Compare commits
6 Commits
82cb0b4034
...
feature
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce46820105 | ||
|
|
012c13c49a | ||
|
|
0e9a0d9123 | ||
| 4f163af846 | |||
|
|
ce495ed6fa | ||
|
|
0e66bb471f |
42
.env.production
Normal file
42
.env.production
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ========================================
|
||||||
|
# 生产环境配置
|
||||||
|
# ========================================
|
||||||
|
# 使用方式: npm run build
|
||||||
|
#
|
||||||
|
# 工作原理:
|
||||||
|
# 1. 此文件专门用于生产环境构建
|
||||||
|
# 2. 构建时会将环境变量嵌入到打包文件中
|
||||||
|
# 3. 确保 PostHog 等服务使用正确的生产配置
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
# 环境标识
|
||||||
|
REACT_APP_ENV=production
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Mock 配置(生产环境禁用 Mock)
|
||||||
|
REACT_APP_ENABLE_MOCK=false
|
||||||
|
|
||||||
|
# 后端 API 地址(生产环境)
|
||||||
|
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||||
|
|
||||||
|
# PostHog 分析配置(生产环境)
|
||||||
|
# PostHog API Key(从 PostHog 项目设置中获取)
|
||||||
|
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||||
|
# PostHog API Host(使用 PostHog Cloud)
|
||||||
|
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||||
|
# 启用会话录制(Session Recording)用于回放用户操作、排查问题
|
||||||
|
REACT_APP_ENABLE_SESSION_RECORDING=true
|
||||||
|
|
||||||
|
# React 构建优化配置
|
||||||
|
# 禁用 source map 生成(生产环境不需要,提升打包速度和安全性)
|
||||||
|
GENERATE_SOURCEMAP=false
|
||||||
|
# 跳过预检查(加快启动速度)
|
||||||
|
SKIP_PREFLIGHT_CHECK=true
|
||||||
|
# 禁用 ESLint 检查(生产构建时不需要)
|
||||||
|
DISABLE_ESLINT_PLUGIN=true
|
||||||
|
# TypeScript 编译错误时继续
|
||||||
|
TSC_COMPILE_ON_ERROR=true
|
||||||
|
# 图片内联大小限制
|
||||||
|
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||||
|
# Node.js 内存限制(适用于大型项目)
|
||||||
|
NODE_OPTIONS=--max_old_space_size=4096
|
||||||
136
app.py
136
app.py
@@ -97,38 +97,6 @@ def get_trading_day_near_date(target_date):
|
|||||||
return trading_days[-1] if trading_days else None
|
return trading_days[-1] if trading_days else None
|
||||||
|
|
||||||
|
|
||||||
def get_previous_trading_day(target_date):
|
|
||||||
"""
|
|
||||||
获取指定日期的上一个交易日
|
|
||||||
如果目标日期不是交易日,先找到对应的交易日,然后返回前一个交易日
|
|
||||||
"""
|
|
||||||
if not trading_days:
|
|
||||||
load_trading_days()
|
|
||||||
|
|
||||||
if not trading_days:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 如果目标日期是datetime,转换为date
|
|
||||||
if isinstance(target_date, datetime):
|
|
||||||
target_date = target_date.date()
|
|
||||||
|
|
||||||
# 确保目标日期是交易日
|
|
||||||
if target_date not in trading_days_set:
|
|
||||||
target_date = get_trading_day_near_date(target_date)
|
|
||||||
if not target_date:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 查找上一个交易日
|
|
||||||
try:
|
|
||||||
index = trading_days.index(target_date)
|
|
||||||
if index > 0:
|
|
||||||
return trading_days[index - 1]
|
|
||||||
else:
|
|
||||||
return None # 没有上一个交易日
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# 应用启动时加载交易日数据
|
# 应用启动时加载交易日数据
|
||||||
load_trading_days()
|
load_trading_days()
|
||||||
|
|
||||||
@@ -2634,13 +2602,9 @@ def get_wechat_qrcode():
|
|||||||
# 生成唯一state参数
|
# 生成唯一state参数
|
||||||
state = uuid.uuid4().hex
|
state = uuid.uuid4().hex
|
||||||
|
|
||||||
print(f"🆕 [QRCODE] 生成新的微信二维码, state={state[:8]}...")
|
|
||||||
|
|
||||||
# URL编码回调地址
|
# URL编码回调地址
|
||||||
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
|
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
|
||||||
|
|
||||||
print(f"🔗 [QRCODE] 回调地址: {WECHAT_REDIRECT_URI}")
|
|
||||||
|
|
||||||
# 构建微信授权URL
|
# 构建微信授权URL
|
||||||
wechat_auth_url = (
|
wechat_auth_url = (
|
||||||
f"https://open.weixin.qq.com/connect/qrconnect?"
|
f"https://open.weixin.qq.com/connect/qrconnect?"
|
||||||
@@ -2658,8 +2622,6 @@ def get_wechat_qrcode():
|
|||||||
'wechat_unionid': None
|
'wechat_unionid': None
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"✅ [QRCODE] session 已存储, 当前总数: {len(wechat_qr_sessions)}")
|
|
||||||
|
|
||||||
return jsonify({"code":0,
|
return jsonify({"code":0,
|
||||||
"data":
|
"data":
|
||||||
{
|
{
|
||||||
@@ -2723,8 +2685,6 @@ def check_wechat_scan():
|
|||||||
del wechat_qr_sessions[session_id]
|
del wechat_qr_sessions[session_id]
|
||||||
return jsonify({'status': 'expired'}), 200
|
return jsonify({'status': 'expired'}), 200
|
||||||
|
|
||||||
print(f"📡 [CHECK] session_id: {session_id[:8]}..., status: {session['status']}, user_info: {session.get('user_info')}")
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': session['status'],
|
'status': session['status'],
|
||||||
'user_info': session.get('user_info'),
|
'user_info': session.get('user_info'),
|
||||||
@@ -2783,17 +2743,12 @@ def wechat_callback():
|
|||||||
|
|
||||||
# 验证state
|
# 验证state
|
||||||
if state not in wechat_qr_sessions:
|
if state not in wechat_qr_sessions:
|
||||||
print(f"❌ [CALLBACK] state 不在 wechat_qr_sessions 中: {state[:8]}...")
|
|
||||||
print(f" 当前 sessions: {list(wechat_qr_sessions.keys())}")
|
|
||||||
return redirect('/auth/signin?error=session_expired')
|
return redirect('/auth/signin?error=session_expired')
|
||||||
|
|
||||||
session_data = wechat_qr_sessions[state]
|
session_data = wechat_qr_sessions[state]
|
||||||
|
|
||||||
print(f"✅ [CALLBACK] 找到 session_data, mode={session_data.get('mode')}")
|
|
||||||
|
|
||||||
# 检查过期
|
# 检查过期
|
||||||
if time.time() > session_data['expires']:
|
if time.time() > session_data['expires']:
|
||||||
print(f"❌ [CALLBACK] session 已过期")
|
|
||||||
del wechat_qr_sessions[state]
|
del wechat_qr_sessions[state]
|
||||||
return redirect('/auth/signin?error=session_expired')
|
return redirect('/auth/signin?error=session_expired')
|
||||||
|
|
||||||
@@ -2822,8 +2777,6 @@ def wechat_callback():
|
|||||||
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
|
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
|
||||||
return redirect('/auth/signin?error=userinfo_failed')
|
return redirect('/auth/signin?error=userinfo_failed')
|
||||||
|
|
||||||
print(f"✅ [CALLBACK] 获取用户信息成功, nickname={user_info.get('nickname', 'N/A')}")
|
|
||||||
|
|
||||||
# 查找或创建用户 / 或处理绑定
|
# 查找或创建用户 / 或处理绑定
|
||||||
openid = token_data['openid']
|
openid = token_data['openid']
|
||||||
unionid = user_info.get('unionid') or token_data.get('unionid')
|
unionid = user_info.get('unionid') or token_data.get('unionid')
|
||||||
@@ -2874,8 +2827,7 @@ def wechat_callback():
|
|||||||
user = User.query.filter_by(wechat_open_id=openid).first()
|
user = User.query.filter_by(wechat_open_id=openid).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
# 创建新用户(自动注册)
|
# 创建新用户
|
||||||
is_new_user = True
|
|
||||||
# 先清理微信昵称
|
# 先清理微信昵称
|
||||||
raw_nickname = user_info.get('nickname', '微信用户')
|
raw_nickname = user_info.get('nickname', '微信用户')
|
||||||
# 创建临时用户实例以使用清理方法
|
# 创建临时用户实例以使用清理方法
|
||||||
@@ -2925,22 +2877,8 @@ def wechat_callback():
|
|||||||
session_item['user_info'] = {'user_id': user.id}
|
session_item['user_info'] = {'user_id': user.id}
|
||||||
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
||||||
|
|
||||||
# 返回一个简单的成功页面(前端轮询会检测到状态变化)
|
# 直接跳转到首页
|
||||||
return '''
|
return redirect('/home')
|
||||||
<html>
|
|
||||||
<head><title>授权成功</title></head>
|
|
||||||
<body>
|
|
||||||
<h2>微信授权成功</h2>
|
|
||||||
<p>请返回原页面继续操作...</p>
|
|
||||||
<script>
|
|
||||||
// 尝试关闭窗口(如果是弹窗的话)
|
|
||||||
setTimeout(function() {
|
|
||||||
window.close();
|
|
||||||
}, 1000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''', 200
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 微信登录失败: {e}")
|
print(f"❌ 微信登录失败: {e}")
|
||||||
@@ -2966,16 +2904,16 @@ def login_with_wechat():
|
|||||||
return jsonify({'success': False, 'error': 'session_id不能为空'}), 400
|
return jsonify({'success': False, 'error': 'session_id不能为空'}), 400
|
||||||
|
|
||||||
# 验证session
|
# 验证session
|
||||||
wechat_session = wechat_qr_sessions.get(session_id)
|
session = wechat_qr_sessions.get(session_id)
|
||||||
if not wechat_session:
|
if not session:
|
||||||
return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400
|
return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400
|
||||||
|
|
||||||
# 检查session状态
|
# 检查session状态
|
||||||
if wechat_session['status'] not in ['login_success', 'register_success']:
|
if session['status'] not in ['login_ready', 'register_ready']:
|
||||||
return jsonify({'success': False, 'error': '会话状态无效'}), 400
|
return jsonify({'success': False, 'error': '会话状态无效'}), 400
|
||||||
|
|
||||||
# 检查是否有用户信息
|
# 检查是否有用户信息
|
||||||
user_info = wechat_session.get('user_info')
|
user_info = session.get('user_info')
|
||||||
if not user_info or not user_info.get('user_id'):
|
if not user_info or not user_info.get('user_id'):
|
||||||
return jsonify({'success': False, 'error': '用户信息不完整'}), 400
|
return jsonify({'success': False, 'error': '用户信息不完整'}), 400
|
||||||
|
|
||||||
@@ -2987,33 +2925,18 @@ def login_with_wechat():
|
|||||||
# 更新最后登录时间
|
# 更新最后登录时间
|
||||||
user.update_last_seen()
|
user.update_last_seen()
|
||||||
|
|
||||||
# 设置 Flask session
|
# 清除session
|
||||||
session.permanent = True
|
|
||||||
session['user_id'] = user.id
|
|
||||||
session['username'] = user.username
|
|
||||||
session['logged_in'] = True
|
|
||||||
session['wechat_login'] = True # 标记是微信登录
|
|
||||||
|
|
||||||
# Flask-Login 登录
|
|
||||||
login_user(user, remember=True)
|
|
||||||
|
|
||||||
# 判断是否为新用户
|
|
||||||
is_new_user = user_info.get('is_new_user', False)
|
|
||||||
|
|
||||||
# 清除 wechat_qr_sessions
|
|
||||||
del wechat_qr_sessions[session_id]
|
del wechat_qr_sessions[session_id]
|
||||||
|
|
||||||
# 生成登录响应
|
# 生成登录响应
|
||||||
response_data = {
|
response_data = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '注册成功' if is_new_user else '登录成功',
|
'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功',
|
||||||
'isNewUser': is_new_user,
|
|
||||||
'user': {
|
'user': {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': user.username,
|
||||||
'nickname': user.nickname or user.username,
|
'nickname': user.nickname or user.username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'phone': user.phone,
|
|
||||||
'avatar_url': user.avatar_url,
|
'avatar_url': user.avatar_url,
|
||||||
'has_wechat': True,
|
'has_wechat': True,
|
||||||
'wechat_open_id': user.wechat_open_id,
|
'wechat_open_id': user.wechat_open_id,
|
||||||
@@ -6627,9 +6550,6 @@ def api_get_events():
|
|||||||
event_status = request.args.get('status', 'active')
|
event_status = request.args.get('status', 'active')
|
||||||
importance = request.args.get('importance', 'all')
|
importance = request.args.get('importance', 'all')
|
||||||
|
|
||||||
# 交易日筛选参数
|
|
||||||
tday = request.args.get('tday') # 交易日,格式:YYYY-MM-DD 或 YYYY/M/D
|
|
||||||
|
|
||||||
# 日期筛选参数
|
# 日期筛选参数
|
||||||
start_date = request.args.get('start_date')
|
start_date = request.args.get('start_date')
|
||||||
end_date = request.args.get('end_date')
|
end_date = request.args.get('end_date')
|
||||||
@@ -6715,41 +6635,7 @@ def api_get_events():
|
|||||||
text(f"JSON_SEARCH(keywords, 'one', '%{search_query}%') IS NOT NULL")
|
text(f"JSON_SEARCH(keywords, 'one', '%{search_query}%') IS NOT NULL")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if recent_days:
|
||||||
# 交易日筛选逻辑
|
|
||||||
if tday:
|
|
||||||
from datetime import datetime, timedelta, time
|
|
||||||
try:
|
|
||||||
# 解析交易日参数,支持 YYYY-MM-DD 和 YYYY/M/D 格式
|
|
||||||
if '/' in tday:
|
|
||||||
target_tday = datetime.strptime(tday, '%Y/%m/%d').date()
|
|
||||||
else:
|
|
||||||
target_tday = datetime.strptime(tday, '%Y-%m-%d').date()
|
|
||||||
|
|
||||||
# 获取该交易日的上一个交易日
|
|
||||||
prev_tday = get_previous_trading_day(target_tday)
|
|
||||||
|
|
||||||
if prev_tday:
|
|
||||||
# 计算时间范围:[前一个交易日 15:00, 当前交易日 15:00]
|
|
||||||
start_datetime = datetime.combine(prev_tday, time(15, 0, 0))
|
|
||||||
end_datetime = datetime.combine(target_tday, time(15, 0, 0))
|
|
||||||
|
|
||||||
query = query.filter(
|
|
||||||
Event.created_at >= start_datetime,
|
|
||||||
Event.created_at <= end_datetime
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 如果没有上一个交易日,则筛选当天的事件
|
|
||||||
start_datetime = datetime.combine(target_tday, time(0, 0, 0))
|
|
||||||
end_datetime = datetime.combine(target_tday, time(15, 0, 0))
|
|
||||||
query = query.filter(
|
|
||||||
Event.created_at >= start_datetime,
|
|
||||||
Event.created_at <= end_datetime
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
# 日期格式错误,忽略该参数
|
|
||||||
app.logger.warning(f"无效的交易日参数: {tday}, 错误: {e}")
|
|
||||||
elif recent_days:
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
cutoff_date = datetime.now() - timedelta(days=recent_days)
|
cutoff_date = datetime.now() - timedelta(days=recent_days)
|
||||||
query = query.filter(Event.created_at >= cutoff_date)
|
query = query.filter(Event.created_at >= cutoff_date)
|
||||||
@@ -6855,8 +6741,6 @@ def api_get_events():
|
|||||||
applied_filters['type'] = event_type
|
applied_filters['type'] = event_type
|
||||||
if importance != 'all':
|
if importance != 'all':
|
||||||
applied_filters['importance'] = importance
|
applied_filters['importance'] = importance
|
||||||
if tday:
|
|
||||||
applied_filters['tday'] = tday
|
|
||||||
if start_date:
|
if start_date:
|
||||||
applied_filters['start_date'] = start_date
|
applied_filters['start_date'] = start_date
|
||||||
if end_date:
|
if end_date:
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
|
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
|
||||||
"dev": "npm start",
|
"dev": "npm start",
|
||||||
"backend": "python app_2.py",
|
"backend": "python app_2.py",
|
||||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses",
|
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
|
||||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||||
"test": "craco test --env=jsdom",
|
"test": "craco test --env=jsdom",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
|
|||||||
@@ -501,26 +501,26 @@ export default function WechatRegister() {
|
|||||||
bg="gray.50"
|
bg="gray.50"
|
||||||
boxShadow="sm" // ✅ 添加轻微阴影
|
boxShadow="sm" // ✅ 添加轻微阴影
|
||||||
>
|
>
|
||||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||||
/* 已获取二维码:显示iframe */
|
/* 已获取二维码:显示iframe */
|
||||||
<iframe
|
<iframe
|
||||||
src={wechatAuthUrl}
|
src={wechatAuthUrl}
|
||||||
title="微信扫码登录"
|
title="微信扫码登录"
|
||||||
width="300"
|
width="300"
|
||||||
height="350"
|
height="350"
|
||||||
scrolling="no" // ✅ 新增:禁止滚动
|
scrolling="no" // ✅ 新增:禁止滚动
|
||||||
|
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
marginLeft: '-5px',
|
marginLeft: '-5px',
|
||||||
pointerEvents: 'auto', // 允许点击 │ │
|
pointerEvents: 'auto', // 允许点击 │ │
|
||||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||||
}}
|
}}
|
||||||
// 使用 onWheel 事件阻止滚动 │ │
|
// 使用 onWheel 事件阻止滚动 │ │
|
||||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||||
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* 未获取:显示占位符 */
|
/* 未获取:显示占位符 */
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Flex,
|
Flex,
|
||||||
Badge,
|
Badge,
|
||||||
useColorModeValue,
|
useColorModeValue
|
||||||
useDisclosure
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
|
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
|
||||||
|
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 桌面版主导航菜单组件
|
* 桌面版主导航菜单组件
|
||||||
@@ -37,11 +37,11 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
// 🎯 初始化导航埋点Hook
|
// 🎯 初始化导航埋点Hook
|
||||||
const navEvents = useNavigationEvents({ component: 'top_nav' });
|
const navEvents = useNavigationEvents({ component: 'top_nav' });
|
||||||
|
|
||||||
// 🎯 为每个菜单创建独立的 useDisclosure Hook
|
// 🎯 为每个菜单创建延迟关闭控制(200ms 延迟)
|
||||||
const { isOpen: isHighFreqOpen, onOpen: onHighFreqOpen, onClose: onHighFreqClose } = useDisclosure();
|
const highFreqMenu = useDelayedMenu({ closeDelay: 200 });
|
||||||
const { isOpen: isMarketReviewOpen, onOpen: onMarketReviewOpen, onClose: onMarketReviewClose } = useDisclosure();
|
const marketReviewMenu = useDelayedMenu({ closeDelay: 200 });
|
||||||
const { isOpen: isAgentCommunityOpen, onOpen: onAgentCommunityOpen, onClose: onAgentCommunityClose } = useDisclosure();
|
const agentCommunityMenu = useDelayedMenu({ closeDelay: 200 });
|
||||||
const { isOpen: isContactUsOpen, onOpen: onContactUsOpen, onClose: onContactUsClose } = useDisclosure();
|
const contactUsMenu = useDelayedMenu({ closeDelay: 200 });
|
||||||
|
|
||||||
// 辅助函数:判断导航项是否激活
|
// 辅助函数:判断导航项是否激活
|
||||||
const isActive = useCallback((paths) => {
|
const isActive = useCallback((paths) => {
|
||||||
@@ -53,7 +53,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
return (
|
return (
|
||||||
<HStack spacing={8}>
|
<HStack spacing={8}>
|
||||||
{/* 高频跟踪 */}
|
{/* 高频跟踪 */}
|
||||||
<Menu isOpen={isHighFreqOpen} onClose={onHighFreqClose}>
|
<Menu isOpen={highFreqMenu.isOpen} onClose={highFreqMenu.onClose}>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -64,18 +64,24 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||||
borderColor="blue.600"
|
borderColor="blue.600"
|
||||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
|
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
|
||||||
onMouseEnter={onHighFreqOpen}
|
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||||
onMouseLeave={onHighFreqClose}
|
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||||
|
onClick={highFreqMenu.handleClick}
|
||||||
>
|
>
|
||||||
高频跟踪
|
高频跟踪
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList minW="260px" p={2} onMouseEnter={onHighFreqOpen}>
|
<MenuList
|
||||||
|
minW="260px"
|
||||||
|
p={2}
|
||||||
|
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||||
|
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||||
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 🎯 追踪菜单项点击
|
// 🎯 追踪菜单项点击
|
||||||
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
||||||
navigate('/community');
|
navigate('/community');
|
||||||
onHighFreqClose(); // 跳转后关闭菜单
|
highFreqMenu.onClose(); // 跳转后关闭菜单
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||||
@@ -96,7 +102,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
// 🎯 追踪菜单项点击
|
// 🎯 追踪菜单项点击
|
||||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
||||||
navigate('/concepts');
|
navigate('/concepts');
|
||||||
onHighFreqClose(); // 跳转后关闭菜单
|
highFreqMenu.onClose(); // 跳转后关闭菜单
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||||
@@ -113,7 +119,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{/* 行情复盘 */}
|
{/* 行情复盘 */}
|
||||||
<Menu isOpen={isMarketReviewOpen} onClose={onMarketReviewClose}>
|
<Menu isOpen={marketReviewMenu.isOpen} onClose={marketReviewMenu.onClose}>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -124,16 +130,22 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||||
borderColor="blue.600"
|
borderColor="blue.600"
|
||||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
|
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
|
||||||
onMouseEnter={onMarketReviewOpen}
|
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||||
onMouseLeave={onMarketReviewClose}
|
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||||
|
onClick={marketReviewMenu.handleClick}
|
||||||
>
|
>
|
||||||
行情复盘
|
行情复盘
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList minW="260px" p={2} onMouseEnter={onMarketReviewOpen}>
|
<MenuList
|
||||||
|
minW="260px"
|
||||||
|
p={2}
|
||||||
|
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||||
|
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||||
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/limit-analyse');
|
navigate('/limit-analyse');
|
||||||
onMarketReviewClose(); // 跳转后关闭菜单
|
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||||
@@ -149,7 +161,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/stocks');
|
navigate('/stocks');
|
||||||
onMarketReviewClose(); // 跳转后关闭菜单
|
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||||
@@ -165,7 +177,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/trading-simulation');
|
navigate('/trading-simulation');
|
||||||
onMarketReviewClose(); // 跳转后关闭菜单
|
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||||
@@ -182,17 +194,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{/* AGENT社群 */}
|
{/* AGENT社群 */}
|
||||||
<Menu isOpen={isAgentCommunityOpen} onClose={onAgentCommunityClose}>
|
<Menu isOpen={agentCommunityMenu.isOpen} onClose={agentCommunityMenu.onClose}>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
rightIcon={<ChevronDownIcon />}
|
rightIcon={<ChevronDownIcon />}
|
||||||
onMouseEnter={onAgentCommunityOpen}
|
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||||
onMouseLeave={onAgentCommunityClose}
|
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||||
|
onClick={agentCommunityMenu.handleClick}
|
||||||
>
|
>
|
||||||
AGENT社群
|
AGENT社群
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList minW="300px" p={4} onMouseEnter={onAgentCommunityOpen}>
|
<MenuList
|
||||||
|
minW="300px"
|
||||||
|
p={4}
|
||||||
|
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||||
|
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||||
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
isDisabled
|
isDisabled
|
||||||
cursor="not-allowed"
|
cursor="not-allowed"
|
||||||
@@ -211,17 +229,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{/* 联系我们 */}
|
{/* 联系我们 */}
|
||||||
<Menu isOpen={isContactUsOpen} onClose={onContactUsClose}>
|
<Menu isOpen={contactUsMenu.isOpen} onClose={contactUsMenu.onClose}>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
rightIcon={<ChevronDownIcon />}
|
rightIcon={<ChevronDownIcon />}
|
||||||
onMouseEnter={onContactUsOpen}
|
onMouseEnter={contactUsMenu.handleMouseEnter}
|
||||||
onMouseLeave={onContactUsClose}
|
onMouseLeave={contactUsMenu.handleMouseLeave}
|
||||||
|
onClick={contactUsMenu.handleClick}
|
||||||
>
|
>
|
||||||
联系我们
|
联系我们
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList minW="260px" p={4} onMouseEnter={onContactUsOpen}>
|
<MenuList
|
||||||
|
minW="260px"
|
||||||
|
p={4}
|
||||||
|
onMouseEnter={contactUsMenu.handleMouseEnter}
|
||||||
|
onMouseLeave={contactUsMenu.handleMouseLeave}
|
||||||
|
>
|
||||||
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
|
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Flex,
|
Flex,
|
||||||
HStack,
|
HStack,
|
||||||
Badge,
|
Badge
|
||||||
useDisclosure
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 平板版"更多"下拉菜单组件
|
* 平板版"更多"下拉菜单组件
|
||||||
@@ -30,8 +30,8 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// 🎯 为"更多"菜单创建 useDisclosure Hook
|
// 🎯 使用延迟关闭菜单控制
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const moreMenu = useDelayedMenu({ closeDelay: 200 });
|
||||||
|
|
||||||
// 辅助函数:判断导航项是否激活
|
// 辅助函数:判断导航项是否激活
|
||||||
const isActive = useCallback((paths) => {
|
const isActive = useCallback((paths) => {
|
||||||
@@ -41,23 +41,29 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
if (!isAuthenticated || !user) return null;
|
if (!isAuthenticated || !user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu isOpen={isOpen} onClose={onClose}>
|
<Menu isOpen={moreMenu.isOpen} onClose={moreMenu.onClose}>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
rightIcon={<ChevronDownIcon />}
|
rightIcon={<ChevronDownIcon />}
|
||||||
fontWeight="medium"
|
fontWeight="medium"
|
||||||
onMouseEnter={onOpen}
|
onMouseEnter={moreMenu.handleMouseEnter}
|
||||||
onMouseLeave={onClose}
|
onMouseLeave={moreMenu.handleMouseLeave}
|
||||||
|
onClick={moreMenu.handleClick}
|
||||||
>
|
>
|
||||||
更多
|
更多
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList minW="300px" p={2} onMouseEnter={onOpen}>
|
<MenuList
|
||||||
|
minW="300px"
|
||||||
|
p={2}
|
||||||
|
onMouseEnter={moreMenu.handleMouseEnter}
|
||||||
|
onMouseLeave={moreMenu.handleMouseLeave}
|
||||||
|
>
|
||||||
{/* 高频跟踪组 */}
|
{/* 高频跟踪组 */}
|
||||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
|
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose(); // 先关闭菜单
|
moreMenu.onClose(); // 先关闭菜单
|
||||||
navigate('/community');
|
navigate('/community');
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
@@ -73,7 +79,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose(); // 先关闭菜单
|
moreMenu.onClose(); // 先关闭菜单
|
||||||
navigate('/concepts');
|
navigate('/concepts');
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
@@ -91,7 +97,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
|
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose(); // 先关闭菜单
|
moreMenu.onClose(); // 先关闭菜单
|
||||||
navigate('/limit-analyse');
|
navigate('/limit-analyse');
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
@@ -104,7 +110,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose(); // 先关闭菜单
|
moreMenu.onClose(); // 先关闭菜单
|
||||||
navigate('/stocks');
|
navigate('/stocks');
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
@@ -117,7 +123,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onClose(); // 先关闭菜单
|
moreMenu.onClose(); // 先关闭菜单
|
||||||
navigate('/trading-simulation');
|
navigate('/trading-simulation');
|
||||||
}}
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
|
|||||||
142
src/hooks/useDelayedMenu.js
Normal file
142
src/hooks/useDelayedMenu.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// src/hooks/useDelayedMenu.js
|
||||||
|
// 导航菜单延迟关闭 Hook - 优化 hover 和 click 交互体验
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Hook:提供带延迟关闭功能的菜单控制
|
||||||
|
*
|
||||||
|
* 解决问题:
|
||||||
|
* 1. 用户快速移动鼠标导致菜单意外关闭
|
||||||
|
* 2. Hover 和 Click 状态冲突
|
||||||
|
* 3. 从 MenuButton 移动到 MenuList 时菜单闪烁
|
||||||
|
*
|
||||||
|
* 功能特性:
|
||||||
|
* - ✅ Hover 进入:立即打开菜单
|
||||||
|
* - ✅ Hover 离开:延迟关闭(默认 200ms)
|
||||||
|
* - ✅ Click 切换:支持点击切换打开/关闭状态
|
||||||
|
* - ✅ 智能取消:再次 hover 进入时取消关闭定时器
|
||||||
|
*
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {number} options.closeDelay - 延迟关闭时间(毫秒),默认 200ms
|
||||||
|
* @returns {Object} 菜单控制对象
|
||||||
|
*/
|
||||||
|
export function useDelayedMenu({ closeDelay = 200 } = {}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const closeTimerRef = useRef(null);
|
||||||
|
const isClickedRef = useRef(false); // 追踪是否通过点击打开
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开菜单
|
||||||
|
* - 立即打开,无延迟
|
||||||
|
* - 清除任何待执行的关闭定时器
|
||||||
|
*/
|
||||||
|
const onOpen = useCallback(() => {
|
||||||
|
// 清除待执行的关闭定时器
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟关闭菜单
|
||||||
|
* - 设置定时器,延迟后关闭
|
||||||
|
* - 如果在延迟期间再次 hover 进入,会被 onOpen 取消
|
||||||
|
*/
|
||||||
|
const onDelayedClose = useCallback(() => {
|
||||||
|
// 如果是点击打开的,hover 离开时不自动关闭
|
||||||
|
if (isClickedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除之前的定时器(防止重复设置)
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置延迟关闭定时器
|
||||||
|
closeTimerRef.current = setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}, closeDelay);
|
||||||
|
}, [closeDelay]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即关闭菜单
|
||||||
|
* - 无延迟,立即关闭
|
||||||
|
* - 清除所有定时器和状态标记
|
||||||
|
*/
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
// 清除定时器
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
isClickedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换菜单状态(用于点击)
|
||||||
|
* - 如果关闭 → 打开,并标记为点击打开
|
||||||
|
* - 如果打开 → 关闭,并清除点击标记
|
||||||
|
*/
|
||||||
|
const onToggle = useCallback(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// 当前已打开 → 关闭
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// 当前已关闭 → 打开
|
||||||
|
onOpen();
|
||||||
|
isClickedRef.current = true; // 标记为点击打开
|
||||||
|
}
|
||||||
|
}, [isOpen, onOpen, onClose]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover 进入处理
|
||||||
|
* - 打开菜单
|
||||||
|
* - 清除点击标记(允许 hover 离开时自动关闭)
|
||||||
|
*/
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
onOpen();
|
||||||
|
isClickedRef.current = false; // 清除点击标记,允许 hover 控制
|
||||||
|
}, [onOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover 离开处理
|
||||||
|
* - 延迟关闭菜单
|
||||||
|
*/
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
onDelayedClose();
|
||||||
|
}, [onDelayedClose]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击处理
|
||||||
|
* - 切换菜单状态
|
||||||
|
*/
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onToggle();
|
||||||
|
}, [onToggle]);
|
||||||
|
|
||||||
|
// 组件卸载时清理定时器
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onDelayedClose,
|
||||||
|
onToggle,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
handleClick,
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -170,6 +170,7 @@ export const fetchDynamicNews = createAsyncThunk(
|
|||||||
async ({
|
async ({
|
||||||
page = 1,
|
page = 1,
|
||||||
per_page = 5,
|
per_page = 5,
|
||||||
|
pageSize = 5, // 每页实际显示的数据量(用于计算索引)
|
||||||
clearCache = false,
|
clearCache = false,
|
||||||
prependMode = false
|
prependMode = false
|
||||||
} = {}, { rejectWithValue }) => {
|
} = {}, { rejectWithValue }) => {
|
||||||
@@ -196,6 +197,9 @@ export const fetchDynamicNews = createAsyncThunk(
|
|||||||
return {
|
return {
|
||||||
events: response.data.events,
|
events: response.data.events,
|
||||||
total: response.data.pagination?.total || 0,
|
total: response.data.pagination?.total || 0,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
pageSize, // 返回 pageSize 用于索引计算
|
||||||
clearCache,
|
clearCache,
|
||||||
prependMode
|
prependMode
|
||||||
};
|
};
|
||||||
@@ -205,6 +209,9 @@ export const fetchDynamicNews = createAsyncThunk(
|
|||||||
return {
|
return {
|
||||||
events: [],
|
events: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
pageSize, // 返回 pageSize 用于索引计算
|
||||||
clearCache,
|
clearCache,
|
||||||
prependMode
|
prependMode
|
||||||
};
|
};
|
||||||
@@ -371,7 +378,7 @@ const communityDataSlice = createSlice({
|
|||||||
state.error.dynamicNews = null;
|
state.error.dynamicNews = null;
|
||||||
})
|
})
|
||||||
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
|
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
|
||||||
const { events, total, clearCache, prependMode } = action.payload;
|
const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload;
|
||||||
|
|
||||||
if (clearCache) {
|
if (clearCache) {
|
||||||
// 清空缓存模式:直接替换
|
// 清空缓存模式:直接替换
|
||||||
@@ -389,14 +396,67 @@ const communityDataSlice = createSlice({
|
|||||||
totalCount: state.dynamicNews.length
|
totalCount: state.dynamicNews.length
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 追加到尾部模式(默认):去重后追加
|
// 智能插入模式:根据页码计算正确的插入位置
|
||||||
const existingIds = new Set(state.dynamicNews.map(e => e.id));
|
// 使用 pageSize(每页显示量)而不是 per_page(请求数量)
|
||||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
const startIndex = (page - 1) * (pageSize || per_page);
|
||||||
state.dynamicNews = [...state.dynamicNews, ...newEvents];
|
|
||||||
logger.debug('CommunityData', '追加新数据到尾部', {
|
// 判断插入模式
|
||||||
newCount: newEvents.length,
|
const isAppend = startIndex === state.dynamicNews.length;
|
||||||
totalCount: state.dynamicNews.length
|
const isReplace = startIndex < state.dynamicNews.length;
|
||||||
});
|
const isJump = startIndex > state.dynamicNews.length;
|
||||||
|
|
||||||
|
// 只在 append 模式下去重(避免定时刷新重复)
|
||||||
|
// 替换和跳页模式直接使用原始数据(避免因去重导致数据丢失)
|
||||||
|
if (isAppend) {
|
||||||
|
// Append 模式:连续加载,需要去重
|
||||||
|
const existingIds = new Set(
|
||||||
|
state.dynamicNews
|
||||||
|
.filter(e => e !== null)
|
||||||
|
.map(e => e.id)
|
||||||
|
);
|
||||||
|
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||||
|
state.dynamicNews = [...state.dynamicNews, ...newEvents];
|
||||||
|
|
||||||
|
logger.debug('CommunityData', '连续追加数据(去重)', {
|
||||||
|
page,
|
||||||
|
startIndex,
|
||||||
|
endIndex: startIndex + newEvents.length,
|
||||||
|
originalEventsCount: events.length,
|
||||||
|
newEventsCount: newEvents.length,
|
||||||
|
filteredCount: events.length - newEvents.length,
|
||||||
|
totalCount: state.dynamicNews.length
|
||||||
|
});
|
||||||
|
} else if (isReplace) {
|
||||||
|
// 替换模式:直接覆盖,不去重
|
||||||
|
const endIndex = startIndex + events.length;
|
||||||
|
const before = state.dynamicNews.slice(0, startIndex);
|
||||||
|
const after = state.dynamicNews.slice(endIndex);
|
||||||
|
state.dynamicNews = [...before, ...events, ...after];
|
||||||
|
|
||||||
|
logger.debug('CommunityData', '替换重叠数据(不去重)', {
|
||||||
|
page,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
eventsCount: events.length,
|
||||||
|
beforeLength: before.length,
|
||||||
|
afterLength: after.length,
|
||||||
|
totalCount: state.dynamicNews.length
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 跳页模式:填充间隔,不去重
|
||||||
|
const gap = startIndex - state.dynamicNews.length;
|
||||||
|
const fillers = Array(gap).fill(null);
|
||||||
|
state.dynamicNews = [...state.dynamicNews, ...fillers, ...events];
|
||||||
|
|
||||||
|
logger.debug('CommunityData', '跳页加载,填充间隔(不去重)', {
|
||||||
|
page,
|
||||||
|
startIndex,
|
||||||
|
endIndex: startIndex + events.length,
|
||||||
|
gap,
|
||||||
|
eventsCount: events.length,
|
||||||
|
totalCount: state.dynamicNews.length
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.dynamicNewsTotal = total;
|
state.dynamicNewsTotal = total;
|
||||||
@@ -449,11 +509,11 @@ export const selectHotEventsWithLoading = (state) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const selectDynamicNewsWithLoading = (state) => ({
|
export const selectDynamicNewsWithLoading = (state) => ({
|
||||||
data: state.communityData.dynamicNews, // 完整缓存列表
|
data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符)
|
||||||
loading: state.communityData.loading.dynamicNews,
|
loading: state.communityData.loading.dynamicNews,
|
||||||
error: state.communityData.error.dynamicNews,
|
error: state.communityData.error.dynamicNews,
|
||||||
total: state.communityData.dynamicNewsTotal, // 服务端总数量
|
total: state.communityData.dynamicNewsTotal, // 服务端总数量
|
||||||
cachedCount: state.communityData.dynamicNews.length, // 已缓存数量
|
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null)
|
||||||
lastUpdated: state.communityData.lastUpdated.dynamicNews
|
lastUpdated: state.communityData.lastUpdated.dynamicNews
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Center,
|
Center,
|
||||||
Spinner,
|
Spinner,
|
||||||
useColorModeValue
|
useColorModeValue,
|
||||||
|
useToast
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { TimeIcon } from '@chakra-ui/icons';
|
import { TimeIcon } from '@chakra-ui/icons';
|
||||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||||
@@ -54,6 +55,7 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
...rest
|
...rest
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const toast = useToast();
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
|
|
||||||
@@ -69,52 +71,243 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
|
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
|
||||||
const [currentPage, setCurrentPage] = useState(1); // 当前页码
|
const [currentPage, setCurrentPage] = useState(1); // 当前页码
|
||||||
|
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
|
||||||
|
|
||||||
// 根据模式决定每页显示数量
|
// 根据模式决定每页显示数量
|
||||||
const pageSize = mode === 'carousel' ? 5 : 10;
|
const pageSize = mode === 'carousel' ? 5 : 10;
|
||||||
|
|
||||||
// 计算总页数(基于缓存数量)
|
// 计算总页数(基于服务端总数据量)
|
||||||
const totalPages = Math.ceil(cachedCount / pageSize) || 1;
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||||
|
|
||||||
// 检查是否还有更多数据
|
// 检查是否还有更多数据
|
||||||
const hasMore = cachedCount < total;
|
const hasMore = cachedCount < total;
|
||||||
|
|
||||||
// 从缓存中切片获取当前页数据
|
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
||||||
const currentPageEvents = useMemo(() => {
|
const currentPageEvents = useMemo(() => {
|
||||||
const startIndex = (currentPage - 1) * pageSize;
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
const endIndex = startIndex + pageSize;
|
const endIndex = startIndex + pageSize;
|
||||||
return allCachedEvents.slice(startIndex, endIndex);
|
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
|
||||||
}, [allCachedEvents, currentPage, pageSize]);
|
}, [allCachedEvents, currentPage, pageSize]);
|
||||||
|
|
||||||
// 检查是否需要请求更多数据
|
// 翻页处理(智能预加载)
|
||||||
const shouldFetchMore = useCallback((targetPage) => {
|
const handlePageChange = useCallback(async (newPage) => {
|
||||||
const requiredCount = targetPage * pageSize;
|
// 🔍 诊断日志 - 记录翻页开始状态
|
||||||
// 如果缓存不足,且服务端还有更多数据
|
console.log('[handlePageChange] 开始翻页', {
|
||||||
return cachedCount < requiredCount && hasMore;
|
currentPage,
|
||||||
}, [cachedCount, total, pageSize, hasMore]);
|
newPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
total,
|
||||||
|
allCachedEventsLength: allCachedEvents.length,
|
||||||
|
cachedCount
|
||||||
|
});
|
||||||
|
|
||||||
// 翻页处理
|
// 0. 首先检查目标页数据是否已完整缓存
|
||||||
const handlePageChange = useCallback((newPage) => {
|
const targetPageStartIndex = (newPage - 1) * pageSize;
|
||||||
// 向后翻页(上一页):不请求,直接切换
|
const targetPageEndIndex = targetPageStartIndex + pageSize;
|
||||||
if (newPage < currentPage) {
|
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
|
||||||
|
const validTargetData = targetPageData.filter(e => e !== null);
|
||||||
|
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
|
||||||
|
const isTargetPageCached = validTargetData.length >= expectedCount;
|
||||||
|
|
||||||
|
console.log('[handlePageChange] 目标页缓存检查', {
|
||||||
|
newPage,
|
||||||
|
targetPageStartIndex,
|
||||||
|
targetPageEndIndex,
|
||||||
|
targetPageDataLength: targetPageData.length,
|
||||||
|
validTargetDataLength: validTargetData.length,
|
||||||
|
expectedCount,
|
||||||
|
isTargetPageCached
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
|
||||||
|
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
|
||||||
|
|
||||||
|
// 2. 计算预加载范围
|
||||||
|
let preloadRange;
|
||||||
|
if (isSequentialNavigation) {
|
||||||
|
// 连续翻页:前后各2页(共5页)
|
||||||
|
const start = Math.max(1, newPage - 2);
|
||||||
|
const end = Math.min(totalPages, newPage + 2);
|
||||||
|
preloadRange = Array.from(
|
||||||
|
{ length: end - start + 1 },
|
||||||
|
(_, i) => start + i
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 跳转翻页:只加载当前页
|
||||||
|
preloadRange = [newPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
|
||||||
|
const missingPages = preloadRange.filter(page => {
|
||||||
|
const pageStartIndex = (page - 1) * pageSize;
|
||||||
|
const pageEndIndex = pageStartIndex + pageSize;
|
||||||
|
|
||||||
|
// 如果该页超出数组范围,说明未缓存
|
||||||
|
if (pageEndIndex > allCachedEvents.length) {
|
||||||
|
console.log(`[missingPages] 页面${page}超出数组范围`, {
|
||||||
|
pageStartIndex,
|
||||||
|
pageEndIndex,
|
||||||
|
allCachedEventsLength: allCachedEvents.length
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查该页的数据是否包含 null 占位符或数据不足
|
||||||
|
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
|
||||||
|
const validData = pageData.filter(e => e !== null);
|
||||||
|
const expectedCount = Math.min(pageSize, total - pageStartIndex);
|
||||||
|
const hasNullOrIncomplete = validData.length < expectedCount;
|
||||||
|
|
||||||
|
console.log(`[missingPages] 页面${page}检查`, {
|
||||||
|
pageStartIndex,
|
||||||
|
pageEndIndex,
|
||||||
|
pageDataLength: pageData.length,
|
||||||
|
validDataLength: validData.length,
|
||||||
|
expectedCount,
|
||||||
|
hasNullOrIncomplete
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasNullOrIncomplete;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[handlePageChange] 缺失页面检测完成', {
|
||||||
|
preloadRange,
|
||||||
|
missingPages,
|
||||||
|
missingPagesCount: missingPages.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
|
||||||
|
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
|
||||||
|
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
缺失页面: missingPages,
|
||||||
|
目标页已缓存: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 立即切换页码(用户无感知延迟)
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
return;
|
|
||||||
|
// 在后台静默预加载其他缺失页面(拆分为单页请求)
|
||||||
|
try {
|
||||||
|
console.log('[DynamicNewsCard] 开始后台预加载', {
|
||||||
|
缺失页面: missingPages,
|
||||||
|
每页数量: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
||||||
|
for (const page of missingPages) {
|
||||||
|
await dispatch(fetchDynamicNews({
|
||||||
|
page: page,
|
||||||
|
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
||||||
|
pageSize: pageSize,
|
||||||
|
clearCache: false
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DynamicNewsCard] 后台预加载全部完成', {
|
||||||
|
预加载页面: missingPages
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DynamicNewsCard] 后台预加载失败', error);
|
||||||
|
// 静默失败,不影响用户体验
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // 提前返回,不执行下面的加载逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向前翻页(下一页):检查是否需要请求
|
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
|
||||||
if (shouldFetchMore(newPage)) {
|
if (missingPages.length > 0 && hasMore) {
|
||||||
// 计算需要请求的页码(从缓存末尾继续)
|
console.log('[DynamicNewsCard] 目标页未缓存,显示loading', {
|
||||||
const nextFetchPage = Math.ceil(cachedCount / pageSize) + 1;
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
|
||||||
|
预加载范围: preloadRange,
|
||||||
|
缺失页面: missingPages,
|
||||||
|
每页数量: pageSize,
|
||||||
|
目标页已缓存: false
|
||||||
|
});
|
||||||
|
|
||||||
dispatch(fetchDynamicNews({
|
try {
|
||||||
page: nextFetchPage,
|
// 设置加载状态(显示"正在加载第X页...")
|
||||||
per_page: pageSize,
|
setLoadingPage(newPage);
|
||||||
clearCache: false
|
|
||||||
}));
|
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
||||||
|
for (const page of missingPages) {
|
||||||
|
console.log(`[DynamicNewsCard] 开始加载第 ${page} 页`);
|
||||||
|
|
||||||
|
await dispatch(fetchDynamicNews({
|
||||||
|
page: page,
|
||||||
|
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
||||||
|
pageSize: pageSize, // 传递原始 pageSize,用于正确计算索引
|
||||||
|
clearCache: false
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
|
||||||
|
缺失页面: missingPages
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据加载成功后才更新当前页码
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DynamicNewsCard] 翻页加载失败', error);
|
||||||
|
|
||||||
|
// 显示错误提示
|
||||||
|
toast({
|
||||||
|
title: '加载失败',
|
||||||
|
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载失败时不更新页码,保持在当前页
|
||||||
|
} finally {
|
||||||
|
// 清除加载状态
|
||||||
|
setLoadingPage(null);
|
||||||
|
}
|
||||||
|
} else if (missingPages.length === 0) {
|
||||||
|
// 只有在确实不需要加载时才直接切换
|
||||||
|
console.log('[handlePageChange] 无需加载,直接切换', {
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
preloadRange,
|
||||||
|
missingPages,
|
||||||
|
reason: '所有页面均已缓存'
|
||||||
|
});
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
} else {
|
||||||
|
// 理论上不应该到这里(missingPages.length > 0 但 hasMore=false)
|
||||||
|
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
|
||||||
|
missingPages,
|
||||||
|
hasMore,
|
||||||
|
currentPage,
|
||||||
|
newPage,
|
||||||
|
total,
|
||||||
|
cachedCount
|
||||||
|
});
|
||||||
|
|
||||||
|
// 尝试切换页码,但可能会显示空数据
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '数据不完整',
|
||||||
|
description: `第 ${newPage} 页数据可能不完整`,
|
||||||
|
status: 'warning',
|
||||||
|
duration: 2000,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
|
||||||
setCurrentPage(newPage);
|
|
||||||
}, [currentPage, cachedCount, pageSize, shouldFetchMore, dispatch]);
|
|
||||||
|
|
||||||
// 模式切换处理
|
// 模式切换处理
|
||||||
const handleModeToggle = useCallback((newMode) => {
|
const handleModeToggle = useCallback((newMode) => {
|
||||||
@@ -125,17 +318,22 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
|
|
||||||
const newPageSize = newMode === 'carousel' ? 5 : 10;
|
const newPageSize = newMode === 'carousel' ? 5 : 10;
|
||||||
|
|
||||||
// 检查缓存是否足够显示第1页
|
// 检查第1页的数据是否完整(排除 null)
|
||||||
if (cachedCount < newPageSize) {
|
const firstPageData = allCachedEvents.slice(0, newPageSize);
|
||||||
// 清空缓存,重新请求
|
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
|
||||||
|
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
|
||||||
|
|
||||||
|
if (needsRefetch) {
|
||||||
|
// 第1页数据不完整,清空缓存重新请求
|
||||||
dispatch(fetchDynamicNews({
|
dispatch(fetchDynamicNews({
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: newPageSize,
|
per_page: newPageSize,
|
||||||
|
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
|
||||||
clearCache: true
|
clearCache: true
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// 如果缓存足够,不发起请求,直接切换
|
// 如果第1页数据完整,不发起请求,直接切换
|
||||||
}, [mode, cachedCount, dispatch]);
|
}, [mode, allCachedEvents, total, dispatch]);
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,6 +341,7 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
dispatch(fetchDynamicNews({
|
dispatch(fetchDynamicNews({
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 5,
|
per_page: 5,
|
||||||
|
pageSize: 5, // 传递 pageSize 确保索引计算一致
|
||||||
clearCache: true
|
clearCache: true
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -201,7 +400,8 @@ const DynamicNewsCard = forwardRef(({
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
loading={loading}
|
loading={loadingPage !== null}
|
||||||
|
loadingPage={loadingPage}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onModeChange={handleModeToggle}
|
onModeChange={handleModeToggle}
|
||||||
eventFollowStatus={eventFollowStatus}
|
eventFollowStatus={eventFollowStatus}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Center,
|
Center,
|
||||||
VStack,
|
VStack,
|
||||||
|
HStack,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
useColorModeValue
|
useColorModeValue
|
||||||
@@ -28,7 +29,8 @@ import PaginationControl from './PaginationControl';
|
|||||||
* @param {number} currentPage - 当前页码
|
* @param {number} currentPage - 当前页码
|
||||||
* @param {number} totalPages - 总页数(由服务端返回)
|
* @param {number} totalPages - 总页数(由服务端返回)
|
||||||
* @param {Function} onPageChange - 页码改变回调
|
* @param {Function} onPageChange - 页码改变回调
|
||||||
* @param {boolean} loading - 加载状态
|
* @param {boolean} loading - 全局加载状态
|
||||||
|
* @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页...")
|
||||||
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
|
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
|
||||||
* @param {Function} onModeChange - 模式切换回调
|
* @param {Function} onModeChange - 模式切换回调
|
||||||
* @param {boolean} hasMore - 是否还有更多数据
|
* @param {boolean} hasMore - 是否还有更多数据
|
||||||
@@ -52,20 +54,37 @@ const EventScrollList = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
|
|
||||||
// 时间轴样式配置
|
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
|
||||||
|
const timelineBg = useColorModeValue('gray.50', 'gray.700');
|
||||||
|
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
|
||||||
|
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
|
||||||
|
|
||||||
|
// 翻页按钮颜色
|
||||||
|
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
||||||
|
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
||||||
|
|
||||||
|
// 滚动条颜色
|
||||||
|
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||||
|
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||||
|
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||||
|
|
||||||
|
// 加载遮罩颜色
|
||||||
|
const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700');
|
||||||
|
const loadingTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||||
|
|
||||||
const getTimelineBoxStyle = () => {
|
const getTimelineBoxStyle = () => {
|
||||||
return {
|
return {
|
||||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
bg: timelineBg,
|
||||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
borderColor: timelineBorderColor,
|
||||||
borderWidth: '2px',
|
borderWidth: '2px',
|
||||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
textColor: timelineTextColor,
|
||||||
boxShadow: 'sm',
|
boxShadow: 'sm',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器(右) */}
|
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
|
||||||
<Flex justify="space-between" align="center" mb={2}>
|
<Flex justify="space-between" align="center" mb={2}>
|
||||||
{/* 模式切换按钮 */}
|
{/* 模式切换按钮 */}
|
||||||
<ButtonGroup size="sm" isAttached>
|
<ButtonGroup size="sm" isAttached>
|
||||||
@@ -87,11 +106,11 @@ const EventScrollList = ({
|
|||||||
|
|
||||||
{/* 分页控制器 */}
|
{/* 分页控制器 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<PaginationControl
|
<PaginationControl
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
@@ -114,10 +133,10 @@ const EventScrollList = ({
|
|||||||
h="40px"
|
h="40px"
|
||||||
minW="40px"
|
minW="40px"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')}
|
bg={arrowBtnBg}
|
||||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'),
|
bg: arrowBtnHoverBg,
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||||
transform: 'translateY(-50%) scale(1.05)'
|
transform: 'translateY(-50%) scale(1.05)'
|
||||||
}}
|
}}
|
||||||
@@ -142,10 +161,10 @@ const EventScrollList = ({
|
|||||||
h="40px"
|
h="40px"
|
||||||
minW="40px"
|
minW="40px"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')}
|
bg={arrowBtnBg}
|
||||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'),
|
bg: arrowBtnHoverBg,
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||||
transform: 'translateY(-50%) scale(1.05)'
|
transform: 'translateY(-50%) scale(1.05)'
|
||||||
}}
|
}}
|
||||||
@@ -169,15 +188,15 @@ const EventScrollList = ({
|
|||||||
height: '8px',
|
height: '8px',
|
||||||
},
|
},
|
||||||
'&::-webkit-scrollbar-track': {
|
'&::-webkit-scrollbar-track': {
|
||||||
background: useColorModeValue('#f1f1f1', '#2D3748'),
|
background: scrollbarTrackBg,
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
},
|
},
|
||||||
'&::-webkit-scrollbar-thumb': {
|
'&::-webkit-scrollbar-thumb': {
|
||||||
background: useColorModeValue('#888', '#4A5568'),
|
background: scrollbarThumbBg,
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
},
|
},
|
||||||
'&::-webkit-scrollbar-thumb:hover': {
|
'&::-webkit-scrollbar-thumb:hover': {
|
||||||
background: useColorModeValue('#555', '#718096'),
|
background: scrollbarThumbHoverBg,
|
||||||
},
|
},
|
||||||
scrollBehavior: 'smooth',
|
scrollBehavior: 'smooth',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
@@ -191,14 +210,14 @@ const EventScrollList = ({
|
|||||||
left={0}
|
left={0}
|
||||||
right={0}
|
right={0}
|
||||||
bottom={0}
|
bottom={0}
|
||||||
bg={useColorModeValue('whiteAlpha.800', 'blackAlpha.700')}
|
bg={loadingOverlayBg}
|
||||||
backdropFilter="blur(2px)"
|
backdropFilter="blur(2px)"
|
||||||
zIndex={10}
|
zIndex={10}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
>
|
>
|
||||||
<VStack>
|
<VStack>
|
||||||
<Spinner size="lg" color="blue.500" thickness="3px" />
|
<Spinner size="lg" color="blue.500" thickness="3px" />
|
||||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>
|
<Text fontSize="sm" color={loadingTextColor}>
|
||||||
加载中...
|
加载中...
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
Reference in New Issue
Block a user