Compare commits
82 Commits
feature_bu
...
53fbda44e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fbda44e6 | ||
|
|
540b938525 | ||
|
|
8fe11efcd7 | ||
|
|
e753437b86 | ||
|
|
a6f69418f6 | ||
|
|
dfdd2f4134 | ||
|
|
4c79871ab4 | ||
| f8eb268341 | |||
| 665f5e8416 | |||
| be2da54d82 | |||
| 8bf4a0b6c6 | |||
| 412b2c03ed | |||
| 899500007d | |||
| d3879b3840 | |||
| 80fe74c041 | |||
| 78f7dca1f6 | |||
| 03aee75235 | |||
| 8eff6b1a95 | |||
| 80676dd622 | |||
| 082e644534 | |||
| b0b227a5ef | |||
| 691c4f6eb1 | |||
| d5a55c4e02 | |||
| 27cdf0aecd | |||
| 4a1157c0b6 | |||
| f515dc94f4 | |||
| 683e261756 | |||
| 8bdfd0389c | |||
| eae495ac34 | |||
| 958cedefb8 | |||
|
|
1fc9f4790f | ||
| b48ff99658 | |||
| ae558996b6 | |||
| 71742c0116 | |||
| 2ead50c37c | |||
| 9e8519bb94 | |||
| a4d16e7686 | |||
|
|
3eb31c99dc | ||
|
|
5f6b4b083b | ||
|
|
905023c056 | ||
|
|
25cc28e03b | ||
|
|
5f9901a098 | ||
|
|
28643d7c4a | ||
|
|
bb28e141e6 | ||
|
|
8fa273c8d4 | ||
|
|
17c04211bb | ||
|
|
c9419d3c14 | ||
|
|
dfc13c5737 | ||
|
|
de8d0ef1c3 | ||
|
|
65c16d65ac | ||
|
|
13a291b979 | ||
|
|
4d6da77aeb | ||
|
|
fc1f667700 | ||
|
|
46639030bb | ||
|
|
f747a0bdb2 | ||
|
|
9b55610167 | ||
|
|
a93fcfa9b9 | ||
|
|
8914a46c40 | ||
|
|
678eb6838e | ||
|
|
c06d3a88ae | ||
|
|
307c308739 | ||
|
|
cbb6517bb1 | ||
|
|
f33489f5d7 | ||
|
|
9ff77b570d | ||
|
|
de37546ddb | ||
|
|
163c55f819 | ||
|
|
990d1ca0bc | ||
|
|
3fe2d2bdc9 | ||
|
|
a9f0c5ced2 | ||
|
|
9b355b402d | ||
|
|
3cadd02492 | ||
|
|
d69a32a320 | ||
|
|
8d3327e4dd | ||
|
|
3a02c13dfe | ||
| d28915ac90 | |||
| b2f3a8f140 | |||
| 3014317c12 | |||
| 2013a0f868 | |||
| 05b497de29 | |||
|
|
d9013d1e85 | ||
| 2753fbc37f | |||
| 43de7f7a52 |
@@ -18,10 +18,3 @@ REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 开发环境标识
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# PostHog 配置(开发环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
11
.env.mock
11
.env.mock
@@ -35,14 +35,3 @@ REACT_APP_ENABLE_MOCK=true
|
||||
|
||||
# Mock 环境标识
|
||||
REACT_APP_ENV=mock
|
||||
|
||||
# PostHog 配置(Mock 环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# PostHog Debug 模式(Mock 环境永久启用)
|
||||
# 在浏览器 Console 中打印详细的事件追踪日志
|
||||
REACT_APP_POSTHOG_DEBUG=true
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
# ========================================
|
||||
# 生产环境配置
|
||||
# ========================================
|
||||
# 使用方式: npm run build
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. 此文件专门用于生产环境构建
|
||||
# 2. 构建时会将环境变量嵌入到打包文件中
|
||||
# 3. 确保 PostHog 等服务使用正确的生产配置
|
||||
# ========================================
|
||||
|
||||
# 环境标识
|
||||
REACT_APP_ENV=production
|
||||
@@ -17,13 +10,8 @@ NODE_ENV=production
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 🔧 调试模式(生产环境临时调试用)
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
|
||||
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
|
||||
# 使用方法:
|
||||
# 1. 设置为 true 并重新构建
|
||||
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
|
||||
# 3. 调试完成后设置为 false 并重新构建
|
||||
REACT_APP_ENABLE_DEBUG=true
|
||||
# 开启后会在全局暴露 window.__DEBUG__
|
||||
REACT_APP_ENABLE_DEBUG=false
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
@@ -49,20 +37,3 @@ TSC_COMPILE_ON_ERROR=true
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
# Node.js 内存限制(适用于大型项目)
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
# ========================================
|
||||
# Bytedesk 客服系统配置
|
||||
# ========================================
|
||||
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
|
||||
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
|
||||
# Nginx 配置:location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
|
||||
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
|
||||
|
||||
# 组织 UUID(从管理后台 -> 设置 -> 组织信息 -> 组织UUID)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组 UUID(从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
|
||||
# 客服类型(2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,6 +48,8 @@ Thumbs.db
|
||||
*.md
|
||||
!README.md
|
||||
!CLAUDE.md
|
||||
!docs/**/*.md
|
||||
|
||||
# 忽略 docs 目录(开发文档不提交到 Git)
|
||||
docs/
|
||||
|
||||
src/assets/img/original-backup/
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
**前端**
|
||||
- **核心框架**: React 18.3.1
|
||||
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
|
||||
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **UI 组件库**: Chakra UI 2.10.9(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **状态管理**: Redux Toolkit 2.9.2
|
||||
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
|
||||
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化
|
||||
|
||||
581
app.py
581
app.py
@@ -570,6 +570,28 @@ class User(UserMixin, db.Model):
|
||||
'is_authenticated': True
|
||||
}
|
||||
|
||||
# 获取用户订阅信息(从 user_subscriptions 表)
|
||||
subscription = UserSubscription.query.filter_by(user_id=self.id).first()
|
||||
if subscription:
|
||||
data.update({
|
||||
'subscription_type': subscription.subscription_type,
|
||||
'subscription_status': subscription.subscription_status,
|
||||
'billing_cycle': subscription.billing_cycle,
|
||||
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
|
||||
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
|
||||
'auto_renewal': subscription.auto_renewal
|
||||
})
|
||||
else:
|
||||
# 无订阅时使用默认值
|
||||
data.update({
|
||||
'subscription_type': 'free',
|
||||
'subscription_status': 'inactive',
|
||||
'billing_cycle': None,
|
||||
'start_date': None,
|
||||
'end_date': None,
|
||||
'auto_renewal': False
|
||||
})
|
||||
|
||||
# 敏感信息只在需要时包含
|
||||
if include_sensitive:
|
||||
data.update({
|
||||
@@ -1127,36 +1149,61 @@ def get_user_subscription_safe(user_id):
|
||||
|
||||
|
||||
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
|
||||
"""激活用户订阅
|
||||
"""
|
||||
激活用户订阅(新版:续费时从当前订阅结束时间开始延长)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
plan_type: 套餐类型
|
||||
billing_cycle: 计费周期
|
||||
extend_from_now: 是否从当前时间开始延长(用于升级场景)
|
||||
plan_type: 套餐类型 (pro/max)
|
||||
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
extend_from_now: 废弃参数,保留以兼容(现在自动判断)
|
||||
|
||||
Returns:
|
||||
UserSubscription 对象 或 None
|
||||
"""
|
||||
try:
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
# 新用户,创建订阅记录
|
||||
subscription = UserSubscription(user_id=user_id)
|
||||
db.session.add(subscription)
|
||||
|
||||
# 更新订阅类型和状态
|
||||
subscription.subscription_type = plan_type
|
||||
subscription.subscription_status = 'active'
|
||||
subscription.billing_cycle = billing_cycle
|
||||
|
||||
if not extend_from_now or not subscription.start_date:
|
||||
subscription.start_date = beijing_now()
|
||||
# 计算订阅周期天数
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90, # 3个月
|
||||
'semiannual': 180, # 6个月
|
||||
'yearly': 365
|
||||
}
|
||||
days = cycle_days_map.get(billing_cycle, 30)
|
||||
|
||||
if billing_cycle == 'monthly':
|
||||
subscription.end_date = beijing_now() + timedelta(days=30)
|
||||
else: # yearly
|
||||
subscription.end_date = beijing_now() + timedelta(days=365)
|
||||
now = beijing_now()
|
||||
|
||||
# 判断是新购还是续费
|
||||
if subscription.end_date and subscription.end_date > now:
|
||||
# 续费:从当前订阅结束时间开始延长
|
||||
start_date = subscription.end_date
|
||||
end_date = start_date + timedelta(days=days)
|
||||
else:
|
||||
# 新购或过期后重新购买:从当前时间开始
|
||||
start_date = now
|
||||
end_date = now + timedelta(days=days)
|
||||
subscription.start_date = start_date
|
||||
|
||||
subscription.end_date = end_date
|
||||
subscription.updated_at = now
|
||||
|
||||
subscription.updated_at = beijing_now()
|
||||
db.session.commit()
|
||||
return subscription
|
||||
|
||||
except Exception as e:
|
||||
print(f"激活订阅失败: {e}")
|
||||
db.session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -1233,33 +1280,29 @@ def calculate_discount(promo_code, amount):
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_remaining_value(subscription, current_plan):
|
||||
"""计算当前订阅的剩余价值"""
|
||||
try:
|
||||
if not subscription or not subscription.end_date:
|
||||
return 0
|
||||
def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""
|
||||
简化版价格计算:续费用户和新用户价格完全一致,不计算剩余价值
|
||||
|
||||
now = beijing_now()
|
||||
if subscription.end_date <= now:
|
||||
return 0
|
||||
|
||||
days_left = (subscription.end_date - now).days
|
||||
|
||||
if subscription.billing_cycle == 'monthly':
|
||||
daily_value = float(current_plan.monthly_price) / 30
|
||||
else: # yearly
|
||||
daily_value = float(current_plan.yearly_price) / 365
|
||||
|
||||
return daily_value * days_left
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""计算升级所需价格
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
to_plan_name: 目标套餐名称 (pro/max)
|
||||
to_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
promo_code: 优惠码(可选)
|
||||
|
||||
Returns:
|
||||
dict: 包含价格计算结果的字典
|
||||
dict: {
|
||||
'is_renewal': False/True, # 是否为续费
|
||||
'subscription_type': 'new'/'renew', # 订阅类型
|
||||
'current_plan': 'pro', # 当前套餐(如果有)
|
||||
'current_cycle': 'yearly', # 当前周期(如果有)
|
||||
'new_plan_price': 2699.00, # 新套餐价格
|
||||
'original_amount': 2699.00, # 原价
|
||||
'discount_amount': 0, # 优惠金额
|
||||
'final_amount': 2699.00, # 实付金额
|
||||
'promo_code': None, # 使用的优惠码
|
||||
'promo_error': None # 优惠码错误信息
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 1. 获取当前订阅
|
||||
@@ -1270,83 +1313,176 @@ def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
if not to_plan:
|
||||
return {'error': '目标套餐不存在'}
|
||||
|
||||
# 3. 计算目标套餐价格
|
||||
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
|
||||
# 3. 根据计费周期获取价格
|
||||
# 优先从 pricing_options 获取价格
|
||||
price = None
|
||||
if to_plan.pricing_options:
|
||||
try:
|
||||
pricing_opts = json.loads(to_plan.pricing_options)
|
||||
# 查找匹配的周期
|
||||
for opt in pricing_opts:
|
||||
cycle_key = opt.get('cycle_key', '')
|
||||
months = opt.get('months', 0)
|
||||
|
||||
# 4. 如果是新订阅(非升级)
|
||||
if not current_sub or current_sub.subscription_type == 'free':
|
||||
result = {
|
||||
'is_upgrade': False,
|
||||
'new_plan_price': new_price,
|
||||
'remaining_value': 0,
|
||||
'upgrade_amount': new_price,
|
||||
'original_amount': new_price,
|
||||
'discount_amount': 0,
|
||||
'final_amount': new_price,
|
||||
'promo_code': None
|
||||
}
|
||||
# 匹配逻辑
|
||||
if (cycle_key == to_cycle or
|
||||
(to_cycle == 'monthly' and months == 1) or
|
||||
(to_cycle == 'quarterly' and months == 3) or
|
||||
(to_cycle == 'semiannual' and months == 6) or
|
||||
(to_cycle == 'yearly' and months == 12)):
|
||||
price = float(opt.get('price', 0))
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# 应用优惠码
|
||||
if promo_code:
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, new_price)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = new_price - discount
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
# 如果 pricing_options 中没有找到,使用旧的 monthly_price/yearly_price
|
||||
if price is None:
|
||||
if to_cycle == 'yearly':
|
||||
price = float(to_plan.yearly_price) if to_plan.yearly_price else 0
|
||||
else: # 默认月付
|
||||
price = float(to_plan.monthly_price) if to_plan.monthly_price else 0
|
||||
|
||||
return result
|
||||
if price <= 0:
|
||||
return {'error': f'{to_cycle} 周期价格未配置'}
|
||||
|
||||
# 5. 升级场景:计算剩余价值
|
||||
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
|
||||
if not current_plan:
|
||||
return {'error': '当前套餐信息不存在'}
|
||||
# 4. 判断订阅类型和计算价格
|
||||
is_renewal = False
|
||||
is_upgrade = False
|
||||
is_downgrade = False
|
||||
subscription_type = 'new'
|
||||
current_plan = None
|
||||
current_cycle = None
|
||||
remaining_value = 0
|
||||
final_price = price
|
||||
|
||||
remaining_value = calculate_remaining_value(current_sub, current_plan)
|
||||
if current_sub and current_sub.subscription_type in ['pro', 'max']:
|
||||
current_plan = current_sub.subscription_type
|
||||
current_cycle = current_sub.billing_cycle
|
||||
|
||||
# 6. 计算升级差价
|
||||
upgrade_amount = max(0, new_price - remaining_value)
|
||||
if current_plan == to_plan_name:
|
||||
# 同级续费:延长时长,全价购买
|
||||
is_renewal = True
|
||||
subscription_type = 'renew'
|
||||
elif current_plan == 'pro' and to_plan_name == 'max':
|
||||
# 升级:Pro → Max,需要计算差价
|
||||
is_upgrade = True
|
||||
subscription_type = 'upgrade'
|
||||
|
||||
# 7. 判断升级类型
|
||||
upgrade_type = 'new'
|
||||
if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle:
|
||||
upgrade_type = 'both'
|
||||
elif current_sub.subscription_type != to_plan_name:
|
||||
upgrade_type = 'plan_upgrade'
|
||||
elif current_sub.billing_cycle != to_cycle:
|
||||
upgrade_type = 'cycle_change'
|
||||
# 计算当前订阅的剩余价值
|
||||
if current_sub.end_date and current_sub.end_date > datetime.utcnow():
|
||||
# 获取当前套餐的原始价格
|
||||
current_plan_obj = SubscriptionPlan.query.filter_by(name=current_plan, is_active=True).first()
|
||||
if current_plan_obj:
|
||||
current_price = None
|
||||
|
||||
# 优先从 pricing_options 获取价格
|
||||
if current_plan_obj.pricing_options:
|
||||
try:
|
||||
pricing_opts = json.loads(current_plan_obj.pricing_options)
|
||||
|
||||
# 如果 current_cycle 为空或无效,根据剩余天数推断计费周期
|
||||
if not current_cycle or current_cycle.strip() == '':
|
||||
remaining_days_total = (current_sub.end_date - current_sub.start_date).days if current_sub.start_date else 365
|
||||
|
||||
# 根据总天数推断计费周期
|
||||
if remaining_days_total <= 35:
|
||||
inferred_cycle = 'monthly'
|
||||
elif remaining_days_total <= 100:
|
||||
inferred_cycle = 'quarterly'
|
||||
elif remaining_days_total <= 200:
|
||||
inferred_cycle = 'semiannual'
|
||||
else:
|
||||
inferred_cycle = 'yearly'
|
||||
else:
|
||||
inferred_cycle = current_cycle
|
||||
|
||||
for opt in pricing_opts:
|
||||
if opt.get('cycle_key') == inferred_cycle:
|
||||
current_price = float(opt.get('price', 0))
|
||||
current_cycle = inferred_cycle # 更新周期信息
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# 如果 pricing_options 中没找到,使用 yearly_price 作为默认
|
||||
if current_price is None or current_price <= 0:
|
||||
current_price = float(current_plan_obj.yearly_price) if current_plan_obj.yearly_price else 0
|
||||
current_cycle = 'yearly'
|
||||
|
||||
if current_price and current_price > 0:
|
||||
# 计算剩余天数
|
||||
remaining_days = (current_sub.end_date - datetime.utcnow()).days
|
||||
|
||||
# 计算总天数
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
total_days = cycle_days_map.get(current_cycle, 365)
|
||||
|
||||
# 计算剩余价值
|
||||
if total_days > 0 and remaining_days > 0:
|
||||
remaining_value = current_price * (remaining_days / total_days)
|
||||
# 实付金额 = 新套餐价格 - 剩余价值
|
||||
final_price = max(0, price - remaining_value)
|
||||
|
||||
# 如果剩余价值 >= 新套餐价格,标记为免费升级
|
||||
if remaining_value >= price:
|
||||
final_price = 0
|
||||
elif current_plan == 'max' and to_plan_name == 'pro':
|
||||
# 降级:Max → Pro,到期后切换,全价购买
|
||||
is_downgrade = True
|
||||
subscription_type = 'downgrade'
|
||||
else:
|
||||
# 其他情况视为新购
|
||||
subscription_type = 'new'
|
||||
|
||||
# 5. 构建结果
|
||||
result = {
|
||||
'is_upgrade': True,
|
||||
'upgrade_type': upgrade_type,
|
||||
'current_plan': current_sub.subscription_type,
|
||||
'current_cycle': current_sub.billing_cycle,
|
||||
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None,
|
||||
'new_plan_price': new_price,
|
||||
'remaining_value': remaining_value,
|
||||
'upgrade_amount': upgrade_amount,
|
||||
'original_amount': upgrade_amount,
|
||||
'is_renewal': is_renewal,
|
||||
'is_upgrade': is_upgrade,
|
||||
'is_downgrade': is_downgrade,
|
||||
'subscription_type': subscription_type,
|
||||
'current_plan': current_plan,
|
||||
'current_cycle': current_cycle,
|
||||
'new_plan_price': price,
|
||||
'original_price': price, # 新套餐原价
|
||||
'remaining_value': remaining_value, # 当前订阅剩余价值(仅升级时有效)
|
||||
'original_amount': price,
|
||||
'discount_amount': 0,
|
||||
'final_amount': upgrade_amount,
|
||||
'promo_code': None
|
||||
'final_amount': final_price,
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
|
||||
# 8. 应用优惠码
|
||||
if promo_code and upgrade_amount > 0:
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id)
|
||||
# 6. 应用优惠码(基于差价后的金额)
|
||||
if promo_code and promo_code.strip():
|
||||
# 优惠码作用于差价后的金额
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, final_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, upgrade_amount)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = upgrade_amount - discount
|
||||
discount = calculate_discount(promo, final_price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = final_price - float(discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
return {'error': f'价格计算失败: {str(e)}'}
|
||||
|
||||
|
||||
# 保留旧函数以兼容(标记为废弃)
|
||||
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""
|
||||
【已废弃】旧版升级价格计算函数,保留以兼容旧代码
|
||||
新代码请使用 calculate_subscription_price_simple
|
||||
"""
|
||||
# 直接调用新函数
|
||||
return calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code)
|
||||
|
||||
|
||||
def initialize_subscription_plans_safe():
|
||||
@@ -1594,7 +1730,33 @@ def validate_promo_code_api():
|
||||
|
||||
@app.route('/api/subscription/calculate-price', methods=['POST'])
|
||||
def calculate_subscription_price():
|
||||
"""计算订阅价格(支持升级和优惠码)"""
|
||||
"""
|
||||
计算订阅价格(新版:续费和新购价格一致)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"to_plan": "pro",
|
||||
"to_cycle": "yearly",
|
||||
"promo_code": "WELCOME2025" // 可选
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"is_renewal": true, // 是否为续费
|
||||
"subscription_type": "renew", // new 或 renew
|
||||
"current_plan": "pro", // 当前套餐(如果有)
|
||||
"current_cycle": "monthly", // 当前周期(如果有)
|
||||
"new_plan_price": 2699.00,
|
||||
"original_amount": 2699.00,
|
||||
"discount_amount": 0,
|
||||
"final_amount": 2699.00,
|
||||
"promo_code": null,
|
||||
"promo_error": null
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
@@ -1602,13 +1764,13 @@ def calculate_subscription_price():
|
||||
data = request.get_json()
|
||||
to_plan = data.get('to_plan')
|
||||
to_cycle = data.get('to_cycle')
|
||||
promo_code = data.get('promo_code', '').strip() or None
|
||||
promo_code = (data.get('promo_code') or '').strip() or None
|
||||
|
||||
if not to_plan or not to_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 计算价格
|
||||
result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code)
|
||||
# 使用新的简化价格计算函数
|
||||
result = calculate_subscription_price_simple(session['user_id'], to_plan, to_cycle, promo_code)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify({
|
||||
@@ -1628,9 +1790,101 @@ def calculate_subscription_price():
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/subscription/free-upgrade', methods=['POST'])
|
||||
@login_required
|
||||
def free_upgrade_subscription():
|
||||
"""
|
||||
免费升级订阅(当剩余价值 >= 新套餐价格时)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_name": "max",
|
||||
"billing_cycle": "yearly"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
|
||||
if not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
user_id = current_user.id
|
||||
|
||||
# 计算价格,验证是否可以免费升级
|
||||
price_result = calculate_subscription_price_simple(user_id, plan_name, billing_cycle, None)
|
||||
|
||||
if 'error' in price_result:
|
||||
return jsonify({'success': False, 'error': price_result['error']}), 400
|
||||
|
||||
# 检查是否为升级且实付金额为0
|
||||
if not price_result.get('is_upgrade') or price_result.get('final_amount', 1) > 0:
|
||||
return jsonify({'success': False, 'error': '当前情况不符合免费升级条件'}), 400
|
||||
|
||||
# 获取当前订阅
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
return jsonify({'success': False, 'error': '未找到订阅记录'}), 404
|
||||
|
||||
# 计算新的到期时间(按剩余价值折算)
|
||||
remaining_value = price_result.get('remaining_value', 0)
|
||||
new_plan_price = price_result.get('new_plan_price', 0)
|
||||
|
||||
if new_plan_price > 0:
|
||||
# 计算可以兑换的新套餐天数
|
||||
value_ratio = remaining_value / new_plan_price
|
||||
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
new_cycle_days = cycle_days_map.get(billing_cycle, 365)
|
||||
|
||||
# 新的到期天数 = 周期天数 × 价值比例
|
||||
new_days = int(new_cycle_days * value_ratio)
|
||||
|
||||
# 更新订阅信息
|
||||
subscription.subscription_type = plan_name
|
||||
subscription.billing_cycle = billing_cycle
|
||||
subscription.start_date = datetime.utcnow()
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=new_days)
|
||||
subscription.subscription_status = 'active'
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'升级成功!您的{plan_name.upper()}版本将持续{new_days}天',
|
||||
'data': {
|
||||
'subscription_type': plan_name,
|
||||
'end_date': subscription.end_date.isoformat(),
|
||||
'days': new_days
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': '价格计算异常'}), 500
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': f'升级失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@app.route('/api/payment/create-order', methods=['POST'])
|
||||
def create_payment_order():
|
||||
"""创建支付订单(支持升级和优惠码)"""
|
||||
"""
|
||||
创建支付订单(新版:简化逻辑,不再记录升级)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_name": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
"promo_code": "WELCOME2025" // 可选
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
@@ -1638,21 +1892,28 @@ def create_payment_order():
|
||||
data = request.get_json()
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
promo_code = data.get('promo_code', '').strip() or None
|
||||
promo_code = (data.get('promo_code') or '').strip() or None
|
||||
|
||||
if not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 计算价格(包括升级和优惠码)
|
||||
price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code)
|
||||
# 使用新的简化价格计算
|
||||
price_result = calculate_subscription_price_simple(session['user_id'], plan_name, billing_cycle, promo_code)
|
||||
|
||||
if 'error' in price_result:
|
||||
return jsonify({'success': False, 'error': price_result['error']}), 400
|
||||
|
||||
amount = price_result['final_amount']
|
||||
original_amount = price_result['original_amount']
|
||||
discount_amount = price_result['discount_amount']
|
||||
is_upgrade = price_result.get('is_upgrade', False)
|
||||
subscription_type = price_result.get('subscription_type', 'new') # new 或 renew
|
||||
|
||||
# 检查是否为免费升级(金额为0)
|
||||
if amount <= 0 and price_result.get('is_upgrade'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '当前剩余价值可直接免费升级,请使用免费升级功能',
|
||||
'should_free_upgrade': True,
|
||||
'price_info': price_result
|
||||
}), 400
|
||||
|
||||
# 创建订单
|
||||
try:
|
||||
@@ -1663,48 +1924,23 @@ def create_payment_order():
|
||||
amount=amount
|
||||
)
|
||||
|
||||
# 添加扩展字段(使用动态属性)
|
||||
if hasattr(order, 'original_amount') or True: # 兼容性检查
|
||||
order.original_amount = original_amount
|
||||
order.discount_amount = discount_amount
|
||||
order.is_upgrade = is_upgrade
|
||||
# 添加订阅类型标记(用于前端展示)
|
||||
order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅"
|
||||
|
||||
# 如果使用了优惠码,关联优惠码
|
||||
if promo_code and price_result.get('promo_code'):
|
||||
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
|
||||
if promo_obj:
|
||||
# 如果使用了优惠码,关联优惠码
|
||||
if promo_code and price_result.get('promo_code'):
|
||||
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
|
||||
if promo_obj:
|
||||
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
|
||||
# 如果没有该字段,这行会报错,可以注释掉
|
||||
try:
|
||||
order.promo_code_id = promo_obj.id
|
||||
|
||||
# 如果是升级,记录原套餐信息
|
||||
if is_upgrade:
|
||||
order.upgrade_from_plan = price_result.get('current_plan')
|
||||
except:
|
||||
pass # 如果表中没有该字段,跳过
|
||||
|
||||
db.session.add(order)
|
||||
db.session.commit()
|
||||
|
||||
# 如果是升级订单,创建升级记录
|
||||
if is_upgrade and price_result.get('upgrade_type'):
|
||||
try:
|
||||
upgrade_record = SubscriptionUpgrade(
|
||||
user_id=session['user_id'],
|
||||
order_id=order.id,
|
||||
from_plan=price_result['current_plan'],
|
||||
from_cycle=price_result['current_cycle'],
|
||||
from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None,
|
||||
to_plan=plan_name,
|
||||
to_cycle=billing_cycle,
|
||||
to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30),
|
||||
remaining_value=price_result['remaining_value'],
|
||||
upgrade_amount=price_result['upgrade_amount'],
|
||||
actual_amount=amount,
|
||||
upgrade_type=price_result['upgrade_type']
|
||||
)
|
||||
db.session.add(upgrade_record)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"创建升级记录失败: {e}")
|
||||
# 不影响主流程
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
|
||||
@@ -3424,8 +3660,20 @@ def login_with_wechat():
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
# 清除session
|
||||
del wechat_qr_sessions[session_id]
|
||||
# ✅ 修复:不立即删除session,而是标记为已完成,避免轮询报错
|
||||
# 原因:前端可能还在轮询检查状态,立即删除会导致 "无效的session" 错误
|
||||
# 保留原状态(login_ready/register_ready),前端会正确处理
|
||||
# wechat_qr_sessions[session_id]['status'] 保持不变
|
||||
|
||||
# 设置延迟删除(10秒后自动清理,给前端足够时间完成轮询)
|
||||
import threading
|
||||
def delayed_cleanup():
|
||||
import time
|
||||
time.sleep(10)
|
||||
if session_id in wechat_qr_sessions:
|
||||
del wechat_qr_sessions[session_id]
|
||||
print(f"✅ 延迟清理微信登录session: {session_id[:8]}...")
|
||||
threading.Thread(target=delayed_cleanup, daemon=True).start()
|
||||
|
||||
# 生成登录响应
|
||||
response_data = {
|
||||
@@ -3442,7 +3690,8 @@ def login_with_wechat():
|
||||
'wechat_union_id': user.wechat_union_id,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'last_seen': user.last_seen.isoformat() if user.last_seen else None
|
||||
}
|
||||
},
|
||||
'isNewUser': session['status'] == 'register_ready' # 标记是否为新用户
|
||||
}
|
||||
|
||||
# 如果需要token认证,可以在这里生成
|
||||
@@ -4128,6 +4377,52 @@ def get_my_event_comments():
|
||||
return jsonify({'success': True, 'data': [c.to_dict() for c in comments]})
|
||||
|
||||
|
||||
@app.route('/api/account/events/posts', methods=['GET'])
|
||||
def get_my_event_posts():
|
||||
"""获取我在事件上的帖子(Post)- 用于个人中心显示"""
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
try:
|
||||
# 查询当前用户的所有 Post(按创建时间倒序)
|
||||
posts = Post.query.filter_by(
|
||||
user_id=session['user_id'],
|
||||
status='active'
|
||||
).order_by(Post.created_at.desc()).limit(100).all()
|
||||
|
||||
posts_data = []
|
||||
for post in posts:
|
||||
# 获取关联的事件信息
|
||||
event = Event.query.get(post.event_id)
|
||||
event_title = event.title if event else '未知事件'
|
||||
|
||||
# 获取用户信息
|
||||
user = User.query.get(post.user_id)
|
||||
author = user.username if user else '匿名用户'
|
||||
|
||||
# ⚡ 返回格式兼容旧 EventComment.to_dict()
|
||||
posts_data.append({
|
||||
'id': post.id,
|
||||
'event_id': post.event_id,
|
||||
'event_title': event_title, # ⚡ 新增字段(旧 API 没有)
|
||||
'user_id': post.user_id,
|
||||
'author': author, # ⚡ 兼容旧格式(字符串类型)
|
||||
'content': post.content,
|
||||
'title': post.title, # Post 独有字段(可选)
|
||||
'content_type': post.content_type, # Post 独有字段
|
||||
'likes': post.likes_count, # ⚡ 兼容旧字段名
|
||||
'created_at': post.created_at.isoformat(),
|
||||
'updated_at': post.updated_at.isoformat(),
|
||||
'status': post.status,
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'data': posts_data})
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户帖子失败: {e}")
|
||||
return jsonify({'success': False, 'error': '获取帖子失败'}), 500
|
||||
|
||||
|
||||
@app.route('/api/account/future-events/following', methods=['GET'])
|
||||
def get_my_following_future_events():
|
||||
"""获取当前用户关注的未来事件"""
|
||||
|
||||
12556
app.py.backup
12556
app.py.backup
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
968
category_tree_openapi.json
Normal file
968
category_tree_openapi.json
Normal file
@@ -0,0 +1,968 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "化工商品分类树API",
|
||||
"description": "提供SMM和Mysteel化工商品数据的分类树状结构API接口。\n\n## 功能特点\n- 树状数据在服务启动时加载到内存,响应速度快\n- 支持获取完整分类树或按路径查询特定节点\n- SMM数据: 127,509个指标, 最大深度8层\n- Mysteel数据: 272,450个指标, 最大深度10层\n\n## 数据结构\n每个树节点包含:\n- name: 节点名称\n- path: 完整路径(用|分隔)\n- level: 层级深度\n- children: 子节点数组\n- metrics: 该节点下的指标列表(仅叶子节点)\n",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "API Support"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:18827",
|
||||
"description": "本地开发服务器"
|
||||
},
|
||||
{
|
||||
"url": "http://222.128.1.157:18827",
|
||||
"description": "生产服务器"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "搜索",
|
||||
"description": "指标搜索相关接口"
|
||||
},
|
||||
{
|
||||
"name": "分类树",
|
||||
"description": "分类树状结构相关接口"
|
||||
},
|
||||
{
|
||||
"name": "数据查询",
|
||||
"description": "指标时间序列数据查询接口"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/search": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"搜索"
|
||||
],
|
||||
"summary": "搜索化工商品指标",
|
||||
"description": "基于Elasticsearch的多关键词模糊搜索,支持智能分词和相关度排序。\n\n## 功能特点\n- **多关键词搜索**: 支持空格分隔多个关键词,自动AND逻辑组合\n- **模糊匹配**: 自动容错1-2个字符的拼写错误\n- **多字段匹配**: 同时搜索指标名称、分类路径等多个字段\n- **相关度排序**: 自动按匹配度评分排序,最相关的结果排在前面\n- **灵活过滤**: 支持按数据源(SMM/Mysteel)和频率(日/周/月)过滤\n\n## 搜索字段权重\n- 指标名称(metric_name): 权重最高 (3x)\n- 分类层级(category_levels): 权重中等 (2x)\n- 分类路径(category_path): 权重中等 (2x)\n\n## 使用场景\n- 用户输入关键词快速查找指标\n- 自动补全和搜索建议\n- 按类别和数据源筛选指标\n\n## 搜索示例\n- 搜索\"电解液 产量\": 查找包含\"电解液\"和\"产量\"的指标\n- 搜索\"硫酸钴\": 查找所有硫酸钴相关指标\n- 搜索\"焦炭 价格 日\": 查找焦炭日度价格数据\n",
|
||||
"operationId": "searchMetrics",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "keywords",
|
||||
"in": "query",
|
||||
"description": "搜索关键词,支持空格分隔多个词。\n\n示例:\n- \"电解液 产量\" - 查找同时包含这两个词的指标\n- \"硫酸钴\" - 查找硫酸钴相关指标\n- \"焦炭 价格\" - 查找焦炭价格数据\n",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "电解液 产量"
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"description": "数据源过滤(可选)。\n\n- SMM: 上海有色网数据\n- Mysteel: 我的钢铁网数据\n- 不指定: 搜索所有数据源\n",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
]
|
||||
},
|
||||
"example": "SMM"
|
||||
},
|
||||
{
|
||||
"name": "frequency",
|
||||
"in": "query",
|
||||
"description": "数据频率过滤(可选)。\n\n- 日: 日度数据\n- 周: 周度数据\n- 月: 月度数据\n- 不指定: 搜索所有频率\n",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
]
|
||||
},
|
||||
"example": "日"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"in": "query",
|
||||
"description": "返回结果数量限制",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"default": 100
|
||||
},
|
||||
"example": 10
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回搜索结果",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SearchResponse"
|
||||
},
|
||||
"examples": {
|
||||
"基础搜索示例": {
|
||||
"value": {
|
||||
"total": 50,
|
||||
"query": "电解液 产量",
|
||||
"results": [
|
||||
{
|
||||
"source": "SMM",
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国电解液月度产量",
|
||||
"unit": "吨",
|
||||
"frequency": "月",
|
||||
"category_path": "新能源|电解液|产量|SMM中国电解液月度产量",
|
||||
"description": "",
|
||||
"score": 15.8
|
||||
},
|
||||
{
|
||||
"source": "SMM",
|
||||
"metric_id": "12346",
|
||||
"metric_name": "SMM中国电解液周度产量",
|
||||
"unit": "吨",
|
||||
"frequency": "周",
|
||||
"category_path": "新能源|电解液|产量|SMM中国电解液周度产量",
|
||||
"description": "",
|
||||
"score": 14.2
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"过滤搜索示例": {
|
||||
"value": {
|
||||
"total": 15,
|
||||
"query": "硫酸钴",
|
||||
"results": [
|
||||
{
|
||||
"source": "SMM",
|
||||
"metric_id": "23456",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"unit": "吨",
|
||||
"frequency": "月",
|
||||
"category_path": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量",
|
||||
"description": "",
|
||||
"score": 18.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "请求参数错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "keywords参数不能为空"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "搜索服务暂时不可用"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/category-tree": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"分类树"
|
||||
],
|
||||
"summary": "获取分类树(支持深度控制)",
|
||||
"description": "获取指定数据源的分类树状结构,支持深度控制。\n\n## 使用场景\n- 前端树形组件初始化(默认只加载第一层)\n- 懒加载:用户展开时再加载下一层\n- 级联选择器数据源\n\n## 默认行为\n- **默认只返回第一层** (max_depth=1),大幅减少数据传输量\n- SMM第一层约43个节点,Mysteel第一层约2个节点\n- 完整树数据量: SMM约53MB, Mysteel约152MB\n\n## 推荐用法\n1. 首次加载:不传max_depth(默认1层)\n2. 用户点击节点:调用 /api/category-tree/node 获取子节点\n",
|
||||
"operationId": "getCategoryTree",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"description": "数据源类型",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
]
|
||||
},
|
||||
"example": "SMM"
|
||||
},
|
||||
{
|
||||
"name": "max_depth",
|
||||
"in": "query",
|
||||
"description": "返回的最大层级深度\n- 1: 只返回第一层(默认,推荐)\n- 2: 返回前两层\n- 999: 返回完整树(不推荐,数据量大)\n",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": 1
|
||||
},
|
||||
"example": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回分类树",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CategoryTreeResponse"
|
||||
},
|
||||
"examples": {
|
||||
"SMM示例": {
|
||||
"value": {
|
||||
"source": "SMM",
|
||||
"total_metrics": 127509,
|
||||
"tree": [
|
||||
{
|
||||
"name": "农业食品农资",
|
||||
"path": "农业食品农资",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "饲料",
|
||||
"path": "农业食品农资|饲料",
|
||||
"level": 2,
|
||||
"children": [],
|
||||
"metrics": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Mysteel示例": {
|
||||
"value": {
|
||||
"source": "Mysteel",
|
||||
"total_metrics": 272450,
|
||||
"tree": [
|
||||
{
|
||||
"name": "钢铁产业",
|
||||
"path": "钢铁产业",
|
||||
"level": 1,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "未找到指定数据源",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/category-tree/node": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"分类树"
|
||||
],
|
||||
"summary": "获取特定节点及其子树",
|
||||
"description": "根据路径获取树中的特定节点及其所有子节点。\n\n## 使用场景\n- 懒加载:用户点击节点时动态加载子节点\n- 子树查询:获取某个分类下的所有数据\n- 面包屑导航:根据路径定位节点\n\n## 路径格式\n使用竖线(|)分隔层级,例如:\n- 一级: \"钴\"\n- 二级: \"钴|钴化合物\"\n- 三级: \"钴|钴化合物|硫酸钴\"\n",
|
||||
"operationId": "getCategoryTreeNode",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "节点完整路径,用竖线(|)分隔",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "钴|钴化合物|硫酸钴"
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"description": "数据源类型",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
]
|
||||
},
|
||||
"example": "SMM"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回节点数据",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TreeNode"
|
||||
},
|
||||
"example": {
|
||||
"name": "硫酸钴",
|
||||
"path": "钴|钴化合物|硫酸钴",
|
||||
"level": 3,
|
||||
"children": [
|
||||
{
|
||||
"name": "产量",
|
||||
"path": "钴|钴化合物|硫酸钴|产量",
|
||||
"level": 4,
|
||||
"children": [],
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"source": "SMM",
|
||||
"frequency": "月",
|
||||
"unit": "吨",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "未找到指定路径的节点或数据源",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"examples": {
|
||||
"节点不存在": {
|
||||
"value": {
|
||||
"detail": "未找到路径 '钴|不存在的节点' 对应的节点"
|
||||
}
|
||||
},
|
||||
"数据源不存在": {
|
||||
"value": {
|
||||
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/metric-data": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"数据查询"
|
||||
],
|
||||
"summary": "获取指标时间序列数据",
|
||||
"description": "根据指标ID查询历史时间序列数据,自动识别数据源(SMM或Mysteel)。\n\n## 功能特点\n- **自动识别数据源**: 无需指定source参数,系统自动查找\n- **灵活的日期范围**: 支持可选的开始/结束日期过滤\n- **数据限制**: 支持limit参数控制返回数据量\n\n## 日期格式支持\n- YYYY-MM-DD (推荐): \"2024-01-01\"\n- YYYYMMDD: \"20240101\"\n- YYYYMMDDHHmmss: \"20240101000000\"(只取日期部分)\n\n## 使用场景\n- 用户点击树节点查看指标数据\n- 图表展示时间序列数据\n- 数据导出和分析\n",
|
||||
"operationId": "getMetricData",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "metric_id",
|
||||
"in": "query",
|
||||
"description": "指标唯一ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "12345"
|
||||
},
|
||||
{
|
||||
"name": "start_date",
|
||||
"in": "query",
|
||||
"description": "开始日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "2024-01-01"
|
||||
},
|
||||
{
|
||||
"name": "end_date",
|
||||
"in": "query",
|
||||
"description": "结束日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "2024-12-31"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "返回数据条数限制(1-10000)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10000,
|
||||
"default": 100
|
||||
},
|
||||
"example": 100
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回指标数据",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MetricDataResponse"
|
||||
},
|
||||
"examples": {
|
||||
"SMM数据示例": {
|
||||
"value": {
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"source": "SMM",
|
||||
"frequency": "月",
|
||||
"unit": "吨",
|
||||
"data": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"value": 12500.5
|
||||
},
|
||||
{
|
||||
"date": "2024-11-01",
|
||||
"value": 12300.0
|
||||
},
|
||||
{
|
||||
"date": "2024-10-01",
|
||||
"value": 12100.8
|
||||
}
|
||||
],
|
||||
"total_count": 120
|
||||
}
|
||||
},
|
||||
"Mysteel数据示例": {
|
||||
"value": {
|
||||
"metric_id": "A0101010",
|
||||
"metric_name": "唐山焦炭价格",
|
||||
"source": "MYSTEEL",
|
||||
"frequency": "日",
|
||||
"unit": "元/吨",
|
||||
"data": [
|
||||
{
|
||||
"date": "2024-12-20",
|
||||
"value": 2350.0
|
||||
},
|
||||
{
|
||||
"date": "2024-12-19",
|
||||
"value": 2340.0
|
||||
}
|
||||
],
|
||||
"total_count": 365
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "未找到指定指标",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "未找到指标: metric_id=99999"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "请求参数错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "limit参数必须在1-10000之间"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "查询数据失败: [具体错误信息]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"SearchResponse": {
|
||||
"type": "object",
|
||||
"description": "搜索结果响应对象",
|
||||
"required": [
|
||||
"total",
|
||||
"query",
|
||||
"results"
|
||||
],
|
||||
"properties": {
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"description": "搜索结果总数",
|
||||
"example": 50
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "查询关键词",
|
||||
"example": "电解液 产量"
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"description": "指标列表(按相关度评分降序)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MetricInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MetricInfo": {
|
||||
"type": "object",
|
||||
"description": "指标信息对象",
|
||||
"required": [
|
||||
"source",
|
||||
"metric_id",
|
||||
"metric_name",
|
||||
"unit",
|
||||
"frequency",
|
||||
"category_path"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"metric_id": {
|
||||
"type": "string",
|
||||
"description": "指标唯一ID",
|
||||
"example": "12345"
|
||||
},
|
||||
"metric_name": {
|
||||
"type": "string",
|
||||
"description": "指标名称",
|
||||
"example": "SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "数据单位",
|
||||
"example": "吨"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "数据频率",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
],
|
||||
"example": "月"
|
||||
},
|
||||
"category_path": {
|
||||
"type": "string",
|
||||
"description": "完整分类路径(用|分隔)",
|
||||
"example": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "指标描述备注",
|
||||
"example": ""
|
||||
},
|
||||
"score": {
|
||||
"type": "number",
|
||||
"description": "搜索相关度评分(仅搜索结果返回)",
|
||||
"nullable": true,
|
||||
"example": 15.8
|
||||
}
|
||||
}
|
||||
},
|
||||
"CategoryTreeResponse": {
|
||||
"type": "object",
|
||||
"description": "分类树响应对象",
|
||||
"required": [
|
||||
"source",
|
||||
"total_metrics",
|
||||
"tree"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源名称",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"total_metrics": {
|
||||
"type": "integer",
|
||||
"description": "总指标数量",
|
||||
"example": 127509
|
||||
},
|
||||
"tree": {
|
||||
"type": "array",
|
||||
"description": "树的根节点列表",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TreeNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TreeNode": {
|
||||
"type": "object",
|
||||
"description": "树节点对象",
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"level",
|
||||
"has_children"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "节点名称",
|
||||
"example": "钴"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "节点完整路径,用竖线分隔",
|
||||
"example": "钴|钴化合物|硫酸钴"
|
||||
},
|
||||
"level": {
|
||||
"type": "integer",
|
||||
"description": "节点层级深度(从1开始)",
|
||||
"minimum": 1,
|
||||
"example": 3
|
||||
},
|
||||
"has_children": {
|
||||
"type": "boolean",
|
||||
"description": "是否有子节点(用于前端判断是否可展开)",
|
||||
"example": true
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "子节点列表(根据max_depth可能为空数组)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TreeNode"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"description": "该节点下的指标列表(通常只有叶子节点有)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TreeMetric"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TreeMetric": {
|
||||
"type": "object",
|
||||
"description": "树节点中的指标信息",
|
||||
"required": [
|
||||
"metric_id",
|
||||
"metric_name",
|
||||
"source",
|
||||
"frequency",
|
||||
"unit"
|
||||
],
|
||||
"properties": {
|
||||
"metric_id": {
|
||||
"type": "string",
|
||||
"description": "指标唯一ID",
|
||||
"example": "12345"
|
||||
},
|
||||
"metric_name": {
|
||||
"type": "string",
|
||||
"description": "指标名称",
|
||||
"example": "SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "数据频率",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
],
|
||||
"example": "月"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "指标单位",
|
||||
"example": "吨"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "指标描述",
|
||||
"example": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"MetricDataResponse": {
|
||||
"type": "object",
|
||||
"description": "指标数据查询响应对象",
|
||||
"required": [
|
||||
"metric_id",
|
||||
"metric_name",
|
||||
"source",
|
||||
"frequency",
|
||||
"unit",
|
||||
"data",
|
||||
"total_count"
|
||||
],
|
||||
"properties": {
|
||||
"metric_id": {
|
||||
"type": "string",
|
||||
"description": "指标唯一ID",
|
||||
"example": "12345"
|
||||
},
|
||||
"metric_name": {
|
||||
"type": "string",
|
||||
"description": "指标名称",
|
||||
"example": "SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"MYSTEEL"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "数据频率",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
],
|
||||
"example": "月"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "数据单位",
|
||||
"example": "吨"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "时间序列数据点列表(按日期倒序)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DataPoint"
|
||||
}
|
||||
},
|
||||
"total_count": {
|
||||
"type": "integer",
|
||||
"description": "符合条件的数据总条数",
|
||||
"example": 120
|
||||
}
|
||||
}
|
||||
},
|
||||
"DataPoint": {
|
||||
"type": "object",
|
||||
"description": "单个数据点",
|
||||
"required": [
|
||||
"date",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "日期,格式 YYYY-MM-DD",
|
||||
"example": "2024-01-01"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"description": "数值(可能为null)",
|
||||
"nullable": true,
|
||||
"example": 1234.56
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"description": "错误响应对象",
|
||||
"required": [
|
||||
"detail"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "错误详细信息",
|
||||
"example": "未找到数据源 'XXX' 的树状数据"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"SMM完整树示例": {
|
||||
"summary": "SMM完整树结构示例",
|
||||
"value": {
|
||||
"source": "SMM",
|
||||
"total_metrics": 127509,
|
||||
"tree": [
|
||||
{
|
||||
"name": "农业食品农资",
|
||||
"path": "农业食品农资",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "饲料",
|
||||
"path": "农业食品农资|饲料",
|
||||
"level": 2,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "小金属",
|
||||
"path": "小金属",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "钴",
|
||||
"path": "小金属|钴",
|
||||
"level": 2,
|
||||
"children": [
|
||||
{
|
||||
"name": "钴化合物",
|
||||
"path": "小金属|钴|钴化合物",
|
||||
"level": 3,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Mysteel完整树示例": {
|
||||
"summary": "Mysteel完整树结构示例",
|
||||
"value": {
|
||||
"source": "Mysteel",
|
||||
"total_metrics": 272450,
|
||||
"tree": [
|
||||
{
|
||||
"name": "钢铁产业",
|
||||
"path": "钢铁产业",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "原材料",
|
||||
"path": "钢铁产业|原材料",
|
||||
"level": 2,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"节点查询示例": {
|
||||
"summary": "节点查询返回示例",
|
||||
"value": {
|
||||
"name": "钴化合物",
|
||||
"path": "小金属|钴|钴化合物",
|
||||
"level": 3,
|
||||
"children": [
|
||||
{
|
||||
"name": "硫酸钴",
|
||||
"path": "小金属|钴|钴化合物|硫酸钴",
|
||||
"level": 4,
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"source": "SMM",
|
||||
"frequency": "月",
|
||||
"unit": "吨",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,13 @@ module.exports = {
|
||||
priority: 30,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// TradingView Lightweight Charts 单独分离(避免被压缩破坏)
|
||||
lightweightCharts: {
|
||||
test: /[\\/]node_modules[\\/]lightweight-charts[\\/]/,
|
||||
name: 'lightweight-charts',
|
||||
priority: 26,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// 大型图表库分离(echarts, d3, apexcharts 等)
|
||||
charts: {
|
||||
test: /[\\/]node_modules[\\/](echarts|echarts-for-react|apexcharts|react-apexcharts|recharts|d3|d3-.*)[\\/]/,
|
||||
@@ -69,7 +76,7 @@ module.exports = {
|
||||
},
|
||||
// 日期/日历库
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
name: 'calendar-lib',
|
||||
priority: 18,
|
||||
reuseExistingChunk: true,
|
||||
@@ -96,8 +103,43 @@ module.exports = {
|
||||
moduleIds: 'deterministic',
|
||||
// 最小化配置
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
...webpackConfig.optimization.minimizer,
|
||||
],
|
||||
};
|
||||
|
||||
// 配置 Terser 插件,保留 lightweight-charts 的方法名
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map(plugin => {
|
||||
if (plugin.constructor.name === 'TerserPlugin') {
|
||||
const originalOptions = plugin.options || {};
|
||||
const originalTerserOptions = originalOptions.terserOptions || {};
|
||||
const originalMangle = originalTerserOptions.mangle || {};
|
||||
|
||||
// 只保留 TerserPlugin 有效的配置项
|
||||
const validOptions = {
|
||||
test: originalOptions.test,
|
||||
include: originalOptions.include,
|
||||
exclude: originalOptions.exclude,
|
||||
extractComments: originalOptions.extractComments,
|
||||
parallel: originalOptions.parallel,
|
||||
minify: originalOptions.minify,
|
||||
terserOptions: {
|
||||
...originalTerserOptions,
|
||||
keep_classnames: /^(IChartApi|ISeriesApi|Re)$/, // 保留 lightweight-charts 的类名
|
||||
keep_fnames: /^(createChart|addLineSeries|addSeries)$/, // 保留关键方法名
|
||||
mangle: {
|
||||
...originalMangle,
|
||||
reserved: ['createChart', 'addLineSeries', 'addSeries', 'IChartApi', 'ISeriesApi'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new TerserPlugin(validOptions);
|
||||
}
|
||||
return plugin;
|
||||
});
|
||||
|
||||
// 生产环境禁用 source map 以加快构建(可节省 40-60% 时间)
|
||||
webpackConfig.devtool = false;
|
||||
} else {
|
||||
@@ -161,13 +203,8 @@ module.exports = {
|
||||
);
|
||||
}
|
||||
|
||||
// 忽略 moment 的语言包(如果项目使用了 moment)
|
||||
webpackConfig.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^\.\/locale$/,
|
||||
contextRegExp: /moment$/,
|
||||
})
|
||||
);
|
||||
// Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
|
||||
// 如果需要优化,可以只导入需要的语言包
|
||||
|
||||
// ============== Loader 优化 ==============
|
||||
const babelLoaderRule = webpackConfig.module.rules.find(
|
||||
|
||||
918
docs/BYTEDESK_INTEGRATION_GUIDE.md
Normal file
918
docs/BYTEDESK_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# Bytedesk客服系统 - 前端工程师集成手册
|
||||
|
||||
**版本**: v1.0
|
||||
**最后更新**: 2025-01-07
|
||||
**适用项目**: vf_react
|
||||
**后端服务器**: http://43.143.189.195
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [1. 集成概述](#1-集成概述)
|
||||
- [2. 快速开始(5分钟集成)](#2-快速开始5分钟集成)
|
||||
- [3. 详细集成步骤](#3-详细集成步骤)
|
||||
- [4. 配置说明](#4-配置说明)
|
||||
- [5. 高级功能](#5-高级功能)
|
||||
- [6. 样式定制](#6-样式定制)
|
||||
- [7. 故障排查](#7-故障排查)
|
||||
- [8. 常见问题FAQ](#8-常见问题faq)
|
||||
- [9. 性能优化](#9-性能优化)
|
||||
- [10. 安全注意事项](#10-安全注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 1. 集成概述
|
||||
|
||||
### 1.1 什么是Bytedesk客服系统?
|
||||
|
||||
Bytedesk是一个开源的在线客服系统,为您的网站提供实时客户服务功能。本手册将指导您将Bytedesk客服Widget集成到vf_react项目中。
|
||||
|
||||
### 1.2 集成架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ vf_react前端项目 │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ App.jsx │ │
|
||||
│ │ ┌──────────────────────────────────────────────┐ │ │
|
||||
│ │ │ BytedeskWidget组件 │ │ │
|
||||
│ │ │ - 动态加载客服脚本 │ │ │
|
||||
│ │ │ - 显示悬浮客服图标 │ │ │
|
||||
│ │ │ - 处理用户交互 │ │ │
|
||||
│ │ └──────────────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP/WebSocket
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Bytedesk后端服务 (43.143.189.195) │
|
||||
│ - API接口: :9003 │
|
||||
│ - WebSocket: :9885 │
|
||||
│ - Nginx反向代理: :80 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 集成特点
|
||||
|
||||
- ✅ **零侵入**: 不修改vf_react原有代码逻辑
|
||||
- ✅ **即插即用**: 复制文件 + 修改配置即可使用
|
||||
- ✅ **样式隔离**: 使用Shadow DOM,不影响全局样式
|
||||
- ✅ **异步加载**: 不阻塞页面渲染
|
||||
- ✅ **跨页面**: 在所有页面显示客服图标
|
||||
- ✅ **响应式**: 自动适配移动端和PC端
|
||||
|
||||
---
|
||||
|
||||
## 2. 快速开始(5分钟集成)
|
||||
|
||||
### 步骤1: 复制集成文件
|
||||
|
||||
将`bytedesk-integration`文件夹复制到vf_react项目的`src/`目录下:
|
||||
|
||||
```bash
|
||||
# 在vf_react项目根目录执行
|
||||
cd D:\【Git】\vf_react
|
||||
cp -r bytedesk-integration src/
|
||||
```
|
||||
|
||||
文件结构:
|
||||
```
|
||||
vf_react/
|
||||
├── src/
|
||||
│ ├── bytedesk-integration/ # 客服集成文件夹
|
||||
│ │ ├── components/
|
||||
│ │ │ └── BytedeskWidget.jsx # 客服Widget组件
|
||||
│ │ ├── config/
|
||||
│ │ │ └── bytedesk.config.js # 配置文件
|
||||
│ │ ├── App.jsx.example # 集成示例代码
|
||||
│ │ ├── .env.bytedesk.example # 环境变量示例
|
||||
│ │ └── 前端工程师集成手册.md # 本手册
|
||||
│ ├── App.jsx # 您的主App文件
|
||||
│ └── ...
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 步骤2: 配置环境变量
|
||||
|
||||
复制环境变量模板到项目根目录并配置:
|
||||
|
||||
```bash
|
||||
# 复制模板
|
||||
cp src/bytedesk-integration/.env.bytedesk.example .env.local
|
||||
|
||||
# 编辑配置文件
|
||||
vim .env.local
|
||||
```
|
||||
|
||||
**必需配置项**(在.env.local中):
|
||||
```bash
|
||||
# Bytedesk服务器地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织ID(由管理员提供)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID(由管理员提供)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
```
|
||||
|
||||
> **注意**: ORG和SID需要从管理员处获取,或登录后台http://43.143.189.195/admin/查看。
|
||||
|
||||
### 步骤3: 集成到App.jsx
|
||||
|
||||
打开`src/App.jsx`,参考`App.jsx.example`添加以下代码:
|
||||
|
||||
```jsx
|
||||
// 1. 导入组件和配置(在文件顶部添加)
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
// 2. 获取配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的原有代码保持不变 */}
|
||||
|
||||
{/* 3. 添加客服Widget(在return的JSX最后添加) */}
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 步骤4: 启动项目测试
|
||||
|
||||
```bash
|
||||
# 安装依赖(如果需要)
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm start
|
||||
```
|
||||
|
||||
打开浏览器,您应该在页面右下角看到客服图标(💬)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细集成步骤
|
||||
|
||||
### 3.1 文件说明
|
||||
|
||||
#### BytedeskWidget.jsx
|
||||
React组件,负责加载和管理Bytedesk客服Widget。
|
||||
|
||||
**主要功能**:
|
||||
- 动态加载客服脚本(https://www.weiyuai.cn/embed/bytedesk-web.js)
|
||||
- 初始化客服Widget
|
||||
- 生命周期管理(加载、卸载、清理)
|
||||
- 错误处理
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface BytedeskWidgetProps {
|
||||
config: Object; // 配置对象(必需)
|
||||
autoLoad?: boolean; // 是否自动加载(默认true)
|
||||
onLoad?: (bytedesk) => void; // 加载成功回调
|
||||
onError?: (error) => void; // 加载失败回调
|
||||
}
|
||||
```
|
||||
|
||||
#### bytedesk.config.js
|
||||
配置文件,包含客服系统的所有配置项。
|
||||
|
||||
**主要函数**:
|
||||
- `getBytedeskConfig()`: 获取基础配置
|
||||
- `getBytedeskConfigWithUser(user)`: 获取带用户信息的配置
|
||||
- `shouldShowCustomerService(pathname)`: 判断是否在当前页面显示客服
|
||||
|
||||
### 3.2 集成方式选择
|
||||
|
||||
根据您的需求,选择合适的集成方式:
|
||||
|
||||
#### 方式一: 全局集成(推荐)
|
||||
|
||||
**适用场景**: 所有页面都需要客服功能
|
||||
|
||||
```jsx
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 方式二: 按页面显示
|
||||
|
||||
**适用场景**: 只在特定页面显示客服(如排除登录页、支付页)
|
||||
|
||||
```jsx
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
自定义页面规则(修改`bytedesk.config.js`):
|
||||
|
||||
```javascript
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面显示客服
|
||||
const allowedPages = [
|
||||
'/',
|
||||
'/home',
|
||||
'/products',
|
||||
'/pricing',
|
||||
];
|
||||
|
||||
// 在以下页面隐藏客服
|
||||
const blockedPages = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/payment',
|
||||
];
|
||||
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
};
|
||||
```
|
||||
|
||||
#### 方式三: 带用户信息集成
|
||||
|
||||
**适用场景**: 需要将登录用户信息传递给客服端
|
||||
|
||||
```jsx
|
||||
import { useContext } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext';
|
||||
|
||||
function App() {
|
||||
const { user } = useContext(AuthContext);
|
||||
const bytedeskConfig = getBytedeskConfigWithUser(user);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
用户信息格式:
|
||||
```javascript
|
||||
const user = {
|
||||
id: '12345', // 用户ID(必需)
|
||||
name: '张三', // 用户名
|
||||
email: 'user@example.com', // 邮箱
|
||||
mobile: '13800138000', // 手机号
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置说明
|
||||
|
||||
### 4.1 环境变量配置
|
||||
|
||||
在`.env.local`文件中配置(项目根目录):
|
||||
|
||||
```bash
|
||||
# ========== 必需配置 ==========
|
||||
|
||||
# 后端服务地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织ID
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
|
||||
# ========== 可选配置 ==========
|
||||
|
||||
# 客服类型 (2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
# 语言 (zh-cn, en, ja, ko)
|
||||
REACT_APP_BYTEDESK_LOCALE=zh-cn
|
||||
|
||||
# 图标位置 (bottom-right, bottom-left, top-right, top-left)
|
||||
REACT_APP_BYTEDESK_PLACEMENT=bottom-right
|
||||
|
||||
# 图标边距(像素)
|
||||
REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
|
||||
REACT_APP_BYTEDESK_MARGIN_SIDE=20
|
||||
|
||||
# 主题模式 (system, light, dark)
|
||||
REACT_APP_BYTEDESK_THEME_MODE=system
|
||||
|
||||
# 主题色
|
||||
REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
|
||||
|
||||
# 自动弹出(不推荐)
|
||||
REACT_APP_BYTEDESK_AUTO_POPUP=false
|
||||
```
|
||||
|
||||
### 4.2 代码配置
|
||||
|
||||
在`bytedesk.config.js`中直接修改:
|
||||
|
||||
```javascript
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
htmlUrl: 'http://43.143.189.195/chat/',
|
||||
|
||||
// 客服图标位置
|
||||
placement: 'bottom-right',
|
||||
|
||||
// 边距设置
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
|
||||
// 自动弹出
|
||||
autoPopup: false,
|
||||
|
||||
// 语言设置
|
||||
locale: 'zh-cn',
|
||||
|
||||
// 客服图标配置
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: '💬', // 可以使用emoji或图片URL
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
backgroundColor: '#0066FF',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
|
||||
// 聊天配置
|
||||
chatConfig: {
|
||||
org: 'df_org_uid',
|
||||
t: '2', // 2=人工客服, 1=机器人
|
||||
sid: 'df_wg_aftersales',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 高级功能
|
||||
|
||||
### 5.1 多工作组支持
|
||||
|
||||
根据页面显示不同工作组的客服:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
export const getBytedeskConfigByPath = (pathname) => {
|
||||
const config = getBytedeskConfig();
|
||||
|
||||
// 根据路径选择工作组
|
||||
if (pathname.startsWith('/sales')) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
sid: 'df_wg_sales', // 销售组
|
||||
},
|
||||
};
|
||||
} else if (pathname.startsWith('/support')) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
sid: 'df_wg_support', // 技术支持组
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config; // 默认售后组
|
||||
};
|
||||
```
|
||||
|
||||
使用示例:
|
||||
```jsx
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getBytedeskConfigByPath } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const bytedeskConfig = getBytedeskConfigByPath(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 条件性显示
|
||||
|
||||
根据用户登录状态或角色显示客服:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const { user } = useContext(AuthContext);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 只为普通用户显示客服(管理员不显示)
|
||||
const showBytedesk = user && user.role === 'customer';
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 事件回调
|
||||
|
||||
监听客服系统的加载状态:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const handleLoad = (bytedesk) => {
|
||||
console.log('客服系统加载成功', bytedesk);
|
||||
// 可以在这里执行自定义逻辑
|
||||
// 例如: 发送统计事件
|
||||
};
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('客服系统加载失败', error);
|
||||
// 可以在这里显示降级方案
|
||||
// 例如: 显示备用联系方式
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 自定义触发按钮
|
||||
|
||||
隐藏默认图标,使用自定义按钮:
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 隐藏默认图标
|
||||
const bytedeskConfig = {
|
||||
...getBytedeskConfig(),
|
||||
bubbleConfig: {
|
||||
show: false, // 隐藏默认图标
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 自定义按钮 */}
|
||||
<button
|
||||
onClick={() => setShowBytedesk(true)}
|
||||
className="custom-service-btn"
|
||||
>
|
||||
联系客服
|
||||
</button>
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 样式定制
|
||||
|
||||
### 6.1 修改主题色
|
||||
|
||||
在配置中修改主题色:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
theme: {
|
||||
mode: 'light',
|
||||
backgroundColor: '#FF6600', // 您的品牌色
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 修改图标位置
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
placement: 'bottom-left', // 左下角
|
||||
marginBottom: 30, // 距底部30px
|
||||
marginSide: 30, // 距左侧30px
|
||||
```
|
||||
|
||||
### 6.3 使用自定义图标
|
||||
|
||||
使用图片URL替换emoji:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: 'https://yourdomain.com/images/service-icon.png',
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
```
|
||||
|
||||
### 6.4 样式不冲突
|
||||
|
||||
Bytedesk Widget使用Shadow DOM技术,样式完全隔离,不会影响您的全局CSS。
|
||||
|
||||
---
|
||||
|
||||
## 7. 故障排查
|
||||
|
||||
### 7.1 客服图标不显示
|
||||
|
||||
**可能原因**:
|
||||
1. 环境变量未配置
|
||||
2. 配置文件路径错误
|
||||
3. 后端服务未启动
|
||||
4. 脚本加载失败
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 1. 检查.env.local文件是否存在
|
||||
ls -la .env.local
|
||||
|
||||
# 2. 检查环境变量是否加载
|
||||
console.log(process.env.REACT_APP_BYTEDESK_API_URL);
|
||||
|
||||
# 3. 检查后端服务状态
|
||||
curl http://43.143.189.195/api/health
|
||||
|
||||
# 4. 查看浏览器控制台错误
|
||||
# 打开浏览器开发者工具 -> Console标签页
|
||||
```
|
||||
|
||||
### 7.2 连接不上后端
|
||||
|
||||
**检查清单**:
|
||||
```bash
|
||||
# 1. 后端服务是否运行
|
||||
# 联系后端工程师确认docker容器状态
|
||||
|
||||
# 2. 防火墙是否开放
|
||||
# 确认80端口可访问
|
||||
|
||||
# 3. CORS配置
|
||||
# 后端需要在.env.production中添加您的前端地址:
|
||||
# BYTEDESK_CORS_ALLOWED_ORIGINS=http://your-frontend-domain.com
|
||||
```
|
||||
|
||||
### 7.3 ORG或SID错误
|
||||
|
||||
**获取正确配置**:
|
||||
1. 登录管理后台: http://43.143.189.195/admin/
|
||||
2. 导航到"设置" -> "组织信息",复制`组织UID`
|
||||
3. 导航到"客服管理" -> "工作组",复制`工作组ID`
|
||||
4. 更新`.env.local`文件
|
||||
5. 重启开发服务器: `npm start`
|
||||
|
||||
### 7.4 开发环境正常,生产环境异常
|
||||
|
||||
**检查清单**:
|
||||
```bash
|
||||
# 1. 确认生产环境的环境变量
|
||||
# 查看构建时的配置
|
||||
|
||||
# 2. 检查CORS配置
|
||||
# 后端需要添加生产域名到CORS白名单
|
||||
|
||||
# 3. 检查HTTPS/HTTP
|
||||
# 如果前端使用HTTPS,后端也应使用HTTPS
|
||||
|
||||
# 4. 查看生产环境日志
|
||||
npm run build
|
||||
# 检查构建产物中的配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题FAQ
|
||||
|
||||
### Q1: 客服系统会影响页面性能吗?
|
||||
|
||||
**A**: 不会。客服脚本采用异步加载,不会阻塞页面渲染。Widget总大小约50KB(gzip后),首次加载后会被浏览器缓存。
|
||||
|
||||
### Q2: 可以在移动端使用吗?
|
||||
|
||||
**A**: 可以。Bytedesk Widget完全响应式,自动适配移动端和PC端。
|
||||
|
||||
### Q3: 是否支持离线消息?
|
||||
|
||||
**A**: 支持。用户在客服离线时发送的消息会被保存,客服上线后可以查看。
|
||||
|
||||
### Q4: 可以集成到React Native吗?
|
||||
|
||||
**A**: BytedeskWidget是为Web设计的。React Native需要使用Bytedesk的原生SDK(另外提供)。
|
||||
|
||||
### Q5: 如何隐藏特定页面的客服?
|
||||
|
||||
**A**: 使用`shouldShowCustomerService`函数(见3.2节"方式二")。
|
||||
|
||||
### Q6: 可以同时配置多个工作组吗?
|
||||
|
||||
**A**: 可以。参考5.1节"多工作组支持"。
|
||||
|
||||
### Q7: 用户信息是否安全?
|
||||
|
||||
**A**: 是的。所有通信使用WebSocket加密传输,用户信息不会被第三方获取。建议生产环境使用HTTPS。
|
||||
|
||||
### Q8: 是否需要付费?
|
||||
|
||||
**A**: Bytedesk社区版(当前使用)完全免费,License有效期至2040年12月31日。
|
||||
|
||||
---
|
||||
|
||||
## 9. 性能优化
|
||||
|
||||
### 9.1 按需加载
|
||||
|
||||
只在需要时加载客服系统:
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function App() {
|
||||
const [loadBytedesk, setLoadBytedesk] = useState(false);
|
||||
|
||||
// 延迟5秒加载(页面渲染完成后)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoadBytedesk(true);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
{loadBytedesk && (
|
||||
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Lazy Import
|
||||
|
||||
使用React.lazy延迟导入组件:
|
||||
|
||||
```jsx
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const BytedeskWidget = lazy(() => import('./bytedesk-integration/components/BytedeskWidget'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 缓存优化
|
||||
|
||||
客服脚本会自动被浏览器缓存,无需额外配置。
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全注意事项
|
||||
|
||||
### 10.1 环境变量安全
|
||||
|
||||
```bash
|
||||
# ❌ 错误: 不要在代码中硬编码配置
|
||||
const config = {
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
org: 'df_org_uid',
|
||||
};
|
||||
|
||||
# ✅ 正确: 使用环境变量
|
||||
const config = {
|
||||
apiUrl: process.env.REACT_APP_BYTEDESK_API_URL,
|
||||
org: process.env.REACT_APP_BYTEDESK_ORG,
|
||||
};
|
||||
```
|
||||
|
||||
### 10.2 敏感信息保护
|
||||
|
||||
```javascript
|
||||
// ❌ 不要传递敏感信息
|
||||
const user = {
|
||||
id: '12345',
|
||||
password: 'user-password', // 不要传递密码
|
||||
creditCard: '1234-5678', // 不要传递信用卡
|
||||
};
|
||||
|
||||
// ✅ 只传递必要信息
|
||||
const user = {
|
||||
id: '12345',
|
||||
name: '张三',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
```
|
||||
|
||||
### 10.3 HTTPS使用
|
||||
|
||||
生产环境强烈建议使用HTTPS:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 生产环境
|
||||
REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
|
||||
```
|
||||
|
||||
### 10.4 内容安全策略(CSP)
|
||||
|
||||
如果您的项目使用CSP,需要允许以下域名:
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' https://www.weiyuai.cn;
|
||||
connect-src 'self' http://43.143.189.195;
|
||||
img-src 'self' data: http://43.143.189.195;
|
||||
"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 获取帮助
|
||||
|
||||
### 11.1 联系方式
|
||||
|
||||
- **技术支持**: 访问 http://43.143.189.195/chat/ 在线咨询
|
||||
- **管理员**: 联系您的项目管理员获取ORG和SID
|
||||
- **后端工程师**: 联系后端团队确认服务器状态
|
||||
|
||||
### 11.2 日志查看
|
||||
|
||||
```javascript
|
||||
// 在浏览器控制台查看Bytedesk日志
|
||||
// 日志前缀为 [Bytedesk]
|
||||
|
||||
// 示例:
|
||||
[Bytedesk] 开始加载客服Widget...
|
||||
[Bytedesk] Widget脚本加载成功
|
||||
[Bytedesk] 初始化Widget
|
||||
[Bytedesk] Widget初始化成功
|
||||
```
|
||||
|
||||
### 11.3 调试技巧
|
||||
|
||||
```javascript
|
||||
// 1. 检查配置是否正确
|
||||
console.log('Bytedesk配置:', getBytedeskConfig());
|
||||
|
||||
// 2. 检查环境变量
|
||||
console.log('API URL:', process.env.REACT_APP_BYTEDESK_API_URL);
|
||||
console.log('ORG:', process.env.REACT_APP_BYTEDESK_ORG);
|
||||
console.log('SID:', process.env.REACT_APP_BYTEDESK_SID);
|
||||
|
||||
// 3. 检查Widget是否加载
|
||||
console.log('BytedeskWeb对象:', window.BytedeskWeb);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 版本历史
|
||||
|
||||
| 版本 | 日期 | 更新内容 |
|
||||
|------|------|---------|
|
||||
| v1.0 | 2025-01-07 | 初始版本,支持基础集成功能 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 附录
|
||||
|
||||
### 13.1 完整配置示例
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js - 完整配置
|
||||
export const bytedeskConfig = {
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
htmlUrl: 'http://43.143.189.195/chat/',
|
||||
placement: 'bottom-right',
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
autoPopup: false,
|
||||
locale: 'zh-cn',
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: '💬',
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
theme: {
|
||||
mode: 'system',
|
||||
backgroundColor: '#0066FF',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
chatConfig: {
|
||||
org: 'df_org_uid',
|
||||
t: '2',
|
||||
sid: 'df_wg_aftersales',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 13.2 文件清单
|
||||
|
||||
集成所需的所有文件:
|
||||
|
||||
```
|
||||
bytedesk-integration/
|
||||
├── components/
|
||||
│ └── BytedeskWidget.jsx # React组件(必需)
|
||||
├── config/
|
||||
│ └── bytedesk.config.js # 配置文件(必需)
|
||||
├── App.jsx.example # 集成示例(参考)
|
||||
├── .env.bytedesk.example # 环境变量示例(参考)
|
||||
└── 前端工程师集成手册.md # 本手册(参考)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**祝您集成顺利!**
|
||||
|
||||
如有任何问题,请随时联系技术支持。
|
||||
@@ -1,626 +0,0 @@
|
||||
# 通知系统增强功能 - 使用指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本指南介绍通知系统的三大增强功能:
|
||||
1. **智能桌面通知** - 自动请求权限,系统级通知
|
||||
2. **性能监控** - 追踪推送效果,数据驱动优化
|
||||
3. **历史记录** - 持久化存储,随时查询
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能 1:智能桌面通知
|
||||
|
||||
### 功能说明
|
||||
|
||||
首次收到重要/紧急通知时,自动请求浏览器通知权限,确保用户不错过关键信息。
|
||||
|
||||
### 工作原理
|
||||
|
||||
```javascript
|
||||
// 在 NotificationContext 中的逻辑
|
||||
if (priority === URGENT || priority === IMPORTANT) {
|
||||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
||||
// 首次遇到重要通知,自动请求权限
|
||||
await requestBrowserPermission();
|
||||
setHasRequestedPermission(true); // 避免重复请求
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 权限状态
|
||||
|
||||
- **granted**: 已授权,可以发送桌面通知
|
||||
- **denied**: 已拒绝,无法发送桌面通知
|
||||
- **default**: 未请求,首次重要通知时会自动请求
|
||||
|
||||
### 使用示例
|
||||
|
||||
**自动触发**(推荐)
|
||||
```javascript
|
||||
// 无需任何代码,系统自动处理
|
||||
// 首次收到重要/紧急通知时会自动弹出权限请求
|
||||
```
|
||||
|
||||
**手动请求**
|
||||
```javascript
|
||||
import { useNotification } from 'contexts/NotificationContext';
|
||||
|
||||
function SettingsPage() {
|
||||
const { requestBrowserPermission, browserPermission } = useNotification();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>当前状态: {browserPermission}</p>
|
||||
<button onClick={requestBrowserPermission}>
|
||||
开启桌面通知
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 通知分发策略
|
||||
|
||||
| 优先级 | 页面在前台 | 页面在后台 |
|
||||
|-------|----------|----------|
|
||||
| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
|
||||
| 重要 | 网页通知 | 桌面通知 |
|
||||
| 普通 | 网页通知 | 网页通知 |
|
||||
|
||||
### 测试步骤
|
||||
|
||||
1. **清除已保存的权限状态**
|
||||
```javascript
|
||||
localStorage.removeItem('browser_notification_requested');
|
||||
```
|
||||
|
||||
2. **刷新页面**
|
||||
|
||||
3. **触发一个重要/紧急通知**
|
||||
- Mock 模式:等待自动推送
|
||||
- Real 模式:创建测试事件
|
||||
|
||||
4. **观察权限请求弹窗**
|
||||
- 浏览器会弹出通知权限请求
|
||||
- 点击"允许"授权
|
||||
|
||||
5. **验证桌面通知**
|
||||
- 切换到其他标签页
|
||||
- 收到重要通知时应该看到桌面通知
|
||||
|
||||
---
|
||||
|
||||
## 📊 功能 2:性能监控
|
||||
|
||||
### 功能说明
|
||||
|
||||
追踪通知推送的各项指标,包括:
|
||||
- **到达率**: 发送 vs 接收
|
||||
- **点击率**: 点击 vs 接收
|
||||
- **响应时间**: 收到通知到点击的平均时间
|
||||
- **类型分布**: 各类型通知的数量和效果
|
||||
- **时段分布**: 每小时推送量
|
||||
|
||||
### API 参考
|
||||
|
||||
#### 获取汇总统计
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
|
||||
const summary = notificationMetricsService.getSummary();
|
||||
console.log(summary);
|
||||
/* 输出:
|
||||
{
|
||||
totalSent: 100,
|
||||
totalReceived: 98,
|
||||
totalClicked: 45,
|
||||
totalDismissed: 53,
|
||||
avgResponseTime: 5200, // 毫秒
|
||||
clickRate: '45.92', // 百分比
|
||||
deliveryRate: '98.00' // 百分比
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取按类型统计
|
||||
|
||||
```javascript
|
||||
const byType = notificationMetricsService.getByType();
|
||||
console.log(byType);
|
||||
/* 输出:
|
||||
{
|
||||
announcement: { sent: 20, received: 20, clicked: 15, dismissed: 5, clickRate: '75.00' },
|
||||
stock_alert: { sent: 30, received: 30, clicked: 20, dismissed: 10, clickRate: '66.67' },
|
||||
event_alert: { sent: 40, received: 38, clicked: 10, dismissed: 28, clickRate: '26.32' },
|
||||
analysis_report: { sent: 10, received: 10, clicked: 0, dismissed: 10, clickRate: '0.00' }
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取按优先级统计
|
||||
|
||||
```javascript
|
||||
const byPriority = notificationMetricsService.getByPriority();
|
||||
console.log(byPriority);
|
||||
/* 输出:
|
||||
{
|
||||
urgent: { sent: 10, received: 10, clicked: 9, dismissed: 1, clickRate: '90.00' },
|
||||
important: { sent: 40, received: 39, clicked: 25, dismissed: 14, clickRate: '64.10' },
|
||||
normal: { sent: 50, received: 49, clicked: 11, dismissed: 38, clickRate: '22.45' }
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取每日数据
|
||||
|
||||
```javascript
|
||||
const dailyData = notificationMetricsService.getDailyData(7); // 最近 7 天
|
||||
console.log(dailyData);
|
||||
/* 输出:
|
||||
[
|
||||
{ date: '2025-01-15', sent: 15, received: 14, clicked: 6, dismissed: 8, clickRate: '42.86' },
|
||||
{ date: '2025-01-16', sent: 20, received: 20, clicked: 10, dismissed: 10, clickRate: '50.00' },
|
||||
...
|
||||
]
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取完整指标
|
||||
|
||||
```javascript
|
||||
const allMetrics = notificationMetricsService.getAllMetrics();
|
||||
console.log(allMetrics);
|
||||
```
|
||||
|
||||
#### 导出数据
|
||||
|
||||
```javascript
|
||||
// 导出为 JSON
|
||||
const json = notificationMetricsService.exportToJSON();
|
||||
console.log(json);
|
||||
|
||||
// 导出为 CSV
|
||||
const csv = notificationMetricsService.exportToCSV();
|
||||
console.log(csv);
|
||||
```
|
||||
|
||||
#### 重置指标
|
||||
|
||||
```javascript
|
||||
notificationMetricsService.reset();
|
||||
```
|
||||
|
||||
### 在控制台查看实时指标
|
||||
|
||||
打开浏览器控制台,执行:
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
||||
|
||||
// 查看汇总
|
||||
console.table(notificationMetricsService.getSummary());
|
||||
|
||||
// 查看按类型分布
|
||||
console.table(notificationMetricsService.getByType());
|
||||
|
||||
// 查看最近 7 天数据
|
||||
console.table(notificationMetricsService.getDailyData(7));
|
||||
```
|
||||
|
||||
### 监控埋点(自动)
|
||||
|
||||
监控服务已自动集成到 `NotificationContext`,无需手动调用:
|
||||
|
||||
- **trackReceived**: 收到通知时自动调用
|
||||
- **trackClicked**: 点击通知时自动调用
|
||||
- **trackDismissed**: 关闭通知时自动调用
|
||||
|
||||
### 可视化展示(可选)
|
||||
|
||||
你可以基于监控数据创建仪表板:
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
import { PieChart, LineChart } from 'recharts';
|
||||
|
||||
function MetricsDashboard() {
|
||||
const summary = notificationMetricsService.getSummary();
|
||||
const dailyData = notificationMetricsService.getDailyData(7);
|
||||
const byType = notificationMetricsService.getByType();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 汇总卡片 */}
|
||||
<StatsCard title="总推送数" value={summary.totalSent} />
|
||||
<StatsCard title="点击率" value={`${summary.clickRate}%`} />
|
||||
<StatsCard title="平均响应时间" value={`${summary.avgResponseTime}ms`} />
|
||||
|
||||
{/* 类型分布饼图 */}
|
||||
<PieChart data={Object.entries(byType).map(([type, data]) => ({
|
||||
name: type,
|
||||
value: data.received
|
||||
}))} />
|
||||
|
||||
{/* 每日趋势折线图 */}
|
||||
<LineChart data={dailyData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 功能 3:历史记录
|
||||
|
||||
### 功能说明
|
||||
|
||||
持久化存储所有接收到的通知,支持:
|
||||
- 查询和筛选
|
||||
- 搜索关键词
|
||||
- 标记已读/已点击
|
||||
- 批量删除
|
||||
- 导出(JSON/CSV)
|
||||
|
||||
### API 参考
|
||||
|
||||
#### 获取历史记录(支持筛选和分页)
|
||||
|
||||
```javascript
|
||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
||||
|
||||
const result = notificationHistoryService.getHistory({
|
||||
type: 'event_alert', // 可选:筛选类型
|
||||
priority: 'urgent', // 可选:筛选优先级
|
||||
readStatus: 'unread', // 可选:'read' | 'unread' | 'all'
|
||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000, // 可选:开始日期
|
||||
endDate: Date.now(), // 可选:结束日期
|
||||
page: 1, // 页码
|
||||
pageSize: 20, // 每页数量
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
/* 输出:
|
||||
{
|
||||
records: [...], // 当前页的记录
|
||||
total: 150, // 总记录数
|
||||
page: 1, // 当前页
|
||||
pageSize: 20, // 每页数量
|
||||
totalPages: 8 // 总页数
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 搜索历史记录
|
||||
|
||||
```javascript
|
||||
const results = notificationHistoryService.searchHistory('降准');
|
||||
console.log(results); // 返回标题/内容中包含"降准"的所有记录
|
||||
```
|
||||
|
||||
#### 标记已读/已点击
|
||||
|
||||
```javascript
|
||||
// 标记已读
|
||||
notificationHistoryService.markAsRead('notification_id');
|
||||
|
||||
// 标记已点击
|
||||
notificationHistoryService.markAsClicked('notification_id');
|
||||
```
|
||||
|
||||
#### 删除记录
|
||||
|
||||
```javascript
|
||||
// 删除单条
|
||||
notificationHistoryService.deleteRecord('notification_id');
|
||||
|
||||
// 批量删除
|
||||
notificationHistoryService.deleteRecords(['id1', 'id2', 'id3']);
|
||||
|
||||
// 清空所有
|
||||
notificationHistoryService.clearHistory();
|
||||
```
|
||||
|
||||
#### 获取统计数据
|
||||
|
||||
```javascript
|
||||
const stats = notificationHistoryService.getStats();
|
||||
console.log(stats);
|
||||
/* 输出:
|
||||
{
|
||||
total: 500, // 总记录数
|
||||
read: 320, // 已读数
|
||||
unread: 180, // 未读数
|
||||
clicked: 150, // 已点击数
|
||||
clickRate: '30.00', // 点击率
|
||||
byType: { // 按类型统计
|
||||
announcement: 100,
|
||||
stock_alert: 150,
|
||||
event_alert: 200,
|
||||
analysis_report: 50
|
||||
},
|
||||
byPriority: { // 按优先级统计
|
||||
urgent: 50,
|
||||
important: 200,
|
||||
normal: 250
|
||||
}
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 导出历史记录
|
||||
|
||||
```javascript
|
||||
// 导出为 JSON 字符串
|
||||
const json = notificationHistoryService.exportToJSON({
|
||||
type: 'event_alert' // 可选:只导出特定类型
|
||||
});
|
||||
|
||||
// 导出为 CSV 字符串
|
||||
const csv = notificationHistoryService.exportToCSV();
|
||||
|
||||
// 直接下载 JSON 文件
|
||||
notificationHistoryService.downloadJSON();
|
||||
|
||||
// 直接下载 CSV 文件
|
||||
notificationHistoryService.downloadCSV();
|
||||
```
|
||||
|
||||
### 在控制台使用
|
||||
|
||||
打开浏览器控制台,执行:
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
||||
|
||||
// 查看所有历史
|
||||
console.table(notificationHistoryService.getHistory().records);
|
||||
|
||||
// 搜索
|
||||
const results = notificationHistoryService.searchHistory('央行');
|
||||
console.table(results);
|
||||
|
||||
// 查看统计
|
||||
console.table(notificationHistoryService.getStats());
|
||||
|
||||
// 导出并下载
|
||||
notificationHistoryService.downloadJSON();
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
每条历史记录包含:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 'notif_123', // 通知 ID
|
||||
notification: { // 完整通知对象
|
||||
type: 'event_alert',
|
||||
priority: 'urgent',
|
||||
title: '...',
|
||||
content: '...',
|
||||
...
|
||||
},
|
||||
receivedAt: 1737459600000, // 接收时间戳
|
||||
readAt: 1737459650000, // 已读时间戳(null 表示未读)
|
||||
clickedAt: null, // 已点击时间戳(null 表示未点击)
|
||||
}
|
||||
```
|
||||
|
||||
### 存储限制
|
||||
|
||||
- **最大数量**: 500 条(超过后自动删除最旧的)
|
||||
- **存储位置**: localStorage
|
||||
- **容量估算**: 约 2-5MB(取决于通知内容长度)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── browserNotificationService.js [已存在] 浏览器通知服务
|
||||
│ ├── notificationMetricsService.js [新建] 性能监控服务
|
||||
│ └── notificationHistoryService.js [新建] 历史记录服务
|
||||
├── contexts/
|
||||
│ └── NotificationContext.js [修改] 集成所有功能
|
||||
└── components/
|
||||
└── NotificationContainer/
|
||||
└── index.js [修改] 添加点击追踪
|
||||
```
|
||||
|
||||
### 修改清单
|
||||
|
||||
| 文件 | 修改内容 | 状态 |
|
||||
|------|---------|------|
|
||||
| `NotificationContext.js` | 添加智能权限请求、监控埋点、历史保存 | ✅ 已完成 |
|
||||
| `NotificationContainer/index.js` | 添加点击追踪 | ✅ 已完成 |
|
||||
| `notificationMetricsService.js` | 性能监控服务 | ✅ 已创建 |
|
||||
| `notificationHistoryService.js` | 历史记录服务 | ✅ 已创建 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户收到通知
|
||||
↓
|
||||
NotificationContext.addWebNotification()
|
||||
├─ notificationMetricsService.trackReceived() [监控埋点]
|
||||
├─ notificationHistoryService.saveNotification() [历史保存]
|
||||
├─ 首次重要通知 → requestBrowserPermission() [智能权限]
|
||||
└─ 显示网页通知或桌面通知
|
||||
|
||||
用户点击通知
|
||||
↓
|
||||
NotificationContainer.handleClick()
|
||||
├─ notificationMetricsService.trackClicked() [监控埋点]
|
||||
├─ notificationHistoryService.markAsClicked() [历史标记]
|
||||
└─ 跳转到目标页面
|
||||
|
||||
用户关闭通知
|
||||
↓
|
||||
NotificationContext.removeNotification()
|
||||
└─ notificationMetricsService.trackDismissed() [监控埋点]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试智能桌面通知
|
||||
|
||||
```bash
|
||||
# 1. 清除已保存的权限状态
|
||||
localStorage.removeItem('browser_notification_requested');
|
||||
|
||||
# 2. 刷新页面
|
||||
|
||||
# 3. 等待或触发一个重要/紧急通知
|
||||
|
||||
# 4. 观察浏览器弹出权限请求
|
||||
|
||||
# 5. 授权后验证桌面通知功能
|
||||
```
|
||||
|
||||
### 2. 测试性能监控
|
||||
|
||||
```javascript
|
||||
// 在控制台执行
|
||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
||||
|
||||
// 查看实时统计
|
||||
console.table(notificationMetricsService.getSummary());
|
||||
|
||||
// 模拟推送几条通知,再次查看
|
||||
console.table(notificationMetricsService.getAllMetrics());
|
||||
|
||||
// 导出数据
|
||||
console.log(notificationMetricsService.exportToJSON());
|
||||
```
|
||||
|
||||
### 3. 测试历史记录
|
||||
|
||||
```javascript
|
||||
// 在控制台执行
|
||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
||||
|
||||
// 查看历史
|
||||
console.table(notificationHistoryService.getHistory().records);
|
||||
|
||||
// 搜索
|
||||
console.table(notificationHistoryService.searchHistory('降准'));
|
||||
|
||||
// 查看统计
|
||||
console.table(notificationHistoryService.getStats());
|
||||
|
||||
// 导出
|
||||
notificationHistoryService.downloadJSON();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 数据导出示例
|
||||
|
||||
### 导出性能监控数据
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
|
||||
// 导出 JSON
|
||||
const json = notificationMetricsService.exportToJSON();
|
||||
// 复制到剪贴板或保存
|
||||
|
||||
// 导出 CSV
|
||||
const csv = notificationMetricsService.exportToCSV();
|
||||
// 可以在 Excel 中打开
|
||||
```
|
||||
|
||||
### 导出历史记录
|
||||
|
||||
```javascript
|
||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
||||
|
||||
// 导出最近 7 天的事件动向通知
|
||||
const json = notificationHistoryService.exportToJSON({
|
||||
type: 'event_alert',
|
||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// 直接下载为文件
|
||||
notificationHistoryService.downloadJSON({
|
||||
type: 'event_alert'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. localStorage 容量限制
|
||||
|
||||
- 大多数浏览器限制为 5-10MB
|
||||
- 建议定期清理历史记录和监控数据
|
||||
- 使用导出功能备份数据
|
||||
|
||||
### 2. 浏览器兼容性
|
||||
|
||||
- **桌面通知**: 需要 HTTPS 或 localhost
|
||||
- **localStorage**: 所有现代浏览器支持
|
||||
- **权限请求**: 需要用户交互(不能自动授权)
|
||||
|
||||
### 3. 隐私和数据安全
|
||||
|
||||
- 所有数据存储在本地(localStorage)
|
||||
- 不会上传到服务器
|
||||
- 用户可以随时清空数据
|
||||
|
||||
### 4. 性能影响
|
||||
|
||||
- 监控埋点非常轻量,几乎无性能影响
|
||||
- 历史记录保存异步进行,不阻塞 UI
|
||||
- 数据查询在客户端完成,不增加服务器负担
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 已实现的功能
|
||||
|
||||
✅ **智能桌面通知**
|
||||
- 首次重要通知时自动请求权限
|
||||
- 智能分发策略(前台/后台)
|
||||
- localStorage 持久化权限状态
|
||||
|
||||
✅ **性能监控**
|
||||
- 到达率、点击率、响应时间追踪
|
||||
- 按类型、优先级、时段统计
|
||||
- 数据导出(JSON/CSV)
|
||||
|
||||
✅ **历史记录**
|
||||
- 持久化存储(最多 500 条)
|
||||
- 筛选、搜索、分页
|
||||
- 已读/已点击标记
|
||||
- 数据导出(JSON/CSV)
|
||||
|
||||
### 未实现的功能(备份,待上线)
|
||||
|
||||
⏸️ 历史记录页面 UI(代码已备份,随时可上线)
|
||||
⏸️ 监控仪表板 UI(可选,暂未实现)
|
||||
|
||||
### 下一步建议
|
||||
|
||||
1. **用户设置页面**: 允许用户自定义通知偏好
|
||||
2. **声音提示**: 为紧急通知添加音效
|
||||
3. **数据同步**: 将历史和监控数据同步到服务器
|
||||
4. **高级筛选**: 添加更多筛选维度(如关键词、股票代码等)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2025-01-21
|
||||
**维护者**: Claude Code
|
||||
@@ -1,371 +0,0 @@
|
||||
# 消息推送系统整合 - 测试指南
|
||||
|
||||
## 📋 整合完成清单
|
||||
|
||||
✅ **统一事件名称**
|
||||
- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
|
||||
- 移除了 `trade_notification` 事件名
|
||||
|
||||
✅ **数据适配器**
|
||||
- 创建了 `adaptEventToNotification` 函数
|
||||
- 自动识别后端事件格式并转换为前端通知格式
|
||||
- 重要性映射:S → urgent, A → important, B/C → normal
|
||||
|
||||
✅ **NotificationContext 升级**
|
||||
- 监听 `new_event` 事件
|
||||
- 自动使用适配器转换事件数据
|
||||
- 支持 Mock 和 Real 模式无缝切换
|
||||
|
||||
✅ **EventList 实时推送**
|
||||
- 集成 `useEventNotifications` Hook
|
||||
- 实时更新事件列表
|
||||
- Toast 通知提示
|
||||
- WebSocket 连接状态指示器
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试 Mock 模式(开发环境)
|
||||
|
||||
#### 1.1 配置环境变量
|
||||
确保 `.env` 文件包含以下配置:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=true
|
||||
# 或者
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
```
|
||||
|
||||
#### 1.2 启动应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 1.3 验证功能
|
||||
|
||||
**a) 右下角通知卡片**
|
||||
- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
|
||||
- 每隔 60 秒会自动推送 1-2 条模拟消息
|
||||
- 通知类型包括:
|
||||
- 📢 公告通知(蓝色)
|
||||
- 📈 股票动向(红/绿色,根据涨跌)
|
||||
- 📰 事件动向(橙色)
|
||||
- 📊 分析报告(紫色)
|
||||
|
||||
**b) 事件列表页面**
|
||||
- 访问事件列表页面(Community/Events)
|
||||
- 顶部应显示 "🟢 实时推送已开启"
|
||||
- 收到新事件时:
|
||||
- 右上角显示 Toast 通知
|
||||
- 事件自动添加到列表顶部
|
||||
- 无重复添加
|
||||
|
||||
**c) 控制台日志**
|
||||
打开浏览器控制台,应该看到:
|
||||
```
|
||||
[Socket Service] Using MOCK Socket Service
|
||||
NotificationContext: Socket connected
|
||||
EventList: 收到新事件推送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 测试 Real 模式(生产环境)
|
||||
|
||||
#### 2.1 配置环境变量
|
||||
修改 `.env` 文件:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=false
|
||||
# 或删除该配置项
|
||||
```
|
||||
|
||||
#### 2.2 启动后端 Flask 服务
|
||||
```bash
|
||||
python app_2.py
|
||||
```
|
||||
|
||||
确保后端已启动 Socket.IO 服务并监听事件推送。
|
||||
|
||||
#### 2.3 启动前端应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 2.4 创建测试事件(后端)
|
||||
使用后端提供的测试脚本:
|
||||
```bash
|
||||
python test_create_event.py
|
||||
```
|
||||
|
||||
#### 2.5 验证功能
|
||||
|
||||
**a) WebSocket 连接**
|
||||
- 检查控制台:`[Socket Service] Using REAL Socket Service`
|
||||
- 事件列表顶部显示 "🟢 实时推送已开启"
|
||||
|
||||
**b) 事件推送流程**
|
||||
1. 运行 `test_create_event.py` 创建新事件
|
||||
2. 后端轮询检测到新事件(最多等待 30 秒)
|
||||
3. 后端通过 Socket.IO 推送 `new_event`
|
||||
4. 前端接收事件并转换格式
|
||||
5. 同时显示:
|
||||
- 右下角通知卡片
|
||||
- 事件列表 Toast 提示
|
||||
- 事件添加到列表顶部
|
||||
|
||||
**c) 数据格式验证**
|
||||
在控制台查看事件对象,应包含:
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
type: "event_alert", // 适配器转换后
|
||||
priority: "urgent", // importance: S → urgent
|
||||
title: "事件标题",
|
||||
content: "事件描述",
|
||||
clickable: true,
|
||||
link: "/event-detail/123",
|
||||
extra: {
|
||||
eventType: "tech",
|
||||
importance: "S",
|
||||
// ... 更多后端字段
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证清单
|
||||
|
||||
### 功能验证
|
||||
|
||||
- [ ] Mock 模式下收到模拟通知
|
||||
- [ ] Real 模式下收到真实后端推送
|
||||
- [ ] 通知卡片正确显示(类型、颜色、内容)
|
||||
- [ ] 事件列表实时更新
|
||||
- [ ] Toast 通知正常弹出
|
||||
- [ ] 连接状态指示器正确显示
|
||||
- [ ] 点击通知可跳转到详情页
|
||||
- [ ] 无重复事件添加
|
||||
|
||||
### 数据验证
|
||||
|
||||
- [ ] 后端事件格式正确转换
|
||||
- [ ] 重要性映射正确(S/A/B/C → urgent/important/normal)
|
||||
- [ ] 时间戳正确显示
|
||||
- [ ] 链接路径正确生成
|
||||
- [ ] 所有字段完整保留在 extra 中
|
||||
|
||||
### 性能验证
|
||||
|
||||
- [ ] 事件列表最多保留 100 条
|
||||
- [ ] 通知自动关闭(紧急=不关闭,重要=30s,普通=15s)
|
||||
- [ ] WebSocket 自动重连
|
||||
- [ ] 无内存泄漏
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题排查
|
||||
|
||||
### Q1: Mock 模式下没有收到通知?
|
||||
**A:** 检查:
|
||||
1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
|
||||
2. 控制台是否显示 "Using MOCK Socket Service"
|
||||
3. 是否等待了 3 秒(首次通知延迟)
|
||||
|
||||
### Q2: Real 模式下无法连接?
|
||||
**A:** 检查:
|
||||
1. Flask 后端是否启动:`python app_2.py`
|
||||
2. API_BASE_URL 是否正确配置
|
||||
3. CORS 设置是否包含前端域名
|
||||
4. 控制台是否有连接错误
|
||||
|
||||
### Q3: 收到重复通知?
|
||||
**A:** 检查:
|
||||
1. 是否多次渲染了 EventList 组件
|
||||
2. 是否在多个地方调用了 `useEventNotifications`
|
||||
3. 控制台日志中是否有 "事件已存在,跳过添加"
|
||||
|
||||
### Q4: 通知卡片样式异常?
|
||||
**A:** 检查:
|
||||
1. 事件的 `type` 字段是否正确
|
||||
2. 是否缺少必要的字段(title, content)
|
||||
3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
|
||||
|
||||
### Q5: 事件列表不更新?
|
||||
**A:** 检查:
|
||||
1. WebSocket 连接状态(顶部 Badge)
|
||||
2. `onNewEvent` 回调是否触发(控制台日志)
|
||||
3. `setLocalEvents` 是否正确执行
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试数据示例
|
||||
|
||||
### Mock 模拟数据类型
|
||||
|
||||
**公告通知**
|
||||
```javascript
|
||||
{
|
||||
type: "announcement",
|
||||
priority: "urgent",
|
||||
title: "贵州茅台发布2024年度财报公告",
|
||||
content: "2024年度营收同比增长15.2%..."
|
||||
}
|
||||
```
|
||||
|
||||
**股票动向**
|
||||
```javascript
|
||||
{
|
||||
type: "stock_alert",
|
||||
priority: "urgent",
|
||||
title: "您关注的股票触发预警",
|
||||
extra: {
|
||||
stockCode: "300750",
|
||||
priceChange: "+5.2%"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**事件动向**
|
||||
```javascript
|
||||
{
|
||||
type: "event_alert",
|
||||
priority: "important",
|
||||
title: "央行宣布降准0.5个百分点",
|
||||
extra: {
|
||||
eventId: "evt001",
|
||||
sectors: ["银行", "地产", "基建"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析报告**
|
||||
```javascript
|
||||
{
|
||||
type: "analysis_report",
|
||||
priority: "important",
|
||||
title: "医药行业深度报告:创新药迎来政策拐点",
|
||||
author: {
|
||||
name: "李明",
|
||||
organization: "中信证券"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 真实后端事件格式
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
title: "新能源汽车补贴政策延期",
|
||||
description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
|
||||
event_type: "policy",
|
||||
importance: "S",
|
||||
status: "active",
|
||||
created_at: "2025-01-21T14:30:00",
|
||||
hot_score: 95.5,
|
||||
view_count: 1234,
|
||||
related_avg_chg: 5.2,
|
||||
related_max_chg: 15.8,
|
||||
keywords: ["新能源", "补贴", "政策"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步建议
|
||||
|
||||
### 1. 用户设置
|
||||
允许用户控制通知偏好:
|
||||
```jsx
|
||||
<Switch
|
||||
isChecked={enableNotifications}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
启用实时通知
|
||||
</Switch>
|
||||
```
|
||||
|
||||
### 2. 通知过滤
|
||||
按重要性、类型过滤通知:
|
||||
```javascript
|
||||
useEventNotifications({
|
||||
eventType: 'tech', // 只订阅科技类
|
||||
importance: 'S', // 只订阅 S 级
|
||||
enabled: true
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 声音提示
|
||||
添加音效提醒:
|
||||
```javascript
|
||||
onNewEvent: (event) => {
|
||||
if (event.priority === 'urgent') {
|
||||
new Audio('/alert.mp3').play();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 桌面通知
|
||||
利用浏览器通知 API:
|
||||
```javascript
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(event.title, {
|
||||
body: event.content,
|
||||
icon: '/logo.png'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 技术说明
|
||||
|
||||
### 架构优势
|
||||
|
||||
1. **统一接口**:Mock 和 Real 完全相同的 API
|
||||
2. **自动适配**:智能识别数据格式并转换
|
||||
3. **解耦设计**:通知系统和事件列表独立工作
|
||||
4. **向后兼容**:不影响现有功能
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `src/services/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
后端创建事件
|
||||
↓
|
||||
后端轮询检测(30秒)
|
||||
↓
|
||||
Socket.IO 推送 new_event
|
||||
↓
|
||||
前端 socketService 接收
|
||||
↓
|
||||
NotificationContext 监听并适配
|
||||
↓
|
||||
同时触发:
|
||||
├─ NotificationContainer(右下角卡片)
|
||||
└─ EventList onNewEvent(Toast + 列表更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 整合完成
|
||||
|
||||
所有代码和功能已经就绪!你现在可以:
|
||||
|
||||
1. ✅ 在 Mock 模式下测试实时推送
|
||||
2. ✅ 在 Real 模式下连接后端
|
||||
3. ✅ 查看右下角通知卡片
|
||||
4. ✅ 体验事件列表实时更新
|
||||
5. ✅ 随时切换 Mock/Real 模式
|
||||
|
||||
**祝测试顺利!🎉**
|
||||
576
docs/NEW_PAYMENT_SYSTEM_DESIGN.md
Normal file
576
docs/NEW_PAYMENT_SYSTEM_DESIGN.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# 订阅支付系统重新设计方案
|
||||
|
||||
## 📊 问题分析
|
||||
|
||||
### 现有系统的问题
|
||||
|
||||
1. **价格配置混乱**
|
||||
- 季付和月付价格相同(配置错误)
|
||||
- `monthly_price` 和 `yearly_price` 字段命名不清晰
|
||||
- 缺少季付、半年付等周期的价格配置
|
||||
|
||||
2. **升级逻辑复杂且不合理**
|
||||
- 计算剩余价值折算(按天计算 `remaining_value`)
|
||||
- 用户难以理解升级价格
|
||||
- 续费用户和新用户价格不一致
|
||||
- 逻辑复杂,容易出错
|
||||
|
||||
3. **按钮文案不清晰**
|
||||
- 已订阅用户应显示"续费 Pro"/"续费 Max"
|
||||
- 而不是"升级至 Pro"/"切换至 Pro"
|
||||
|
||||
4. **数据库表设计问题**
|
||||
- `SubscriptionUpgrade` 表记录升级,但逻辑过于复杂
|
||||
- `PaymentOrder` 表缺少必要字段
|
||||
- 价格配置分散在多个字段
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新设计方案
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **简化续费逻辑**: **续费用户与新用户价格完全一致**,不做任何折算
|
||||
2. **清晰的价格体系**: 每个套餐每个周期都有明确的价格
|
||||
3. **统一的用户体验**: 无论是新购还是续费,价格透明一致
|
||||
4. **独立的订阅记录**: 每次支付都创建新的订阅记录(历史可追溯)
|
||||
|
||||
---
|
||||
|
||||
## 📐 数据库表设计
|
||||
|
||||
### 1. `subscription_plans` - 订阅套餐表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscription_plans (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
|
||||
plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
|
||||
description TEXT COMMENT '套餐描述',
|
||||
features JSON COMMENT '功能列表',
|
||||
|
||||
-- 价格配置(所有周期价格)
|
||||
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
|
||||
price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
|
||||
price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
|
||||
price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
|
||||
|
||||
-- 状态字段
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
display_order INT DEFAULT 0 COMMENT '展示顺序',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_plan_code (plan_code),
|
||||
INDEX idx_active_order (is_active, display_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
|
||||
```
|
||||
|
||||
**示例数据**:
|
||||
```sql
|
||||
INSERT INTO subscription_plans (plan_code, plan_name, description, price_monthly, price_quarterly, price_semiannual, price_yearly) VALUES
|
||||
('pro', 'Pro 专业版', '为专业投资者打造', 299.00, 799.00, 1499.00, 2699.00),
|
||||
('max', 'Max 旗舰版', '旗舰级体验', 599.00, 1599.00, 2999.00, 5399.00);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `user_subscriptions` - 用户订阅记录表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_subscriptions (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
|
||||
|
||||
-- 订阅基本信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
|
||||
|
||||
-- 订阅时间
|
||||
start_date DATETIME NOT NULL COMMENT '订阅开始时间',
|
||||
end_date DATETIME NOT NULL COMMENT '订阅结束时间',
|
||||
|
||||
-- 订阅状态
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
|
||||
is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
|
||||
|
||||
-- 支付信息
|
||||
payment_order_id INT COMMENT '关联的支付订单ID',
|
||||
paid_amount DECIMAL(10,2) NOT NULL COMMENT '实际支付金额',
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
|
||||
-- 订阅类型
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
|
||||
|
||||
-- 自动续费
|
||||
auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_subscription_id (subscription_id),
|
||||
INDEX idx_user_current (user_id, is_current),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_end_date (end_date),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
|
||||
```
|
||||
|
||||
**设计说明**:
|
||||
- 每次支付都创建新的订阅记录
|
||||
- 通过 `is_current` 标识当前生效的订阅
|
||||
- 支持订阅历史追溯
|
||||
- 续费时记录 `previous_subscription_id` 形成订阅链
|
||||
|
||||
---
|
||||
|
||||
### 3. `payment_orders` - 支付订单表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE payment_orders (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
|
||||
-- 订阅信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
|
||||
-- 价格信息
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
|
||||
|
||||
-- 优惠码
|
||||
promo_code_id INT COMMENT '优惠码ID',
|
||||
promo_code VARCHAR(50) COMMENT '优惠码',
|
||||
|
||||
-- 支付信息
|
||||
payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
|
||||
payment_channel VARCHAR(50) COMMENT '支付渠道详情',
|
||||
transaction_id VARCHAR(64) COMMENT '第三方交易号',
|
||||
qr_code_url TEXT COMMENT '支付二维码URL',
|
||||
|
||||
-- 订单状态
|
||||
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
|
||||
|
||||
-- 时间信息
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
expired_at TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 备注
|
||||
remark TEXT COMMENT '备注信息',
|
||||
|
||||
INDEX idx_order_no (order_no),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `promo_codes` - 优惠码表(保持不变,微调)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promo_codes (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码',
|
||||
description VARCHAR(200) COMMENT '描述',
|
||||
|
||||
-- 折扣类型
|
||||
discount_type VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比), fixed_amount(固定金额)',
|
||||
discount_value DECIMAL(10,2) NOT NULL COMMENT '折扣值',
|
||||
|
||||
-- 适用范围
|
||||
applicable_plans JSON COMMENT '适用套餐: ["pro", "max"] 或 null(全部)',
|
||||
applicable_cycles JSON COMMENT '适用周期: ["monthly", "yearly"] 或 null(全部)',
|
||||
min_amount DECIMAL(10,2) COMMENT '最低消费金额',
|
||||
|
||||
-- 使用限制
|
||||
max_total_uses INT COMMENT '最大使用次数(总)',
|
||||
max_uses_per_user INT DEFAULT 1 COMMENT '每用户最大使用次数',
|
||||
current_uses INT DEFAULT 0 COMMENT '当前使用次数',
|
||||
|
||||
-- 有效期
|
||||
valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
|
||||
valid_until TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 状态
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_code (code),
|
||||
INDEX idx_active (is_active),
|
||||
INDEX idx_valid_period (valid_from, valid_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `promo_code_usage` - 优惠码使用记录表(保持不变)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promo_code_usage (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
promo_code_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
order_id INT NOT NULL,
|
||||
discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
|
||||
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_promo_code (promo_code_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_order_id (order_id),
|
||||
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 删除不必要的表
|
||||
|
||||
**删除 `subscription_upgrades` 表** - 不再需要复杂的升级逻辑
|
||||
|
||||
---
|
||||
|
||||
## 💡 业务逻辑设计
|
||||
|
||||
### 1. 价格计算逻辑(简化版)
|
||||
|
||||
```python
|
||||
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None):
|
||||
"""
|
||||
计算订阅价格(新购和续费价格完全一致)
|
||||
|
||||
Args:
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
promo_code: 优惠码(可选)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'plan_code': 'pro',
|
||||
'billing_cycle': 'yearly',
|
||||
'original_price': 2699.00,
|
||||
'discount_amount': 0,
|
||||
'final_amount': 2699.00,
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
"""
|
||||
# 1. 查询套餐价格
|
||||
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
|
||||
if not plan:
|
||||
return {'error': '套餐不存在'}
|
||||
|
||||
# 2. 获取对应周期的价格
|
||||
price_field = f'price_{billing_cycle}'
|
||||
original_price = getattr(plan, price_field, 0)
|
||||
|
||||
if original_price <= 0:
|
||||
return {'error': '价格配置错误'}
|
||||
|
||||
result = {
|
||||
'plan_code': plan_code,
|
||||
'plan_name': plan.plan_name,
|
||||
'billing_cycle': billing_cycle,
|
||||
'original_price': float(original_price),
|
||||
'discount_amount': 0,
|
||||
'final_amount': float(original_price),
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
|
||||
# 3. 应用优惠码(如果有)
|
||||
if promo_code:
|
||||
promo, error = validate_promo_code(promo_code, plan_code, billing_cycle, original_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, original_price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = float(original_price - discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ 不再计算 `remaining_value`(剩余价值)
|
||||
- ✅ 不再区分新购/续费价格
|
||||
- ✅ 逻辑简单,易于维护
|
||||
- ✅ 用户体验清晰透明
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建订单逻辑
|
||||
|
||||
```python
|
||||
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None):
|
||||
"""
|
||||
创建订阅支付订单
|
||||
"""
|
||||
# 1. 计算价格
|
||||
price_result = calculate_subscription_price(plan_code, billing_cycle, promo_code)
|
||||
if 'error' in price_result:
|
||||
return {'success': False, 'error': price_result['error']}
|
||||
|
||||
# 2. 判断是新购还是续费
|
||||
current_sub = get_current_subscription(user_id)
|
||||
|
||||
subscription_type = 'new'
|
||||
if current_sub and current_sub.plan_code in ['pro', 'max']:
|
||||
subscription_type = 'renew'
|
||||
|
||||
# 3. 创建支付订单
|
||||
order = PaymentOrder(
|
||||
order_no=generate_order_no(user_id),
|
||||
user_id=user_id,
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
subscription_type=subscription_type,
|
||||
original_price=price_result['original_price'],
|
||||
discount_amount=price_result['discount_amount'],
|
||||
final_amount=price_result['final_amount'],
|
||||
promo_code=promo_code,
|
||||
status='pending',
|
||||
expired_at=datetime.now() + timedelta(minutes=30)
|
||||
)
|
||||
|
||||
db.session.add(order)
|
||||
db.session.commit()
|
||||
|
||||
# 4. 生成支付二维码(微信支付)
|
||||
qr_code_url = generate_wechat_qr_code(order)
|
||||
order.qr_code_url = qr_code_url
|
||||
db.session.commit()
|
||||
|
||||
return {'success': True, 'order': order.to_dict()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 支付成功后的订阅激活逻辑
|
||||
|
||||
```python
|
||||
def activate_subscription_after_payment(order_id):
|
||||
"""
|
||||
支付成功后激活订阅
|
||||
"""
|
||||
order = PaymentOrder.query.get(order_id)
|
||||
if not order or order.status != 'paid':
|
||||
return {'success': False, 'error': '订单状态错误'}
|
||||
|
||||
user_id = order.user_id
|
||||
plan_code = order.plan_code
|
||||
billing_cycle = order.billing_cycle
|
||||
|
||||
# 1. 计算订阅周期
|
||||
cycle_days = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
days = cycle_days.get(billing_cycle, 30)
|
||||
|
||||
# 2. 获取当前订阅
|
||||
current_sub = UserSubscription.query.filter_by(
|
||||
user_id=user_id,
|
||||
is_current=True
|
||||
).first()
|
||||
|
||||
# 3. 计算开始和结束时间
|
||||
now = datetime.now()
|
||||
|
||||
if current_sub and current_sub.end_date > now:
|
||||
# 续费:从当前订阅结束时间开始
|
||||
start_date = current_sub.end_date
|
||||
else:
|
||||
# 新购:从当前时间开始
|
||||
start_date = now
|
||||
|
||||
end_date = start_date + timedelta(days=days)
|
||||
|
||||
# 4. 创建新订阅记录
|
||||
new_subscription = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_id=generate_subscription_id(),
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
status='active',
|
||||
is_current=True,
|
||||
payment_order_id=order.id,
|
||||
paid_amount=order.final_amount,
|
||||
original_price=order.original_price,
|
||||
discount_amount=order.discount_amount,
|
||||
subscription_type=order.subscription_type,
|
||||
previous_subscription_id=current_sub.subscription_id if current_sub else None
|
||||
)
|
||||
|
||||
# 5. 将旧订阅标记为非当前
|
||||
if current_sub:
|
||||
current_sub.is_current = False
|
||||
|
||||
db.session.add(new_subscription)
|
||||
db.session.commit()
|
||||
|
||||
return {'success': True, 'subscription': new_subscription.to_dict()}
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
- ✅ 续费时从**当前订阅结束时间**开始,避免浪费
|
||||
- ✅ 每次支付都创建新的订阅记录
|
||||
- ✅ 保留历史订阅记录(通过 `previous_subscription_id` 形成链)
|
||||
- ✅ 逻辑清晰,易于理解
|
||||
|
||||
---
|
||||
|
||||
### 4. 按钮文案逻辑
|
||||
|
||||
```python
|
||||
def get_subscription_button_text(user, plan_code, billing_cycle):
|
||||
"""
|
||||
获取订阅按钮文字
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期
|
||||
|
||||
Returns:
|
||||
str: 按钮文字
|
||||
"""
|
||||
current_sub = get_current_subscription(user.id)
|
||||
|
||||
# 1. 如果没有订阅或订阅已过期
|
||||
if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
|
||||
return f"选择 {get_plan_display_name(plan_code)}"
|
||||
|
||||
# 2. 如果是当前套餐且周期相同
|
||||
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
|
||||
return f"续费 {get_plan_display_name(plan_code)}"
|
||||
|
||||
# 3. 如果是当前套餐但周期不同
|
||||
if current_sub.plan_code == plan_code:
|
||||
return f"切换至{get_cycle_display_name(billing_cycle)}"
|
||||
|
||||
# 4. 如果是不同套餐
|
||||
return f"选择 {get_plan_display_name(plan_code)}"
|
||||
|
||||
def get_plan_display_name(plan_code):
|
||||
names = {'pro': 'Pro 专业版', 'max': 'Max 旗舰版'}
|
||||
return names.get(plan_code, plan_code)
|
||||
|
||||
def get_cycle_display_name(billing_cycle):
|
||||
names = {
|
||||
'monthly': '月付',
|
||||
'quarterly': '季付',
|
||||
'semiannual': '半年付',
|
||||
'yearly': '年付'
|
||||
}
|
||||
return names.get(billing_cycle, billing_cycle)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 免费用户看 Pro 年付: "选择 Pro 专业版"
|
||||
- Pro 月付用户看 Pro 年付: "切换至年付"
|
||||
- Pro 年付用户看 Pro 年付: "续费 Pro 专业版"
|
||||
- Pro 用户看 Max 年付: "选择 Max 旗舰版"
|
||||
|
||||
---
|
||||
|
||||
## 📊 价格配置示例
|
||||
|
||||
### Pro 专业版价格设定
|
||||
|
||||
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|
||||
|---------|------|------|------|---------|
|
||||
| 月付 | ¥299 | - | - | ¥299 |
|
||||
| 季付(3个月) | ¥799 | ¥897 | 11% | ¥266 |
|
||||
| 半年付(6个月) | ¥1499 | ¥1794 | 16% | ¥250 |
|
||||
| 年付(12个月) | ¥2699 | ¥3588 | 25% | ¥225 |
|
||||
|
||||
### Max 旗舰版价格设定
|
||||
|
||||
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|
||||
|---------|------|------|------|---------|
|
||||
| 月付 | ¥599 | - | - | ¥599 |
|
||||
| 季付(3个月) | ¥1599 | ¥1797 | 11% | ¥533 |
|
||||
| 半年付(6个月) | ¥2999 | ¥3594 | 17% | ¥500 |
|
||||
| 年付(12个月) | ¥5399 | ¥7188 | 25% | ¥450 |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 迁移方案
|
||||
|
||||
### 数据迁移 SQL
|
||||
|
||||
参见 `database_migration.sql`
|
||||
|
||||
### 代码迁移步骤
|
||||
|
||||
1. **备份现有数据库**
|
||||
2. **执行数据库迁移 SQL**
|
||||
3. **更新数据库模型** (`models.py`)
|
||||
4. **更新价格计算逻辑** (`calculate_price.py`)
|
||||
5. **更新 API 路由** (`routes.py`)
|
||||
6. **更新前端组件** (`SubscriptionContentNew.tsx`)
|
||||
7. **测试完整流程**
|
||||
8. **灰度发布**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 优势总结
|
||||
|
||||
### 相比旧系统的改进
|
||||
|
||||
1. **价格透明** - 续费用户和新用户价格完全一致
|
||||
2. **逻辑简化** - 不再计算剩余价值,代码减少 50%+
|
||||
3. **易于理解** - 用户体验更清晰
|
||||
4. **灵活扩展** - 轻松添加新的计费周期
|
||||
5. **历史追溯** - 完整的订阅历史记录
|
||||
6. **数据完整** - 每次支付都有完整的记录
|
||||
|
||||
### 用户体验改进
|
||||
|
||||
1. **按钮文案清晰** - "续费 Pro"/"选择 Pro"明确表达意图
|
||||
2. **价格一致性** - 所有用户看到的价格都一样
|
||||
3. **无隐藏费用** - 不会因为"升级折算"产生困惑
|
||||
4. **透明计费** - 支付金额 = 显示价格 - 优惠码折扣
|
||||
|
||||
---
|
||||
|
||||
## 📝 后续优化建议
|
||||
|
||||
1. **自动续费** - 到期前自动扣款续费
|
||||
2. **订阅提醒** - 到期前 7 天、3 天、1 天发送通知
|
||||
3. **订阅暂停** - 允许用户暂停订阅
|
||||
4. **订阅降级** - 从 Max 降级到 Pro(当前周期结束后生效)
|
||||
5. **发票管理** - 支持开具电子发票
|
||||
6. **支付方式扩展** - 支持支付宝、银行卡等
|
||||
|
||||
---
|
||||
|
||||
**设计时间**: 2025-11-19
|
||||
**设计者**: Claude Code
|
||||
**版本**: v2.0.0
|
||||
@@ -1,280 +0,0 @@
|
||||
# 消息推送系统优化总结
|
||||
|
||||
## 优化目标
|
||||
1. 简化通知信息密度,通过视觉层次(边框+背景色)表达优先级
|
||||
2. 增强紧急通知的视觉冲击力(红色脉冲边框动画)
|
||||
3. 采用智能显示策略,降低普通通知的视觉干扰
|
||||
|
||||
## 实施内容
|
||||
|
||||
### 1. 优先级配置更新 (src/constants/notificationTypes.js)
|
||||
|
||||
#### 新增配置项
|
||||
- `borderWidth`: 边框宽度
|
||||
- 紧急 (urgent): 6px
|
||||
- 重要 (important): 4px
|
||||
- 普通 (normal): 2px
|
||||
|
||||
- `bgOpacity`: 背景色透明度(亮色模式)
|
||||
- 紧急: 0.25 (深色背景)
|
||||
- 重要: 0.15 (中色背景)
|
||||
- 普通: 0.08 (浅色背景)
|
||||
|
||||
- `darkBgOpacity`: 背景色透明度(暗色模式)
|
||||
- 紧急: 0.30
|
||||
- 重要: 0.20
|
||||
- 普通: 0.12
|
||||
|
||||
#### 新增辅助函数
|
||||
- `getPriorityBgOpacity(priority, isDark)`: 获取优先级对应的背景色透明度
|
||||
- `getPriorityBorderWidth(priority)`: 获取优先级对应的边框宽度
|
||||
|
||||
### 2. 紧急通知脉冲动画 (src/components/NotificationContainer/index.js)
|
||||
|
||||
#### 动画效果
|
||||
- 使用 `@emotion/react` 的 `keyframes` 创建脉冲动画
|
||||
- 仅紧急通知 (urgent) 应用动画效果
|
||||
- 动画特性:
|
||||
- 边框颜色脉冲效果
|
||||
- 阴影扩散效果(0 → 12px)
|
||||
- 持续时间:2秒
|
||||
- 缓动函数:ease-in-out
|
||||
- 无限循环
|
||||
|
||||
```javascript
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### 3. 背景色优先级优化
|
||||
|
||||
#### 亮色模式
|
||||
- **紧急通知**:`${colorScheme}.200` - 深色背景 + 脉冲动画
|
||||
- **重要通知**:`${colorScheme}.100` - 中色背景
|
||||
- **普通通知**:`white` - 极淡背景(降低视觉干扰)
|
||||
|
||||
#### 暗色模式
|
||||
- **紧急通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
||||
- **重要通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
||||
- **普通通知**:`gray.800` - 暗灰背景(降低视觉干扰)
|
||||
|
||||
### 4. 可点击性视觉提示
|
||||
|
||||
#### 问题
|
||||
- 用户需要 hover 才能知道通知是否可点击
|
||||
- cursor: pointer 不够直观
|
||||
|
||||
#### 解决方案
|
||||
- **可点击的通知**:
|
||||
- 添加完整边框(四周 1px solid)
|
||||
- 保持左侧优先级边框宽度
|
||||
- 使用更明显的阴影(md 级别)
|
||||
- 产生微妙的悬浮感
|
||||
|
||||
- **不可点击的通知**:
|
||||
- 仅左侧边框
|
||||
- 使用较淡的阴影(sm 级别)
|
||||
|
||||
```javascript
|
||||
// 可点击的通知添加完整边框
|
||||
{...(isActuallyClickable && {
|
||||
border: '1px solid',
|
||||
borderLeftWidth: priorityBorderWidth, // 保持优先级
|
||||
})}
|
||||
|
||||
// 可点击的通知使用更明显的阴影
|
||||
boxShadow={isActuallyClickable
|
||||
? (isNewest ? '2xl' : 'md')
|
||||
: (isNewest ? 'xl' : 'sm')}
|
||||
```
|
||||
|
||||
### 5. 通知组件简化 (src/components/NotificationContainer/index.js)
|
||||
|
||||
#### 显示元素分级
|
||||
|
||||
**LV1 - 必需元素(始终显示)**
|
||||
- ✅ 标题 (title)
|
||||
- ✅ 内容 (content, 最多3行)
|
||||
- ✅ 时间 (publishTime/pushTime)
|
||||
- ✅ 查看详情 (仅当 clickable=true 时)
|
||||
- ✅ 关闭按钮
|
||||
|
||||
**LV2 - 可选元素(数据存在时显示)**
|
||||
- ✅ 图标:仅在紧急/重要通知时显示
|
||||
- ❌ 优先级标签:已移除,改用边框+背景色表示
|
||||
- ✅ 状态提示:仅当 `extra?.statusHint` 存在时显示
|
||||
|
||||
**LV3 - 可选元素(数据存在时显示)**
|
||||
- ✅ AI 标识:仅当 `isAIGenerated = true` 时显示
|
||||
- ✅ 预测标识:仅当 `isPrediction = true` 时显示
|
||||
|
||||
**其他**
|
||||
- ✅ 作者信息:移除屏幕尺寸限制,仅当 `author` 存在时显示
|
||||
|
||||
#### 优先级视觉样式
|
||||
- ✅ 边框宽度:根据优先级动态调整 (2px/4px/6px)
|
||||
- ✅ 背景色深度:根据优先级使用不同深度的颜色
|
||||
- 亮色模式: .50 (普通) / .100 (重要) / .200 (紧急)
|
||||
- 暗色模式: 使用 typeConfig 的 darkBg 配置
|
||||
|
||||
#### 布局优化
|
||||
- ✅ 内容和元数据区域的左侧填充根据图标显示状态自适应
|
||||
- ✅ 无图标时不添加额外的左侧间距
|
||||
|
||||
## 预期效果
|
||||
|
||||
### 视觉改进
|
||||
- **清晰度提升**:移除冗余的优先级标签,视觉更整洁
|
||||
- **优先级强化**:
|
||||
- 紧急通知:6px 粗边框 + 深色背景 + **红色脉冲动画** → 视觉冲击力极强
|
||||
- 重要通知:4px 中等边框 + 中色背景 + 图标 → 醒目但不打扰
|
||||
- 普通通知:2px 细边框 + 白色/极淡背景 → 低视觉干扰
|
||||
- **可点击性一目了然**:
|
||||
- 可点击:完整边框 + 明显阴影 → 卡片悬浮感
|
||||
- 不可点击:仅左侧边框 + 淡阴影 → 平面感
|
||||
- **信息密度降低**:减少不必要的视觉元素,关键信息更突出
|
||||
|
||||
### 用户体验
|
||||
- **紧急通知引起注意**:脉冲动画确保用户不会错过紧急信息
|
||||
- **快速识别优先级**:
|
||||
- 动画 = 紧急(需要立即关注)
|
||||
- 图标 + 粗边框 = 重要(需要关注)
|
||||
- 细边框 + 淡背景 = 普通(可稍后查看)
|
||||
- **可点击性无需 hover**:
|
||||
- 完整边框 + 悬浮感 = 可以点击查看详情
|
||||
- 仅左侧边框 = 信息已完整,无需跳转
|
||||
- **智能显示**:可选信息只在数据存在时显示,避免空白占位
|
||||
- **响应式优化**:所有设备上保持一致的显示逻辑
|
||||
|
||||
### 向后兼容
|
||||
- ✅ 完全兼容现有通知数据结构
|
||||
- ✅ 可选字段不存在时自动隐藏
|
||||
- ✅ 不影响现有功能(点击、关闭、自动消失等)
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 功能测试
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm start
|
||||
|
||||
# 观察不同优先级通知的显示效果
|
||||
# - 紧急通知:粗边框 (6px) + 深色背景 + 红色脉冲动画 + 图标 + 不自动关闭
|
||||
# - 重要通知:中等边框 (4px) + 中色背景 + 图标 + 30秒后关闭
|
||||
# - 普通通知:细边框 (2px) + 白色背景 + 无图标 + 15秒后关闭
|
||||
```
|
||||
|
||||
### 1.1 动画测试
|
||||
- [ ] 紧急通知的脉冲动画流畅无卡顿
|
||||
- [ ] 动画周期为 2 秒
|
||||
- [ ] 动画在紧急通知显示期间持续循环
|
||||
- [ ] 阴影扩散效果清晰可见
|
||||
|
||||
### 2. 边界测试
|
||||
- [ ] 仅必需字段的通知(无作者、无 AI 标识、无预测标识)
|
||||
- [ ] 包含所有可选字段的通知
|
||||
- [ ] 不同类型的通知(公告、股票、事件、分析报告)
|
||||
- [ ] 不同优先级的通知(紧急、重要、普通)
|
||||
|
||||
### 3. 响应式测试
|
||||
- [ ] 移动设备 (< 480px)
|
||||
- [ ] 平板设备 (480px - 768px)
|
||||
- [ ] 桌面设备 (> 768px)
|
||||
|
||||
### 4. 暗色模式测试
|
||||
- [ ] 切换到暗色模式,确认背景色对比度合适
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 关键代码变更
|
||||
|
||||
#### 1. 脉冲动画实现
|
||||
```javascript
|
||||
// 导入 keyframes
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
// 定义脉冲动画
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
// 应用到紧急通知
|
||||
<Box
|
||||
animation={priority === PRIORITY_LEVELS.URGENT
|
||||
? `${pulseAnimation} 2s ease-in-out infinite`
|
||||
: undefined}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. 优先级标签自动隐藏
|
||||
```javascript
|
||||
// PRIORITY_CONFIGS 中所有 show 属性设置为 false
|
||||
show: false, // 不再显示标签,改用边框+背景色表示
|
||||
```
|
||||
|
||||
#### 3. 背景色优先级优化
|
||||
```javascript
|
||||
const getPriorityBgColor = () => {
|
||||
const colorScheme = typeConfig.colorScheme;
|
||||
if (!isDark) {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return `${colorScheme}.200`; // 深色背景 + 脉冲动画
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return `${colorScheme}.100`; // 中色背景
|
||||
} else {
|
||||
return 'white'; // 极淡背景(降低视觉干扰)
|
||||
}
|
||||
} else {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else {
|
||||
return 'gray.800'; // 暗灰背景(降低视觉干扰)
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 图标条件显示
|
||||
```javascript
|
||||
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT ||
|
||||
priority === PRIORITY_LEVELS.IMPORTANT;
|
||||
|
||||
{shouldShowIcon && (
|
||||
<Icon as={typeConfig.icon} ... />
|
||||
)}
|
||||
};
|
||||
```
|
||||
|
||||
## 后续改进建议
|
||||
|
||||
### 短期
|
||||
- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
|
||||
- [ ] 提供配置选项让用户自定义显示元素
|
||||
|
||||
### 长期
|
||||
- [ ] 支持通知分组(按类型或优先级)
|
||||
- [ ] 添加通知搜索和筛选功能
|
||||
- [ ] 通知历史记录可视化统计
|
||||
|
||||
## 构建状态
|
||||
✅ 构建成功 (npm run build)
|
||||
✅ 无语法错误
|
||||
✅ 无 TypeScript 错误
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,841 +0,0 @@
|
||||
# PostHog 事件追踪实施总结
|
||||
|
||||
## ✅ 已完成的追踪
|
||||
|
||||
### 1. Home 页面(首页/落地页)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `LANDING_PAGE_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
- `is_authenticated` - 是否已登录
|
||||
- `user_id` - 用户ID(如果已登录)
|
||||
|
||||
#### 🎯 功能卡片点击
|
||||
- **事件**: `FEATURE_CARD_CLICKED`
|
||||
- **触发时机**: 用户点击任何功能卡片
|
||||
- **属性**:
|
||||
- `feature_id` - 功能ID(news-catalyst, concepts, stocks, etc.)
|
||||
- `feature_title` - 功能标题
|
||||
- `feature_url` - 目标URL
|
||||
- `is_featured` - 是否为推荐功能(新闻中心为 true)
|
||||
- `link_type` - 链接类型(internal/external)
|
||||
|
||||
**追踪的6个核心功能**:
|
||||
1. **新闻中心** (`news-catalyst`) - 推荐功能,黄色边框
|
||||
2. **概念中心** (`concepts`)
|
||||
3. **个股信息汇总** (`stocks`)
|
||||
4. **涨停板块分析** (`limit-analyse`)
|
||||
5. **个股罗盘** (`company`)
|
||||
6. **模拟盘交易** (`trading-simulation`)
|
||||
|
||||
---
|
||||
|
||||
### 2. StockOverview 页面(个股中心)✅ 已完成
|
||||
|
||||
**注意**:个股中心页面已完全实现 PostHog 追踪,通过 `src/views/StockOverview/hooks/useStockOverviewEvents.js` Hook。
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `STOCK_OVERVIEW_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
|
||||
#### 📊 市场统计数据查看
|
||||
- **事件**: `STOCK_LIST_VIEWED`
|
||||
- **触发时机**: 加载市场统计数据
|
||||
- **属性**:
|
||||
- `total_market_cap` - 总市值
|
||||
- `total_volume` - 总成交量
|
||||
- `rising_stocks` - 上涨股票数
|
||||
- `falling_stocks` - 下跌股票数
|
||||
- `data_date` - 数据日期
|
||||
|
||||
#### 🔍 搜索追踪
|
||||
- **事件**: `SEARCH_INITIATED` / `STOCK_SEARCHED`
|
||||
- **触发时机**: 用户输入搜索、完成搜索
|
||||
- **属性**:
|
||||
- `query` - 搜索关键词
|
||||
- `result_count` - 搜索结果数量
|
||||
- `has_results` - 是否有结果
|
||||
- `context` - 固定为 'stock_overview'
|
||||
|
||||
#### 🎯 搜索结果点击
|
||||
- **事件**: `SEARCH_RESULT_CLICKED`
|
||||
- **触发时机**: 用户点击搜索结果
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `exchange` - 交易所
|
||||
- `position` - 在搜索结果中的位置
|
||||
- `context` - 固定为 'stock_overview'
|
||||
|
||||
#### 🔥 概念卡片点击
|
||||
- **事件**: `CONCEPT_CLICKED`
|
||||
- **触发时机**: 用户点击热门概念卡片
|
||||
- **属性**:
|
||||
- `concept_name` - 概念名称
|
||||
- `concept_code` - 概念代码
|
||||
- `change_percent` - 涨跌幅
|
||||
- `stock_count` - 股票数量
|
||||
- `rank` - 排名
|
||||
- `source` - 固定为 'daily_hot_concepts'
|
||||
|
||||
#### 🏷️ 概念股票标签点击
|
||||
- **事件**: `CONCEPT_STOCK_CLICKED`
|
||||
- **触发时机**: 点击概念下的股票标签
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `concept_name` - 所属概念
|
||||
- `source` - 固定为 'daily_hot_concepts_tag'
|
||||
|
||||
#### 📊 热力图股票点击
|
||||
- **事件**: `STOCK_CLICKED`
|
||||
- **触发时机**: 点击热力图中的股票
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `change_percent` - 涨跌幅
|
||||
- `market_cap_range` - 市值区间
|
||||
- `source` - 固定为 'market_heatmap'
|
||||
|
||||
#### 📅 日期选择变化
|
||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
||||
- **触发时机**: 用户选择不同的交易日期
|
||||
- **属性**:
|
||||
- `filter_type` - 固定为 'date'
|
||||
- `filter_value` - 新选择的日期
|
||||
- `previous_value` - 之前的日期
|
||||
- `context` - 固定为 'stock_overview'
|
||||
|
||||
**实施方式**: Custom Hook (`useStockOverviewEvents.js`) 已集成
|
||||
|
||||
---
|
||||
|
||||
### 3. Concept 页面(概念中心)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `CONCEPT_CENTER_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
|
||||
#### 🔍 搜索查询
|
||||
- **事件**: `SEARCH_QUERY_SUBMITTED`
|
||||
- **触发时机**: 用户搜索概念
|
||||
- **属性**:
|
||||
- `query` - 搜索关键词
|
||||
- `category` - 固定为 'concept'
|
||||
- `result_count` - 搜索结果数量
|
||||
- `has_results` - 是否有结果
|
||||
|
||||
#### 🎚️ 筛选追踪
|
||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
||||
- **触发时机**: 用户更改筛选条件
|
||||
- **属性**:
|
||||
- `filter_type` - 筛选类型(sort/date)
|
||||
- `filter_value` - 筛选值
|
||||
- `previous_value` - 之前的值
|
||||
- `context` - 固定为 'concept_center'
|
||||
|
||||
**支持的筛选类型**:
|
||||
1. **排序** (`sort`): 涨跌幅/相关度/股票数量/概念名称
|
||||
2. **日期范围** (`date`): 选择交易日期
|
||||
|
||||
#### 🎯 概念卡片点击
|
||||
- **事件**: `CONCEPT_CLICKED`
|
||||
- **触发时机**: 用户点击概念卡片
|
||||
- **属性**:
|
||||
- `concept_id` - 概念ID
|
||||
- `concept_name` - 概念名称
|
||||
- `change_percent` - 涨跌幅
|
||||
- `stock_count` - 股票数量
|
||||
- `position` - 在列表中的位置
|
||||
- `source` - 固定为 'concept_center_list'
|
||||
|
||||
#### 👀 查看个股
|
||||
- **事件**: `CONCEPT_STOCKS_VIEWED`
|
||||
- **触发时机**: 用户点击"查看个股"按钮
|
||||
- **属性**:
|
||||
- `concept_name` - 概念名称
|
||||
- `stock_count` - 股票数量
|
||||
- `source` - 固定为 'concept_center'
|
||||
|
||||
#### 🏷️ 概念股票点击
|
||||
- **事件**: `CONCEPT_STOCK_CLICKED`
|
||||
- **触发时机**: 点击概念股票表格中的股票
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `concept_name` - 所属概念
|
||||
- `source` - 固定为 'concept_center_stock_table'
|
||||
|
||||
#### 📊 历史时间轴查看
|
||||
- **事件**: `CONCEPT_TIMELINE_VIEWED`
|
||||
- **触发时机**: 用户点击"历史时间轴"按钮
|
||||
- **属性**:
|
||||
- `concept_id` - 概念ID
|
||||
- `concept_name` - 概念名称
|
||||
- `source` - 固定为 'concept_center'
|
||||
|
||||
#### 📄 翻页追踪
|
||||
- **事件**: `NEWS_LIST_VIEWED`
|
||||
- **触发时机**: 用户翻页
|
||||
- **属性**:
|
||||
- `page` - 页码
|
||||
- `filters` - 当前筛选条件
|
||||
- `sort` - 排序方式
|
||||
- `has_query` - 是否有搜索词
|
||||
- `date` - 日期
|
||||
- `context` - 固定为 'concept_center'
|
||||
|
||||
#### 🔄 视图模式切换
|
||||
- **事件**: `VIEW_MODE_CHANGED`
|
||||
- **触发时机**: 用户切换网格/列表视图
|
||||
- **属性**:
|
||||
- `view_mode` - 新视图模式(grid/list)
|
||||
- `previous_mode` - 之前的模式
|
||||
- `context` - 固定为 'concept_center'
|
||||
|
||||
---
|
||||
|
||||
### 4. Company 页面(公司详情/个股罗盘)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `COMPANY_PAGE_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
- `stock_code` - 当前查看的股票代码
|
||||
|
||||
#### 🔍 股票搜索
|
||||
- **事件**: `STOCK_SEARCHED`
|
||||
- **触发时机**: 用户输入股票代码并查询
|
||||
- **属性**:
|
||||
- `query` - 搜索的股票代码
|
||||
- `stock_code` - 股票代码
|
||||
- `previous_stock_code` - 之前查看的股票代码
|
||||
- `context` - 固定为 'company_page'
|
||||
|
||||
#### 🔄 Tab 切换
|
||||
- **事件**: `TAB_CHANGED`
|
||||
- **触发时机**: 用户切换不同的 Tab
|
||||
- **属性**:
|
||||
- `tab_index` - Tab 索引(0-3)
|
||||
- `tab_name` - Tab 名称(公司概览/股票行情/财务全景/盈利预测)
|
||||
- `previous_tab_index` - 之前的 Tab 索引
|
||||
- `stock_code` - 当前股票代码
|
||||
- `context` - 固定为 'company_page'
|
||||
|
||||
**支持的 Tab**:
|
||||
1. **公司概览** (index 0): 公司基本信息
|
||||
2. **股票行情** (index 1): 实时行情数据
|
||||
3. **财务全景** (index 2): 财务报表分析
|
||||
4. **盈利预测** (index 3): 盈利预测数据
|
||||
|
||||
#### ⭐ 自选股管理
|
||||
- **事件**: `WATCHLIST_ADDED` / `WATCHLIST_REMOVED`
|
||||
- **触发时机**: 用户添加/移除自选股
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `source` - 固定为 'company_page'
|
||||
|
||||
---
|
||||
|
||||
### 5. Community 页面(新闻催化分析)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `COMMUNITY_PAGE_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
- `has_hot_events` - 是否有热点事件
|
||||
- `has_keywords` - 是否有热门关键词
|
||||
|
||||
#### 🔍 搜索追踪
|
||||
- **事件**: `SEARCH_QUERY_SUBMITTED`
|
||||
- **触发时机**: 用户输入搜索关键词
|
||||
- **属性**:
|
||||
- `query` - 搜索关键词
|
||||
- `category` - 分类(固定为 'news')
|
||||
- `previous_query` - 上一次搜索词
|
||||
|
||||
#### 🎚️ 筛选追踪
|
||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
||||
- **触发时机**: 用户更改筛选条件
|
||||
- **属性**:
|
||||
- `filter_type` - 筛选类型(sort/importance/date_range/industry)
|
||||
- `filter_value` - 筛选值
|
||||
- `previous_value` - 上一次的值
|
||||
|
||||
**支持的筛选类型**:
|
||||
1. **排序** (`sort`): 最新/最热/重要性
|
||||
2. **重要性** (`importance`): 全部/高/中/低
|
||||
3. **时间范围** (`date_range`): 今天/近7天/近30天
|
||||
4. **行业** (`industry`): 各行业代码
|
||||
|
||||
#### 🗞️ 新闻点击追踪
|
||||
- **事件**: `NEWS_ARTICLE_CLICKED`
|
||||
- **触发时机**: 用户点击新闻事件
|
||||
- **属性**:
|
||||
- `event_id` - 事件ID
|
||||
- `event_title` - 事件标题
|
||||
- `importance` - 重要性等级
|
||||
- `source` - 来源(固定为 'community_page')
|
||||
- `has_stocks` - 是否包含相关股票
|
||||
- `has_concepts` - 是否包含相关概念
|
||||
|
||||
#### 📖 详情查看追踪
|
||||
- **事件**: `NEWS_DETAIL_OPENED`
|
||||
- **触发时机**: 用户点击"查看详情"
|
||||
- **属性**:
|
||||
- `event_id` - 事件ID
|
||||
- `source` - 来源(固定为 'community_page')
|
||||
|
||||
#### 📄 翻页追踪
|
||||
- **事件**: `NEWS_LIST_VIEWED`
|
||||
- **触发时机**: 用户翻页
|
||||
- **属性**:
|
||||
- `page` - 页码
|
||||
- `filters` - 当前筛选条件
|
||||
- `sort` - 排序方式
|
||||
- `importance` - 重要性
|
||||
- `has_query` - 是否有搜索词
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 实施方式
|
||||
|
||||
### 方案:Custom Hook 集成(推荐)
|
||||
|
||||
**优势**:
|
||||
- ✅ 集中管理,易于维护
|
||||
- ✅ 自动追踪,无需修改组件
|
||||
- ✅ 符合关注点分离原则
|
||||
- ✅ 便于测试和调试
|
||||
|
||||
### 修改的文件
|
||||
|
||||
#### 0. `src/views/StockOverview/hooks/useStockOverviewEvents.js` ✅
|
||||
|
||||
**文件已存在**,无需修改。已完整实现个股中心的所有追踪事件。
|
||||
|
||||
#### 1. `src/views/Concept/hooks/useConceptEvents.js`
|
||||
|
||||
**新建 Hook 文件**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
```
|
||||
|
||||
**提供的追踪函数**:
|
||||
- `trackConceptSearched()` - 搜索概念
|
||||
- `trackFilterApplied()` - 筛选变化
|
||||
- `trackConceptClicked()` - 概念点击
|
||||
- `trackConceptStocksViewed()` - 查看个股
|
||||
- `trackConceptStockClicked()` - 点击概念股票
|
||||
- `trackConceptTimelineViewed()` - 历史时间轴
|
||||
- `trackPageChange()` - 翻页
|
||||
- `trackViewModeChanged()` - 视图切换
|
||||
|
||||
#### 2. `src/views/Company/hooks/useCompanyEvents.js`
|
||||
|
||||
**新建 Hook 文件**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
```
|
||||
|
||||
**提供的追踪函数**:
|
||||
- `trackStockSearched()` - 股票搜索
|
||||
- `trackTabChanged()` - Tab 切换
|
||||
- `trackWatchlistAdded()` - 加入自选
|
||||
- `trackWatchlistRemoved()` - 移除自选
|
||||
|
||||
#### 3. `src/views/Company/index.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
```
|
||||
|
||||
**添加的 Hook**:
|
||||
```javascript
|
||||
const {
|
||||
trackStockSearched,
|
||||
trackTabChanged,
|
||||
trackWatchlistAdded,
|
||||
trackWatchlistRemoved,
|
||||
} = useCompanyEvents({ stockCode });
|
||||
```
|
||||
|
||||
**添加的 State**:
|
||||
```javascript
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
1. **`handleSearch`**: 追踪股票搜索
|
||||
2. **`handleWatchlistToggle`**: 追踪自选股添加/移除
|
||||
3. **Tabs `onChange`**: 追踪 Tab 切换
|
||||
|
||||
#### 4. `src/views/Concept/index.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { useConceptEvents } from './hooks/useConceptEvents';
|
||||
```
|
||||
|
||||
**添加的 Hook**:
|
||||
```javascript
|
||||
const {
|
||||
trackConceptSearched,
|
||||
trackFilterApplied,
|
||||
trackConceptClicked,
|
||||
trackConceptStocksViewed,
|
||||
trackConceptStockClicked,
|
||||
trackConceptTimelineViewed,
|
||||
trackPageChange,
|
||||
trackViewModeChanged,
|
||||
} = useConceptEvents({ navigate });
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
1. **`handleSearch`**: 追踪搜索查询
|
||||
2. **`handleSortChange`**: 追踪排序变化
|
||||
3. **`handleDateChange`**: 追踪日期变化
|
||||
4. **`handlePageChange`**: 追踪翻页
|
||||
5. **`handleConceptClick`**: 追踪概念点击
|
||||
6. **`handleViewStocks`**: 追踪查看个股
|
||||
7. **`handleViewContent`**: 追踪历史时间轴
|
||||
8. **视图切换按钮**: 追踪网格/列表切换
|
||||
|
||||
#### 3. `src/views/Home/HomePage.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { ACQUISITION_EVENTS } from '../../lib/constants';
|
||||
```
|
||||
|
||||
**添加的 Hook**:
|
||||
```javascript
|
||||
const { track } = usePostHogTrack();
|
||||
```
|
||||
|
||||
**添加的 useEffect**(页面浏览追踪):
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
is_authenticated: isAuthenticated,
|
||||
user_id: user?.id || null,
|
||||
});
|
||||
}, [track, isAuthenticated, user?.id]);
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
- **`handleProductClick`**: 从接收 URL 改为接收完整 feature 对象,添加追踪逻辑
|
||||
|
||||
**修改后的代码**:
|
||||
```javascript
|
||||
const handleProductClick = useCallback((feature) => {
|
||||
// 🎯 PostHog 追踪:功能卡片点击
|
||||
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
|
||||
feature_id: feature.id,
|
||||
feature_title: feature.title,
|
||||
feature_url: feature.url,
|
||||
is_featured: feature.featured || false,
|
||||
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
|
||||
});
|
||||
|
||||
// 原有导航逻辑
|
||||
if (feature.url.startsWith('http')) {
|
||||
window.open(feature.url, '_blank');
|
||||
} else {
|
||||
navigate(feature.url);
|
||||
}
|
||||
}, [track, navigate]);
|
||||
```
|
||||
|
||||
**更新的 onClick 事件**:
|
||||
```javascript
|
||||
// 从
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
|
||||
// 改为
|
||||
onClick={() => handleProductClick(coreFeatures[0])}
|
||||
```
|
||||
|
||||
#### 1. `src/views/Community/hooks/useEventFilters.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
```
|
||||
|
||||
**添加的Hook**:
|
||||
```javascript
|
||||
const { track } = usePostHogTrack();
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
1. **`updateFilters`**: 追踪搜索和筛选
|
||||
2. **`handlePageChange`**: 追踪翻页
|
||||
3. **`handleEventClick`**: 追踪新闻点击
|
||||
4. **`handleViewDetail`**: 追踪详情查看
|
||||
|
||||
#### 2. `src/views/Community/index.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../lib/constants';
|
||||
```
|
||||
|
||||
**添加的Hook**:
|
||||
```javascript
|
||||
const { track } = usePostHogTrack();
|
||||
```
|
||||
|
||||
**添加的useEffect**:
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
has_hot_events: hotEvents && hotEvents.length > 0,
|
||||
has_keywords: popularKeywords && popularKeywords.length > 0,
|
||||
});
|
||||
}, [track]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 追踪效果示例
|
||||
|
||||
### 用户行为路径示例
|
||||
|
||||
**首页转化路径**:
|
||||
```
|
||||
1. 游客访问首页
|
||||
→ 触发: LANDING_PAGE_VIEWED
|
||||
→ 属性: { is_authenticated: false, user_id: null }
|
||||
|
||||
2. 点击"新闻中心"功能卡片
|
||||
→ 触发: FEATURE_CARD_CLICKED
|
||||
→ 属性: { feature_id: "news-catalyst", feature_title: "新闻中心", is_featured: true, link_type: "internal" }
|
||||
|
||||
3. 进入 Community 页面
|
||||
→ 触发: COMMUNITY_PAGE_VIEWED
|
||||
```
|
||||
|
||||
**Community 页面行为路径**:
|
||||
```
|
||||
1. 用户进入 Community 页面
|
||||
→ 触发: COMMUNITY_PAGE_VIEWED
|
||||
|
||||
2. 用户搜索 "人工智能"
|
||||
→ 触发: SEARCH_QUERY_SUBMITTED
|
||||
→ 属性: { query: "人工智能", category: "news" }
|
||||
|
||||
3. 用户筛选 "重要性:高"
|
||||
→ 触发: SEARCH_FILTER_APPLIED
|
||||
→ 属性: { filter_type: "importance", filter_value: "high" }
|
||||
|
||||
4. 用户点击第一条新闻
|
||||
→ 触发: NEWS_ARTICLE_CLICKED
|
||||
→ 属性: { event_id: "123", event_title: "...", importance: "high", source: "community_page" }
|
||||
|
||||
5. 用户翻到第2页
|
||||
→ 触发: NEWS_LIST_VIEWED
|
||||
→ 属性: { page: 2, filters: { sort: "new", importance: "high", has_query: true } }
|
||||
|
||||
6. 用户点击"查看详情"
|
||||
→ 触发: NEWS_DETAIL_OPENED
|
||||
→ 属性: { event_id: "456", source: "community_page" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试方法
|
||||
|
||||
### 1. 使用 Redux DevTools
|
||||
|
||||
1. 打开应用:`npm start`
|
||||
2. 打开浏览器 Redux DevTools
|
||||
3. 筛选 `posthog/trackEvent` actions
|
||||
4. 执行各种操作
|
||||
5. 查看追踪的事件和属性
|
||||
|
||||
### 2. 控制台日志
|
||||
|
||||
开发环境下,PostHog 会自动输出日志:
|
||||
|
||||
```
|
||||
📍 Event tracked: Community Page Viewed { timestamp: "...", has_hot_events: true }
|
||||
📍 Event tracked: Search Query Submitted { query: "人工智能", category: "news" }
|
||||
📍 Event tracked: Search Filter Applied { filter_type: "importance", filter_value: "high" }
|
||||
```
|
||||
|
||||
### 3. PostHog Dashboard
|
||||
|
||||
1. 登录 PostHog 后台
|
||||
2. 查看 "Events" 页面
|
||||
3. 筛选 Community 相关事件:
|
||||
- `Community Page Viewed`
|
||||
- `Search Query Submitted`
|
||||
- `Search Filter Applied`
|
||||
- `News Article Clicked`
|
||||
- `News List Viewed`
|
||||
|
||||
---
|
||||
|
||||
## 📈 数据分析建议
|
||||
|
||||
### 1. 搜索行为分析
|
||||
|
||||
**问题**: 用户最常搜索什么?
|
||||
|
||||
**方法**:
|
||||
- 筛选 `SEARCH_QUERY_SUBMITTED` 事件
|
||||
- 按 `query` 属性分组
|
||||
- 查看 Top 关键词
|
||||
|
||||
### 2. 筛选偏好分析
|
||||
|
||||
**问题**: 用户更喜欢什么排序方式?
|
||||
|
||||
**方法**:
|
||||
- 筛选 `SEARCH_FILTER_APPLIED` 事件
|
||||
- 按 `filter_type: "sort"` 筛选
|
||||
- 按 `filter_value` 分组统计
|
||||
|
||||
### 3. 新闻热度分析
|
||||
|
||||
**问题**: 哪些新闻最受欢迎?
|
||||
|
||||
**方法**:
|
||||
- 筛选 `NEWS_ARTICLE_CLICKED` 事件
|
||||
- 按 `event_id` 分组
|
||||
- 统计点击次数
|
||||
|
||||
### 4. 用户旅程分析
|
||||
|
||||
**问题**: 用户从搜索到点击的转化率?
|
||||
|
||||
**方法**:
|
||||
- 创建漏斗:
|
||||
1. `COMMUNITY_PAGE_VIEWED`
|
||||
2. `SEARCH_QUERY_SUBMITTED`
|
||||
3. `NEWS_ARTICLE_CLICKED`
|
||||
- 分析每一步的流失率
|
||||
|
||||
---
|
||||
|
||||
## 🔧 扩展计划
|
||||
|
||||
### 下一步:其他页面追踪
|
||||
|
||||
按优先级排序:
|
||||
|
||||
1. **Concept(概念中心)** ⭐⭐⭐
|
||||
- 搜索概念
|
||||
- 点击概念卡片
|
||||
- 查看概念详情
|
||||
- 点击概念内股票
|
||||
|
||||
2. **StockOverview(个股中心)** ⭐⭐⭐
|
||||
- 搜索股票
|
||||
- 点击股票卡片
|
||||
- 查看股票详情
|
||||
- 切换 Tab
|
||||
|
||||
3. **LimitAnalyse(涨停分析)** ⭐⭐
|
||||
- 进入页面
|
||||
- 点击涨停板块
|
||||
- 展开板块详情
|
||||
- 点击涨停个股
|
||||
|
||||
4. **TradingSimulation(模拟盘)** ⭐⭐
|
||||
- 进入模拟盘
|
||||
- 下单操作
|
||||
- 查看持仓
|
||||
- 查看历史
|
||||
|
||||
5. **Company(公司详情)** ⭐
|
||||
- 查看公司概览
|
||||
- 查看财务全景
|
||||
- 查看盈利预测
|
||||
- Tab 切换
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 属性命名规范
|
||||
|
||||
- 使用 **snake_case** 命名(与 PostHog 推荐一致)
|
||||
- 属性名要 **描述性强**,易于理解
|
||||
- 使用 **布尔值** 表示是/否(has_xxx, is_xxx)
|
||||
- 使用 **枚举值** 表示类别(filter_type: "sort")
|
||||
|
||||
### 2. 事件追踪原则
|
||||
|
||||
- **追踪用户意图**,而不仅仅是点击
|
||||
- **添加上下文**,帮助分析(previous_value, source)
|
||||
- **保持一致性**,相似事件使用相似属性
|
||||
- **避免敏感信息**,不追踪用户隐私数据
|
||||
|
||||
### 3. 性能优化
|
||||
|
||||
- 使用 **`usePostHogTrack`** 而不是 `usePostHogRedux`
|
||||
- 更轻量,只订阅追踪功能
|
||||
- 避免不必要的重渲染
|
||||
- 在 **Custom Hooks** 中集成,而不是每个组件
|
||||
- 集中管理,易于维护
|
||||
- 减少重复代码
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 依赖管理
|
||||
|
||||
确保 `useCallback` 的依赖数组包含 `track`:
|
||||
|
||||
```javascript
|
||||
// ✅ 正确
|
||||
const handleClick = useCallback(() => {
|
||||
track(EVENT_NAME, { ... });
|
||||
}, [track]);
|
||||
|
||||
// ❌ 错误(缺少 track)
|
||||
const handleClick = useCallback(() => {
|
||||
track(EVENT_NAME, { ... });
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2. 事件去重
|
||||
|
||||
避免重复追踪相同事件:
|
||||
|
||||
```javascript
|
||||
// ✅ 正确(只在值变化时追踪)
|
||||
if (newFilters.sort !== filters.sort) {
|
||||
track(SEARCH_FILTER_APPLIED, { ... });
|
||||
}
|
||||
|
||||
// ❌ 错误(每次都追踪)
|
||||
track(SEARCH_FILTER_APPLIED, { ... });
|
||||
```
|
||||
|
||||
### 3. 空值处理
|
||||
|
||||
使用安全的属性访问:
|
||||
|
||||
```javascript
|
||||
// ✅ 正确
|
||||
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0)
|
||||
|
||||
// ❌ 错误(可能报错)
|
||||
has_stocks: event.related_stocks.length > 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
- **PostHog Events 文档**: https://posthog.com/docs/data/events
|
||||
- **PostHog Properties 文档**: https://posthog.com/docs/data/properties
|
||||
- **Redux PostHog 集成**: `POSTHOG_REDUX_INTEGRATION.md`
|
||||
- **事件常量定义**: `src/lib/constants.js`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 已实现的功能
|
||||
|
||||
- ✅ Home 页面追踪(2个事件)
|
||||
- ✅ StockOverview 页面完整追踪(10个事件)✨ 已完成
|
||||
- ✅ Concept 页面完整追踪(9个事件)
|
||||
- ✅ Company 页面完整追踪(5个事件)
|
||||
- ✅ Community 页面完整追踪(7个事件)
|
||||
- ✅ Custom Hook 集成方案
|
||||
- ✅ Redux DevTools 调试支持
|
||||
- ✅ 详细的事件属性
|
||||
|
||||
### 追踪的用户行为
|
||||
|
||||
**Home 页面**:
|
||||
1. **页面访问** - 了解流量来源、登录转化率
|
||||
2. **功能卡片点击** - 识别最受欢迎的功能
|
||||
3. **推荐功能效果** - 分析特色功能(新闻中心)的点击率
|
||||
|
||||
**StockOverview 页面** ✨:
|
||||
1. **页面访问** - 了解个股中心流量
|
||||
2. **搜索行为** - 股票搜索、搜索结果点击
|
||||
3. **概念交互** - 热门概念点击、概念股票标签点击
|
||||
4. **热力图交互** - 热力图中股票点击
|
||||
5. **数据筛选** - 日期选择变化
|
||||
6. **市场统计** - 市场数据查看
|
||||
|
||||
**Concept 页面**:
|
||||
1. **页面访问** - 了解概念中心流量
|
||||
2. **搜索行为** - 概念搜索、搜索结果数量
|
||||
3. **筛选偏好** - 排序方式、日期选择
|
||||
4. **概念交互** - 概念点击、位置追踪
|
||||
5. **个股查看** - 查看个股、股票点击
|
||||
6. **时间轴查看** - 历史时间轴
|
||||
7. **翻页行为** - 优化分页逻辑
|
||||
8. **视图切换** - 网格/列表偏好
|
||||
|
||||
**Company 页面**:
|
||||
1. **页面访问** - 了解公司详情页流量
|
||||
2. **股票搜索** - 用户查询哪些股票
|
||||
3. **Tab 切换** - 用户最关注哪个 Tab(概览/行情/财务/预测)
|
||||
4. **自选股管理** - 自选股添加/移除行为
|
||||
5. **股票切换** - 分析用户查看股票的路径
|
||||
|
||||
**Community 页面**:
|
||||
1. **页面访问** - 了解流量来源
|
||||
2. **搜索行为** - 了解用户需求
|
||||
3. **筛选偏好** - 优化默认设置
|
||||
4. **内容点击** - 识别热门内容
|
||||
5. **详情查看** - 分析用户兴趣
|
||||
6. **翻页行为** - 优化分页逻辑
|
||||
|
||||
### 下一步计划
|
||||
|
||||
1. ~~在关键页面实施追踪(Home, StockOverview, Concept, Company, Community)~~ ✅ 已完成
|
||||
2. **下一步**:其他页面追踪
|
||||
- LimitAnalyse(涨停分析)⭐⭐
|
||||
- TradingSimulation(模拟盘)⭐⭐
|
||||
3. 创建 PostHog Dashboard 和 Insights
|
||||
4. 设置用户行为漏斗分析
|
||||
5. 配置 Feature Flags 进行 A/B 测试
|
||||
|
||||
---
|
||||
|
||||
**Home, StockOverview, Concept, Company, Community 页面追踪全部完成!** 🚀
|
||||
|
||||
现在你可以在 PostHog 后台看到完整的用户行为数据:
|
||||
- **首页** → **个股中心/概念中心/公司详情/新闻中心** 的完整转化路径
|
||||
- **搜索行为**、**筛选偏好**、**内容点击** 的详细数据
|
||||
- **Tab 切换**、**视图切换**、**翻页行为** 的用户习惯分析
|
||||
- **自选股管理** 的用户行为追踪
|
||||
|
||||
共追踪 **33个事件**,覆盖 **5个核心页面**。
|
||||
@@ -1,338 +0,0 @@
|
||||
# 崩溃修复测试指南
|
||||
|
||||
> 测试时间:2025-10-14
|
||||
> 测试范围:SignInIllustration.js + SignUpIllustration.js
|
||||
> 服务器地址:http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证以下修复是否有效:
|
||||
- ✅ 响应对象崩溃(6处)
|
||||
- ✅ 组件卸载后 setState(6处)
|
||||
- ✅ 定时器内存泄漏(2处)
|
||||
|
||||
---
|
||||
|
||||
## 📋 测试清单
|
||||
|
||||
### ✅ 关键测试(必做)
|
||||
|
||||
#### 1. **网络异常测试** - 验证响应对象修复
|
||||
|
||||
**登录页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-in
|
||||
2. 切换到"验证码登录"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 打开浏览器开发者工具 (F12) → Network 标签
|
||||
5. 点击 Offline 模拟断网
|
||||
6. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送验证码失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
|
||||
修复前:
|
||||
❌ 页面白屏崩溃
|
||||
❌ Console 报错:Cannot read property 'json' of null
|
||||
```
|
||||
|
||||
**登录页面 - 微信登录**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面,保持断网状态
|
||||
2. 点击"扫码登录"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"获取微信授权失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
```
|
||||
|
||||
**注册页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-up
|
||||
2. 切换到"验证码注册"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 保持断网状态
|
||||
5. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送失败 - 网络请求失败..."
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. **组件卸载测试** - 验证内存泄漏修复
|
||||
|
||||
**倒计时中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 恢复网络连接
|
||||
2. 在登录页面输入手机号并发送验证码
|
||||
3. 等待倒计时开始(60秒倒计时)
|
||||
4. 立即点击浏览器后退按钮或切换到其他页面
|
||||
5. 打开 Console 查看是否有警告
|
||||
|
||||
预期结果:
|
||||
✅ 无警告:"Can't perform a React state update on an unmounted component"
|
||||
✅ 倒计时定时器正确清理
|
||||
✅ 无内存泄漏
|
||||
|
||||
修复前:
|
||||
❌ Console 警告:Memory leak warning
|
||||
❌ setState 在组件卸载后仍被调用
|
||||
```
|
||||
|
||||
**请求进行中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在注册页面填写完整信息
|
||||
2. 点击"注册"按钮
|
||||
3. 在请求响应前(loading 状态)快速刷新页面或关闭标签页
|
||||
4. 打开新标签页查看 Console
|
||||
|
||||
预期结果:
|
||||
✅ 无崩溃
|
||||
✅ 无警告信息
|
||||
✅ 请求被正确取消或忽略
|
||||
```
|
||||
|
||||
**注册成功跳转前离开**
|
||||
```
|
||||
测试步骤:
|
||||
1. 完成注册提交
|
||||
2. 在显示"注册成功"提示后
|
||||
3. 立即关闭标签页(不等待2秒自动跳转)
|
||||
|
||||
预期结果:
|
||||
✅ 无警告
|
||||
✅ navigate 不会在组件卸载后执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. **边界情况测试** - 验证数据完整性检查
|
||||
|
||||
**后端返回空响应**
|
||||
```
|
||||
测试步骤(需要模拟后端):
|
||||
1. 使用 Chrome DevTools → Network → 右键请求 → Edit and Resend
|
||||
2. 修改响应为空对象 {}
|
||||
3. 观察页面反应
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"服务器响应为空"
|
||||
✅ 不会尝试访问 undefined 属性
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
**后端返回 500 错误**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面点击"扫码登录"
|
||||
2. 如果后端返回 500 错误
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"获取二维码失败:HTTP 500"
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🧪 进阶测试(推荐)
|
||||
|
||||
#### 4. **弱网环境测试**
|
||||
|
||||
**慢速网络模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. Chrome DevTools → Network → Throttling → Slow 3G
|
||||
2. 尝试发送验证码
|
||||
3. 等待 10 秒(超时时间)
|
||||
|
||||
预期结果:
|
||||
✅ 10秒后显示超时错误
|
||||
✅ 不会无限等待
|
||||
✅ 用户可以重试
|
||||
```
|
||||
|
||||
**丢包模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. 使用 Chrome DevTools 模拟丢包
|
||||
2. 连续点击"发送验证码"多次
|
||||
|
||||
预期结果:
|
||||
✅ 每次请求都有适当的错误提示
|
||||
✅ 不会因为并发请求而崩溃
|
||||
✅ 按钮在请求期间正确禁用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. **定时器清理测试**
|
||||
|
||||
**倒计时清理验证**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面发送验证码
|
||||
2. 等待倒计时到 50 秒
|
||||
3. 快速切换到注册页面
|
||||
4. 再切换回登录页面
|
||||
5. 观察倒计时是否重置
|
||||
|
||||
预期结果:
|
||||
✅ 定时器在页面切换时正确清理
|
||||
✅ 返回登录页面时倒计时重新开始(如果再次发送)
|
||||
✅ 没有多个定时器同时运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. **并发请求测试**
|
||||
|
||||
**快速连续点击**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面输入手机号
|
||||
2. 快速连续点击"发送验证码"按钮 5 次
|
||||
|
||||
预期结果:
|
||||
✅ 只发送一次请求(按钮在请求期间禁用)
|
||||
✅ 不会因为并发而崩溃
|
||||
✅ 正确显示 loading 状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 监控指标
|
||||
|
||||
### Console 检查清单
|
||||
|
||||
在测试过程中,打开 Console (F12) 监控以下内容:
|
||||
|
||||
```
|
||||
✅ 无红色错误(Error)
|
||||
✅ 无内存泄漏警告(Memory leak warning)
|
||||
✅ 无 setState 警告(Can't perform a React state update...)
|
||||
✅ 无 undefined 访问错误(Cannot read property of undefined)
|
||||
```
|
||||
|
||||
### Network 检查清单
|
||||
|
||||
打开 Network 标签监控:
|
||||
|
||||
```
|
||||
✅ 请求超时时间:10秒
|
||||
✅ 失败请求有正确的错误处理
|
||||
✅ 没有重复的请求
|
||||
✅ 请求被正确取消(如果页面卸载)
|
||||
```
|
||||
|
||||
### Performance 检查清单
|
||||
|
||||
打开 Performance 标签(可选):
|
||||
|
||||
```
|
||||
✅ 无内存泄漏(Memory 不会持续增长)
|
||||
✅ 定时器正确清理(Timer count 正确)
|
||||
✅ EventListener 正确清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试记录表
|
||||
|
||||
请在测试时填写以下表格:
|
||||
|
||||
| 测试项 | 状态 | 问题描述 | 截图 |
|
||||
|--------|------|---------|------|
|
||||
| 登录页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 登录页 - 断网微信登录 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 倒计时中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 请求进行中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册成功跳转前离开 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 后端返回空响应 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 慢速网络超时 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 定时器清理 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 并发请求 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 如何报告问题
|
||||
|
||||
如果发现问题,请提供:
|
||||
|
||||
1. **测试场景**:具体的测试步骤
|
||||
2. **预期结果**:应该发生什么
|
||||
3. **实际结果**:实际发生了什么
|
||||
4. **Console 错误**:完整的错误信息
|
||||
5. **截图/录屏**:问题的视觉证明
|
||||
6. **环境信息**:
|
||||
- 浏览器版本
|
||||
- 操作系统
|
||||
- 网络状态
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试完成检查
|
||||
|
||||
测试完成后,确认以下内容:
|
||||
|
||||
```
|
||||
□ 所有关键测试通过
|
||||
□ Console 无错误
|
||||
□ Network 请求正常
|
||||
□ 无内存泄漏警告
|
||||
□ 用户体验流畅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速测试命令
|
||||
|
||||
```bash
|
||||
# 1. 确认服务器运行
|
||||
curl http://localhost:3000
|
||||
|
||||
# 2. 打开浏览器测试
|
||||
open http://localhost:3000/auth/sign-in
|
||||
|
||||
# 3. 查看编译日志
|
||||
tail -f /tmp/react-build.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 测试页面链接
|
||||
|
||||
- **登录页面**: http://localhost:3000/auth/sign-in
|
||||
- **注册页面**: http://localhost:3000/auth/sign-up
|
||||
- **首页**: http://localhost:3000/home
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发者工具快捷键
|
||||
|
||||
```
|
||||
F12 - 打开开发者工具
|
||||
Ctrl/Cmd+R - 刷新页面
|
||||
Ctrl/Cmd+Shift+R - 强制刷新(清除缓存)
|
||||
Ctrl/Cmd+Shift+C - 元素选择器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**测试时间**:2025-10-14
|
||||
**预计测试时长**:15-30 分钟
|
||||
**建议测试人员**:开发者 + QA
|
||||
|
||||
祝测试顺利!如发现问题请及时反馈。
|
||||
File diff suppressed because it is too large
Load Diff
145
init-forum-es.js
Normal file
145
init-forum-es.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 初始化价值论坛 Elasticsearch 索引
|
||||
* 运行方式:node init-forum-es.js
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// Elasticsearch 配置
|
||||
const ES_BASE_URL = 'http://222.128.1.157:19200';
|
||||
|
||||
// 创建 axios 实例
|
||||
const esClient = axios.create({
|
||||
baseURL: ES_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 索引名称
|
||||
const INDICES = {
|
||||
POSTS: 'forum_posts',
|
||||
COMMENTS: 'forum_comments',
|
||||
EVENTS: 'forum_events',
|
||||
};
|
||||
|
||||
async function initializeIndices() {
|
||||
try {
|
||||
console.log('开始初始化 Elasticsearch 索引...\n');
|
||||
|
||||
// 1. 创建帖子索引
|
||||
console.log('创建帖子索引 (forum_posts)...');
|
||||
try {
|
||||
await esClient.put(`/${INDICES.POSTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
title: { type: 'text' },
|
||||
content: { type: 'text' },
|
||||
images: { type: 'keyword' },
|
||||
tags: { type: 'keyword' },
|
||||
category: { type: 'keyword' },
|
||||
likes_count: { type: 'integer' },
|
||||
comments_count: { type: 'integer' },
|
||||
views_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
updated_at: { type: 'date' },
|
||||
is_pinned: { type: 'boolean' },
|
||||
status: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ 帖子索引创建成功\n');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('⚠️ 帖子索引已存在,跳过创建\n');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建评论索引
|
||||
console.log('创建评论索引 (forum_comments)...');
|
||||
try {
|
||||
await esClient.put(`/${INDICES.COMMENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
content: { type: 'text' },
|
||||
parent_id: { type: 'keyword' },
|
||||
likes_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
status: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ 评论索引创建成功\n');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('⚠️ 评论索引已存在,跳过创建\n');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建事件时间轴索引
|
||||
console.log('创建事件时间轴索引 (forum_events)...');
|
||||
try {
|
||||
await esClient.put(`/${INDICES.EVENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
event_type: { type: 'keyword' },
|
||||
title: { type: 'text' },
|
||||
description: { type: 'text' },
|
||||
source: { type: 'keyword' },
|
||||
source_url: { type: 'keyword' },
|
||||
related_stocks: { type: 'keyword' },
|
||||
occurred_at: { type: 'date' },
|
||||
created_at: { type: 'date' },
|
||||
importance: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ 事件时间轴索引创建成功\n');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('⚠️ 事件时间轴索引已存在,跳过创建\n');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 验证索引
|
||||
console.log('验证索引...');
|
||||
const indices = await esClient.get('/_cat/indices/forum_*?v&format=json');
|
||||
console.log('已创建的论坛索引:');
|
||||
indices.data.forEach(index => {
|
||||
console.log(` - ${index.index} (docs: ${index['docs.count']}, size: ${index['store.size']})`);
|
||||
});
|
||||
|
||||
console.log('\n🎉 所有索引初始化完成!');
|
||||
console.log('\n下一步:');
|
||||
console.log('1. 访问 https://valuefrontier.cn/value-forum');
|
||||
console.log('2. 点击"发布帖子"按钮创建第一篇帖子');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应数据:', JSON.stringify(error.response.data, null, 2));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initializeIndices();
|
||||
14
package.json
14
package.json
@@ -6,9 +6,9 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@asseinfo/react-kanban": "^2.2.0",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/theme-tools": "^1.3.6",
|
||||
"@chakra-ui/icons": "^2.2.6",
|
||||
"@chakra-ui/react": "^2.10.9",
|
||||
"@chakra-ui/theme-tools": "^2.2.6",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.23.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"draft-js": "^0.11.7",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -39,9 +40,8 @@
|
||||
"history": "^5.3.0",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"posthog-js": "^1.295.0",
|
||||
"react": "18.3.1",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
@@ -78,7 +78,8 @@
|
||||
"styled-components": "^5.3.11",
|
||||
"stylis": "^4.0.10",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"tsparticles-slim": "^2.12.0"
|
||||
"tsparticles-slim": "^2.12.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-error-overlay": "6.0.9",
|
||||
@@ -138,7 +139,6 @@
|
||||
"react-error-overlay": "6.0.9",
|
||||
"sharp": "^0.34.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"yn": "^5.1.0"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" layout="admin">
|
||||
<html lang="zh-CN" dir="ltr" layout="admin">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
@@ -7,6 +7,177 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!-- 基本 SEO -->
|
||||
<title>价值前沿 - 金融AI舆情分析系统 | LLM赋能的智能分析平台</title>
|
||||
<meta name="description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析,助力投资决策" />
|
||||
<meta name="keywords" content="金融AI,舆情分析,股市行情,LLM,价值前沿,a股,美股,投资分析" />
|
||||
<link rel="canonical" href="https://valuefrontier.cn/" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://valuefrontier.cn/" />
|
||||
<meta property="og:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta property="og:description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析" />
|
||||
<meta property="og:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
<meta property="og:site_name" content="价值前沿" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://valuefrontier.cn/" />
|
||||
<meta name="twitter:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta name="twitter:description" content="基于金融大语言模型,实时监控股市行情、a股、美股" />
|
||||
<meta name="twitter:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
|
||||
<!-- SEO 增强 -->
|
||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
||||
<meta name="author" content="价值前沿团队" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
|
||||
<!-- 性能优化: DNS 预连接 -->
|
||||
<link rel="preconnect" href="https://valuefrontier.cn" />
|
||||
<link rel="dns-prefetch" href="https://valuefrontier.cn" />
|
||||
|
||||
<!-- JSON-LD 结构化数据: 组织信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"logo": "https://valuefrontier.cn/logo.png",
|
||||
"description": "基于金融大语言模型的智能舆情分析平台",
|
||||
"foundingDate": "2023",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "Customer Service",
|
||||
"availableLanguage": ["zh-CN"]
|
||||
},
|
||||
"sameAs": [
|
||||
"https://valuefrontier.cn"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 网站信息 + 搜索功能 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "金融AI舆情分析系统,实时监控股市行情",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://valuefrontier.cn/search?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 软件应用产品信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "价值前沿",
|
||||
"applicationCategory": "FinanceApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "基于金融大语言模型,实时监控股市行情、a股、美股,提供企业舆情分析",
|
||||
"offers": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "专业版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "198",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "旗舰版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "998",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.8",
|
||||
"ratingCount": "1250",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"featureList": [
|
||||
"实时舆情监控",
|
||||
"智能事件分析",
|
||||
"多维度数据可视化",
|
||||
"AI驱动的投资建议",
|
||||
"行业板块分析"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 面包屑导航 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "首页",
|
||||
"item": "https://valuefrontier.cn/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "事件中心",
|
||||
"item": "https://valuefrontier.cn/community"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "概念分析",
|
||||
"item": "https://valuefrontier.cn/concepts"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 4,
|
||||
"name": "个股分析",
|
||||
"item": "https://valuefrontier.cn/stocks"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link
|
||||
@@ -15,10 +186,19 @@
|
||||
href="%PUBLIC_URL%/apple-icon.png"
|
||||
/>
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
|
||||
<title>价值前沿——LLM赋能的分析平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<noscript>
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 20px;">
|
||||
<div>
|
||||
<h1 style="font-size: 2em; margin-bottom: 20px;">⚠️ 需要启用 JavaScript</h1>
|
||||
<p style="font-size: 1.2em; line-height: 1.6; max-width: 600px; margin: 0 auto;">
|
||||
价值前沿是一个现代化的 Web 应用,需要 JavaScript 才能正常运行。<br><br>
|
||||
请在浏览器设置中启用 JavaScript,然后刷新页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
public/og-image.jpg
Normal file
BIN
public/og-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
96
scripts/init-forum-indices.sh
Normal file
96
scripts/init-forum-indices.sh
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# 初始化价值论坛 Elasticsearch 索引
|
||||
# 使用 Nginx 代理或直连 ES
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 开始初始化价值论坛 Elasticsearch 索引..."
|
||||
echo ""
|
||||
|
||||
# ES 地址(根据环境选择)
|
||||
if [ -n "$USE_PROXY" ]; then
|
||||
ES_URL="https://valuefrontier.cn/es-api"
|
||||
echo "📡 使用 Nginx 代理: $ES_URL"
|
||||
else
|
||||
ES_URL="http://222.128.1.157:19200"
|
||||
echo "📡 直连 Elasticsearch: $ES_URL"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 1. 创建帖子索引
|
||||
echo "📝 创建帖子索引 (forum_posts)..."
|
||||
curl -X PUT "$ES_URL/forum_posts" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"author_id": { "type": "keyword" },
|
||||
"author_name": { "type": "text" },
|
||||
"author_avatar": { "type": "keyword" },
|
||||
"title": { "type": "text" },
|
||||
"content": { "type": "text" },
|
||||
"images": { "type": "keyword" },
|
||||
"tags": { "type": "keyword" },
|
||||
"category": { "type": "keyword" },
|
||||
"likes_count": { "type": "integer" },
|
||||
"comments_count": { "type": "integer" },
|
||||
"views_count": { "type": "integer" },
|
||||
"created_at": { "type": "date" },
|
||||
"updated_at": { "type": "date" },
|
||||
"is_pinned": { "type": "boolean" },
|
||||
"status": { "type": "keyword" }
|
||||
}
|
||||
}
|
||||
}' 2>/dev/null && echo "✅ 帖子索引创建成功" || echo "⚠️ 帖子索引已存在或创建失败"
|
||||
echo ""
|
||||
|
||||
# 2. 创建评论索引
|
||||
echo "💬 创建评论索引 (forum_comments)..."
|
||||
curl -X PUT "$ES_URL/forum_comments" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"post_id": { "type": "keyword" },
|
||||
"author_id": { "type": "keyword" },
|
||||
"author_name": { "type": "text" },
|
||||
"author_avatar": { "type": "keyword" },
|
||||
"content": { "type": "text" },
|
||||
"parent_id": { "type": "keyword" },
|
||||
"likes_count": { "type": "integer" },
|
||||
"created_at": { "type": "date" },
|
||||
"status": { "type": "keyword" }
|
||||
}
|
||||
}
|
||||
}' 2>/dev/null && echo "✅ 评论索引创建成功" || echo "⚠️ 评论索引已存在或创建失败"
|
||||
echo ""
|
||||
|
||||
# 3. 创建事件时间轴索引
|
||||
echo "⏰ 创建事件时间轴索引 (forum_events)..."
|
||||
curl -X PUT "$ES_URL/forum_events" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"post_id": { "type": "keyword" },
|
||||
"event_type": { "type": "keyword" },
|
||||
"title": { "type": "text" },
|
||||
"description": { "type": "text" },
|
||||
"source": { "type": "keyword" },
|
||||
"source_url": { "type": "keyword" },
|
||||
"related_stocks": { "type": "keyword" },
|
||||
"occurred_at": { "type": "date" },
|
||||
"created_at": { "type": "date" },
|
||||
"importance": { "type": "keyword" }
|
||||
}
|
||||
}
|
||||
}' 2>/dev/null && echo "✅ 事件时间轴索引创建成功" || echo "⚠️ 事件时间轴索引已存在或创建失败"
|
||||
echo ""
|
||||
|
||||
# 4. 验证索引
|
||||
echo "🔍 验证已创建的索引..."
|
||||
curl -X GET "$ES_URL/_cat/indices/forum_*?v" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
echo "🎉 初始化完成!"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 访问 https://valuefrontier.cn/value-forum"
|
||||
echo " 2. 点击"发布帖子"按钮创建第一篇帖子"
|
||||
76
src/App.js
76
src/App.js
@@ -9,8 +9,9 @@
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Routes
|
||||
import AppRoutes from './routes';
|
||||
@@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
|
||||
// Utils
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
// PostHog 追踪
|
||||
import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
|
||||
// Contexts
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* AppContent - 应用核心内容
|
||||
* 负责 PostHog 初始化和渲染路由
|
||||
*/
|
||||
function AppContent() {
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
|
||||
const pageEnterTimeRef = useRef(Date.now());
|
||||
const currentPathRef = useRef(location.pathname);
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
@@ -43,6 +56,67 @@ function AppContent() {
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪
|
||||
useEffect(() => {
|
||||
const hasVisited = localStorage.getItem('has_visited');
|
||||
|
||||
if (!hasVisited) {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
// ⚡ 使用异步追踪,不阻塞页面渲染
|
||||
trackEventAsync('first_visit', {
|
||||
referrer: document.referrer || 'direct',
|
||||
utm_source: urlParams.get('utm_source'),
|
||||
utm_medium: urlParams.get('utm_medium'),
|
||||
utm_campaign: urlParams.get('utm_campaign'),
|
||||
landing_page: location.pathname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
localStorage.setItem('has_visited', 'true');
|
||||
}
|
||||
}, [location.search, location.pathname]);
|
||||
|
||||
// ✅ 页面浏览时长追踪
|
||||
useEffect(() => {
|
||||
// 计算上一个页面的停留时长
|
||||
const calculateAndTrackDuration = () => {
|
||||
const exitTime = Date.now();
|
||||
const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒
|
||||
|
||||
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
|
||||
if (duration > 1) {
|
||||
// ⚡ 使用异步追踪,不阻塞页面切换
|
||||
trackEventAsync('page_view_duration', {
|
||||
path: currentPathRef.current,
|
||||
duration_seconds: duration,
|
||||
is_authenticated: isAuthenticated,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 路由切换时追踪上一个页面的时长
|
||||
if (currentPathRef.current !== location.pathname) {
|
||||
calculateAndTrackDuration();
|
||||
|
||||
// 更新为新页面
|
||||
currentPathRef.current = location.pathname;
|
||||
pageEnterTimeRef.current = Date.now();
|
||||
}
|
||||
|
||||
// 页面关闭/刷新时追踪时长
|
||||
const handleBeforeUnload = () => {
|
||||
calculateAndTrackDuration();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [location.pathname, isAuthenticated]);
|
||||
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* vf_react App.jsx集成示例
|
||||
*
|
||||
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
|
||||
*
|
||||
* 集成步骤:
|
||||
* 1. 将bytedesk-integration文件夹复制到src/目录
|
||||
* 2. 在App.jsx中导入BytedeskWidget和配置
|
||||
* 3. 添加BytedeskWidget组件(代码如下)
|
||||
* 4. 配置.env文件(参考.env.bytedesk.example)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom'; // 如果使用react-router
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
// ============================================================================
|
||||
// 方案一: 全局集成(推荐)
|
||||
// 适用场景: 客服系统需要在所有页面显示
|
||||
// ============================================================================
|
||||
|
||||
function App() {
|
||||
// ========== vf_react原有代码保持不变 ==========
|
||||
// 这里是您原有的App.jsx代码
|
||||
// 例如: const [user, setUser] = useState(null);
|
||||
// 例如: const [theme, setTheme] = useState('light');
|
||||
// ... 保持原有逻辑不变 ...
|
||||
|
||||
// ========== Bytedesk集成代码开始 ==========
|
||||
|
||||
const location = useLocation(); // 获取当前路径
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 根据页面路径决定是否显示客服
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 获取Bytedesk配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 客服加载成功回调
|
||||
const handleBytedeskLoad = (bytedesk) => {
|
||||
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
|
||||
};
|
||||
|
||||
// 客服加载失败回调
|
||||
const handleBytedeskError = (error) => {
|
||||
console.error('[App] Bytedesk客服系统加载失败', error);
|
||||
};
|
||||
|
||||
// ========== Bytedesk集成代码结束 ==========
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* ========== vf_react原有内容保持不变 ========== */}
|
||||
{/* 这里是您原有的App.jsx JSX代码 */}
|
||||
{/* 例如: <Header /> */}
|
||||
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
|
||||
{/* ... 保持原有结构不变 ... */}
|
||||
|
||||
{/* ========== Bytedesk客服Widget ========== */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleBytedeskLoad}
|
||||
onError={handleBytedeskError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案二: 带用户信息集成
|
||||
// 适用场景: 需要将登录用户信息传递给客服端
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
|
||||
|
||||
function App() {
|
||||
// 获取登录用户信息
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const location = useLocation();
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 根据用户信息生成配置
|
||||
const bytedeskConfig = user
|
||||
? getBytedeskConfigWithUser(user)
|
||||
: getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案三: 条件性加载
|
||||
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在用户登录且为普通用户时显示客服
|
||||
if (user && user.role === 'customer') {
|
||||
setShowBytedesk(true);
|
||||
} else {
|
||||
setShowBytedesk(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案四: 动态控制显示/隐藏
|
||||
// 适用场景: 需要通过按钮或其他交互控制客服显示
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const toggleBytedesk = () => {
|
||||
setShowBytedesk(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{/* 自定义客服按钮 *\/}
|
||||
<button onClick={toggleBytedesk} className="custom-service-button">
|
||||
{showBytedesk ? '关闭客服' : '联系客服'}
|
||||
</button>
|
||||
|
||||
{/* 客服Widget *\/}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 重要提示
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 1. CSS样式兼容性
|
||||
* - Bytedesk Widget使用Shadow DOM,不会影响您的全局样式
|
||||
* - Widget的样式可通过config中的theme配置调整
|
||||
*
|
||||
* 2. 性能优化
|
||||
* - Widget脚本采用异步加载,不会阻塞页面渲染
|
||||
* - 建议在非关键页面(如登录、支付页)隐藏客服
|
||||
*
|
||||
* 3. 错误处理
|
||||
* - 如果客服脚本加载失败,不会影响主应用
|
||||
* - 建议添加onError回调进行错误监控
|
||||
*
|
||||
* 4. 调试模式
|
||||
* - 查看浏览器控制台的[Bytedesk]前缀日志
|
||||
* - 检查Network面板确认脚本加载成功
|
||||
*
|
||||
* 5. 生产部署
|
||||
* - 确保.env文件配置正确(特别是REACT_APP_BYTEDESK_API_URL)
|
||||
* - 确保CORS已在后端配置(允许您的前端域名)
|
||||
* - 在管理后台配置正确的工作组ID(sid)
|
||||
*/
|
||||
@@ -1,27 +1,10 @@
|
||||
/**
|
||||
* Bytedesk客服配置文件
|
||||
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
|
||||
*
|
||||
* 环境变量配置(.env文件):
|
||||
* REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
* REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
*
|
||||
* 架构说明:
|
||||
* - iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
* - 本地:CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
|
||||
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
* - baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*
|
||||
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
|
||||
*/
|
||||
|
||||
// 从环境变量读取配置
|
||||
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
|
||||
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
|
||||
|
||||
/**
|
||||
* Bytedesk客服基础配置
|
||||
- iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
- 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
- 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
- baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*/
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址(如果 SDK 需要调用 API)
|
||||
@@ -61,9 +44,9 @@ export const bytedeskConfig = {
|
||||
|
||||
// 聊天配置(必需)
|
||||
chatConfig: {
|
||||
org: BYTEDESK_ORG, // 组织ID
|
||||
org: df_org_uid, // 组织ID
|
||||
t: '1', // 类型: 1=人工客服, 2=机器人
|
||||
sid: BYTEDESK_SID, // 工作组ID
|
||||
sid: df_wg_uid, // 工作组ID
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,45 +94,8 @@ export const getBytedeskConfigWithUser = (user) => {
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据页面路径判断是否显示客服
|
||||
*
|
||||
* @param {string} pathname - 当前页面路径
|
||||
* @returns {boolean} 是否显示客服
|
||||
*/
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面隐藏客服(黑名单)
|
||||
const blockedPages = [
|
||||
// '/home', // 登录页
|
||||
];
|
||||
|
||||
// 检查是否在黑名单
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 默认所有页面都显示客服
|
||||
return true;
|
||||
|
||||
/* ============================================
|
||||
白名单模式(备用,需要时取消注释)
|
||||
============================================
|
||||
const allowedPages = [
|
||||
'/', // 首页
|
||||
'/home', // 主页
|
||||
'/products', // 产品页
|
||||
'/pricing', // 价格页
|
||||
'/contact', // 联系我们
|
||||
];
|
||||
|
||||
// 只在白名单页面显示客服
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
============================================ */
|
||||
};
|
||||
|
||||
export default {
|
||||
bytedeskConfig,
|
||||
getBytedeskConfig,
|
||||
getBytedeskConfigWithUser,
|
||||
shouldShowCustomerService,
|
||||
};
|
||||
|
||||
@@ -356,24 +356,22 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// ✅ 兼容后端两种命名格式:camelCase (isNewUser) 和 snake_case (is_new_user)
|
||||
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
title: isNewUser ? '注册成功' : '登录成功',
|
||||
description: config.successDescription,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
logger.info('AuthFormContent', '登录成功', {
|
||||
isNewUser: data.isNewUser,
|
||||
userId: data.user?.id
|
||||
});
|
||||
|
||||
// 检查是否为新注册用户
|
||||
if (data.isNewUser) {
|
||||
if (isNewUser) {
|
||||
// 新注册用户,延迟后显示昵称设置引导
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
import { ACTIVATION_EVENTS } from '@lib/constants';
|
||||
|
||||
/**
|
||||
* 全局认证弹窗管理器
|
||||
@@ -21,6 +23,27 @@ export default function AuthModalManager() {
|
||||
closeModal
|
||||
} = useAuthModal();
|
||||
|
||||
// ✅ 追踪弹窗打开次数(用于漏斗分析)
|
||||
const hasTrackedOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthModalOpen && !hasTrackedOpen.current) {
|
||||
// ✅ 使用异步追踪,不阻塞渲染
|
||||
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
modal_type: 'auth_modal',
|
||||
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
|
||||
});
|
||||
|
||||
hasTrackedOpen.current = true;
|
||||
}
|
||||
|
||||
// ✅ 弹窗关闭时重置标记(允许再次追踪)
|
||||
if (!isAuthModalOpen) {
|
||||
hasTrackedOpen.current = false;
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 响应式尺寸配置
|
||||
const modalSize = useBreakpointValue({
|
||||
base: "md", // 移动端:md(不占满全屏)
|
||||
|
||||
53
src/components/Button2/index.tsx
Normal file
53
src/components/Button2/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
|
||||
type CommonProps = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
isPrimary?: boolean;
|
||||
isSecondary?: boolean;
|
||||
};
|
||||
|
||||
type ButtonAsButton = {
|
||||
as?: "button";
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
type ButtonAsAnchor = {
|
||||
as: "a";
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
type ButtonAsLink = {
|
||||
as: "link";
|
||||
} & LinkProps;
|
||||
|
||||
type ButtonProps = CommonProps &
|
||||
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
children,
|
||||
isPrimary,
|
||||
isSecondary,
|
||||
as = "button",
|
||||
...props
|
||||
}) => {
|
||||
const isLink = as === "link";
|
||||
const Component: React.ElementType = isLink ? Link : as;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
|
||||
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
|
||||
} ${
|
||||
isSecondary
|
||||
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
|
||||
: ""
|
||||
} ${className || ""}`}
|
||||
{...(isLink ? (props as LinkProps) : props)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const now = dayjs();
|
||||
const time = dayjs(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
@@ -9,13 +9,12 @@ import { logger } from '../utils/logger';
|
||||
// Global Components
|
||||
import AuthModalManager from './Auth/AuthModalManager';
|
||||
import NotificationContainer from './NotificationContainer';
|
||||
import NotificationTestTool from './NotificationTestTool';
|
||||
import ConnectionStatusBar from './ConnectionStatusBar';
|
||||
import ScrollToTop from './ScrollToTop';
|
||||
|
||||
// Bytedesk客服组件
|
||||
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
|
||||
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
@@ -71,12 +70,10 @@ function ConnectionStatusBarWrapper() {
|
||||
* - ScrollToTop: 路由切换时自动滚动到顶部
|
||||
* - AuthModalManager: 认证弹窗管理器
|
||||
* - NotificationContainer: 通知容器
|
||||
* - NotificationTestTool: 通知测试工具 (仅开发环境)
|
||||
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -92,16 +89,11 @@ export function GlobalComponents() {
|
||||
{/* 通知容器 */}
|
||||
<NotificationContainer />
|
||||
|
||||
{/* 通知测试工具 (仅开发环境) */}
|
||||
<NotificationTestTool />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,15 +264,20 @@ const MobileDrawer = memo(({
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => handleNavigate('/value-forum')}
|
||||
py={1}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{}}
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
pointerEvents="none"
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">价值论坛</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="xs" colorScheme="yellow">黑金</Badge>
|
||||
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
py={1}
|
||||
|
||||
@@ -239,11 +239,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
onClick={() => {
|
||||
navEvents.trackMenuItemClicked('价值论坛', 'dropdown', '/value-forum');
|
||||
navigate('/value-forum');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/value-forum') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/value-forum') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">价值论坛</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="yellow">黑金</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
|
||||
@@ -155,8 +155,21 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/value-forum');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">价值论坛</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="yellow">黑金</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
// src/components/NotificationTestTool/index.js
|
||||
/**
|
||||
* 金融资讯通知测试工具 - 仅在开发环境显示
|
||||
* 用于手动测试4种通知类型
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
Divider,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Code,
|
||||
UnorderedList,
|
||||
ListItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
|
||||
|
||||
const NotificationTestTool = () => {
|
||||
// 只在开发环境显示 - 必须在所有 Hooks 调用之前检查
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
|
||||
const [testCount, setTestCount] = useState(0);
|
||||
|
||||
// 测试状态
|
||||
const [isTestingNotification, setIsTestingNotification] = useState(false);
|
||||
const [testCountdown, setTestCountdown] = useState(0);
|
||||
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
|
||||
|
||||
// 系统环境检测
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
|
||||
// 故障排查面板
|
||||
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
|
||||
|
||||
// 检测系统环境
|
||||
useEffect(() => {
|
||||
// 检测是否为 macOS
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
setIsMacOS(platform.includes('mac'));
|
||||
|
||||
// 检测全屏状态
|
||||
const checkFullscreen = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', checkFullscreen);
|
||||
checkFullscreen();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', checkFullscreen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
if (testCountdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setTestCountdown(testCountdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (testCountdown === 0 && isTestingNotification) {
|
||||
// 倒计时结束,询问用户
|
||||
setIsTestingNotification(false);
|
||||
|
||||
// 延迟一下再询问,确保用户有时间看到通知
|
||||
setTimeout(() => {
|
||||
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
|
||||
setNotificationShown(sawNotification);
|
||||
|
||||
if (!sawNotification) {
|
||||
// 没看到通知,展开故障排查面板
|
||||
if (!isTroubleshootOpen) {
|
||||
onTroubleshootToggle();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
|
||||
|
||||
// 浏览器权限状态标签
|
||||
const getPermissionLabel = () => {
|
||||
switch (browserPermission) {
|
||||
case 'granted':
|
||||
return '已授权';
|
||||
case 'denied':
|
||||
return '已拒绝';
|
||||
case 'default':
|
||||
return '未授权';
|
||||
default:
|
||||
return '不支持';
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionColor = () => {
|
||||
switch (browserPermission) {
|
||||
case 'granted':
|
||||
return 'green';
|
||||
case 'denied':
|
||||
return 'red';
|
||||
case 'default':
|
||||
return 'gray';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
// 请求浏览器权限
|
||||
const handleRequestPermission = async () => {
|
||||
await requestBrowserPermission();
|
||||
};
|
||||
|
||||
// 公告通知测试数据
|
||||
const testAnnouncement = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】贵州茅台发布2024年度财报公告',
|
||||
content: '2024年度营收同比增长15.2%,净利润创历史新高,董事会建议每10股派息180元',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/test001',
|
||||
extra: {
|
||||
announcementType: '财报',
|
||||
companyCode: '600519',
|
||||
companyName: '贵州茅台',
|
||||
},
|
||||
autoClose: 10000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
|
||||
// 事件动向测试数据
|
||||
const testEventAlert = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】央行宣布降准0.5个百分点',
|
||||
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点,释放长期资金约1万亿元,利好股市',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/test003',
|
||||
extra: {
|
||||
eventId: 'test003',
|
||||
relatedStocks: 12,
|
||||
impactLevel: '重大利好',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 分析报告测试数据(非AI)
|
||||
const testAnalysisReport = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】医药行业深度报告:创新药迎来政策拐点',
|
||||
content: 'CXO板块持续受益于全球创新药研发外包需求,建议关注药明康德、凯莱英等龙头企业',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '李明',
|
||||
organization: '中信证券',
|
||||
},
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=test004',
|
||||
extra: {
|
||||
reportType: '行业研报',
|
||||
industry: '医药',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
|
||||
// 预测通知测试数据(不可跳转)
|
||||
const testPrediction = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【测试】【预测】央行可能宣布降准政策',
|
||||
content: '基于最新宏观数据分析,预计央行将在本周宣布降准0.5个百分点,释放长期资金',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false, // ❌ 不可点击
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
},
|
||||
autoClose: 15000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 预测→详情流程测试(先推预测,5秒后推详情)
|
||||
const testPredictionFlow = () => {
|
||||
// 阶段 1: 推送预测
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【测试】【预测】新能源汽车补贴政策将延期',
|
||||
content: '根据政策趋势分析,预计财政部将宣布新能源汽车购置补贴政策延长至2025年底',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false,
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
relatedPredictionId: 'pred_test_001',
|
||||
},
|
||||
autoClose: 15000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
|
||||
// 阶段 2: 5秒后推送详情
|
||||
setTimeout(() => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】新能源汽车补贴政策延期至2025年底',
|
||||
content: '财政部宣布新能源汽车购置补贴政策延长至2025年底,涉及比亚迪、理想汽车等5家龙头企业',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true, // ✅ 可点击
|
||||
link: '/event-detail/test_pred_001',
|
||||
extra: {
|
||||
isPrediction: false,
|
||||
relatedPredictionId: 'pred_test_001',
|
||||
eventId: 'test_pred_001',
|
||||
relatedStocks: 5,
|
||||
impactLevel: '重大利好',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="116px"
|
||||
right={4}
|
||||
zIndex={9998}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 折叠按钮 */}
|
||||
<HStack
|
||||
p={2}
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
spacing={2}
|
||||
>
|
||||
<MdNotifications size={20} />
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
金融资讯测试工具
|
||||
</Text>
|
||||
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
REAL
|
||||
</Badge>
|
||||
<Badge colorScheme={getPermissionColor()}>
|
||||
浏览器: {getPermissionLabel()}
|
||||
</Badge>
|
||||
<IconButton
|
||||
icon={isOpen ? <MdClose /> : <MdNotifications />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
aria-label={isOpen ? '关闭' : '打开'}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 工具面板 */}
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<VStack p={4} spacing={3} align="stretch" minW="280px">
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="bold">
|
||||
通知类型测试
|
||||
</Text>
|
||||
|
||||
{/* 公告通知 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<MdCampaign />}
|
||||
onClick={testAnnouncement}
|
||||
>
|
||||
公告通知
|
||||
</Button>
|
||||
|
||||
{/* 事件动向 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="orange"
|
||||
leftIcon={<MdArticle />}
|
||||
onClick={testEventAlert}
|
||||
>
|
||||
事件动向
|
||||
</Button>
|
||||
|
||||
{/* 分析报告 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<MdAssessment />}
|
||||
onClick={testAnalysisReport}
|
||||
>
|
||||
分析报告
|
||||
</Button>
|
||||
|
||||
{/* 预测通知 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="gray"
|
||||
leftIcon={<MdArticle />}
|
||||
onClick={testPrediction}
|
||||
>
|
||||
预测通知(不可跳转)
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="bold">
|
||||
组合测试
|
||||
</Text>
|
||||
|
||||
{/* 预测→详情流程测试 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="cyan"
|
||||
onClick={testPredictionFlow}
|
||||
>
|
||||
预测→详情流程(5秒延迟)
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="bold">
|
||||
浏览器通知
|
||||
</Text>
|
||||
|
||||
{/* 请求权限按钮 */}
|
||||
{browserPermission !== 'granted' && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme={browserPermission === 'denied' ? 'red' : 'blue'}
|
||||
onClick={handleRequestPermission}
|
||||
isDisabled={browserPermission === 'denied'}
|
||||
>
|
||||
{browserPermission === 'denied' ? '权限已拒绝' : '请求浏览器权限'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 测试浏览器通知按钮 */}
|
||||
{browserPermission === 'granted' && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<MdNotifications />}
|
||||
onClick={() => {
|
||||
console.log('测试浏览器通知按钮被点击');
|
||||
console.log('Notification support:', 'Notification' in window);
|
||||
console.log('Notification permission:', Notification?.permission);
|
||||
console.log('Platform:', navigator.platform);
|
||||
console.log('Fullscreen:', !!document.fullscreenElement);
|
||||
|
||||
// 直接使用原生 Notification API 测试
|
||||
if (!('Notification' in window)) {
|
||||
alert('您的浏览器不支持桌面通知');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
alert('浏览器通知权限未授予\n当前权限状态:' + Notification.permission);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setNotificationShown(null);
|
||||
setIsTestingNotification(true);
|
||||
setTestCountdown(8); // 8秒倒计时
|
||||
|
||||
try {
|
||||
console.log('正在创建浏览器通知...');
|
||||
const notification = new Notification('【测试】浏览器通知测试', {
|
||||
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
|
||||
icon: '/logo192.png',
|
||||
badge: '/badge.png',
|
||||
tag: 'test_notification_' + Date.now(),
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
console.log('浏览器通知创建成功:', notification);
|
||||
|
||||
// 监听通知显示(成功显示)
|
||||
notification.onshow = () => {
|
||||
console.log('✅ 浏览器通知已显示(onshow 事件触发)');
|
||||
setNotificationShown(true);
|
||||
};
|
||||
|
||||
// 监听通知错误
|
||||
notification.onerror = (error) => {
|
||||
console.error('❌ 浏览器通知错误:', error);
|
||||
setNotificationShown(false);
|
||||
};
|
||||
|
||||
// 监听通知关闭
|
||||
notification.onclose = () => {
|
||||
console.log('浏览器通知已关闭');
|
||||
};
|
||||
|
||||
// 8秒后自动关闭
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
console.log('浏览器通知已自动关闭');
|
||||
}, 8000);
|
||||
|
||||
// 点击通知时聚焦窗口
|
||||
notification.onclick = () => {
|
||||
console.log('浏览器通知被点击');
|
||||
window.focus();
|
||||
notification.close();
|
||||
setNotificationShown(true);
|
||||
};
|
||||
|
||||
setTestCount(prev => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('创建浏览器通知失败:', error);
|
||||
alert('创建浏览器通知失败:' + error.message);
|
||||
setIsTestingNotification(false);
|
||||
setNotificationShown(false);
|
||||
}
|
||||
}}
|
||||
isLoading={isTestingNotification}
|
||||
loadingText={`等待通知... ${testCountdown}s`}
|
||||
>
|
||||
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 浏览器通知状态说明 */}
|
||||
{browserPermission === 'granted' && (
|
||||
<Text fontSize="xs" color="green.500">
|
||||
✅ 浏览器通知已启用
|
||||
</Text>
|
||||
)}
|
||||
{browserPermission === 'denied' && (
|
||||
<Text fontSize="xs" color="red.500">
|
||||
❌ 请在浏览器设置中允许通知
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 实时权限状态 */}
|
||||
<HStack spacing={2} justify="center">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
实际权限:
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
('Notification' in window && Notification.permission === 'granted') ? 'green' :
|
||||
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
|
||||
}
|
||||
>
|
||||
{('Notification' in window) ? Notification.permission : '不支持'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 环境警告 */}
|
||||
{isFullscreen && (
|
||||
<Alert status="warning" size="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<Text fontWeight="bold">全屏模式</Text>
|
||||
<Text>某些浏览器在全屏模式下不显示通知</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isMacOS && notificationShown === false && (
|
||||
<Alert status="error" size="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<Text fontWeight="bold">未检测到通知显示</Text>
|
||||
<Text>可能是专注模式阻止了通知</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 故障排查面板 */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="orange"
|
||||
leftIcon={<MdWarning />}
|
||||
onClick={onTroubleshootToggle}
|
||||
>
|
||||
{isTroubleshootOpen ? '收起' : '故障排查指南'}
|
||||
</Button>
|
||||
|
||||
<Collapse in={isTroubleshootOpen} animateOpacity>
|
||||
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
|
||||
<Text fontSize="xs" fontWeight="bold" color="orange.800">
|
||||
如果看不到浏览器通知,请检查:
|
||||
</Text>
|
||||
|
||||
{/* macOS 专注模式 */}
|
||||
{isMacOS && (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>点击右上角控制中心</ListItem>
|
||||
<ListItem>关闭「专注模式」或「勿扰模式」</ListItem>
|
||||
<ListItem>或者:系统设置 → 专注模式 → 关闭</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* macOS 系统通知设置 */}
|
||||
{isMacOS && (
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>系统设置 → 通知</ListItem>
|
||||
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> 或 <Code fontSize="xs">Microsoft Edge</Code></ListItem>
|
||||
<ListItem>确保「允许通知」已开启</ListItem>
|
||||
<ListItem>通知样式设置为「横幅」或「提醒」</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Chrome 浏览器设置 */}
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
|
||||
<ListItem>确保「网站可以请求发送通知」已开启</ListItem>
|
||||
<ListItem>检查本站点是否在「允许」列表中</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{/* 全屏模式提示 */}
|
||||
{isFullscreen && (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
|
||||
<AlertDescription>
|
||||
按 <Code fontSize="xs">ESC</Code> 键退出全屏,然后重新测试
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 测试结果反馈 */}
|
||||
{notificationShown === true && (
|
||||
<Alert status="success" size="sm">
|
||||
<AlertIcon />
|
||||
<Text fontSize="xs">✅ 通知功能正常!</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 功能按钮 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
onClick={clearAllNotifications}
|
||||
flex={1}
|
||||
>
|
||||
清空全部
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
|
||||
colorScheme={soundEnabled ? 'blue' : 'gray'}
|
||||
onClick={toggleSound}
|
||||
aria-label="切换音效"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<VStack spacing={1}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
当前队列:
|
||||
</Text>
|
||||
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
|
||||
{notifications.length} / 5
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center">
|
||||
已测试: {testCount} 条通知
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTestTool;
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
|
||||
// 计算事件标记线位置
|
||||
let markLineData = [];
|
||||
if (eventTime && times.length > 0) {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
if (activeChartType === 'timeline') {
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
@@ -50,7 +50,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -111,7 +111,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -182,7 +182,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && times.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
const eventTime = eventMoment.format('HH:mm');
|
||||
|
||||
@@ -357,7 +357,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置(重要修复)
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && dates.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
// 找到事件发生日期或最接近的交易日
|
||||
|
||||
2
src/components/Subscription/SubscriptionContentNew.d.ts
vendored
Normal file
2
src/components/Subscription/SubscriptionContentNew.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Type declarations for SubscriptionContentNew component
|
||||
export {};
|
||||
1446
src/components/Subscription/SubscriptionContentNew.tsx
Normal file
1446
src/components/Subscription/SubscriptionContentNew.tsx
Normal file
File diff suppressed because it is too large
Load Diff
204
src/constants/tracking.js
Normal file
204
src/constants/tracking.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/constants/tracking.js
|
||||
// PostHog 事件追踪优先级配置
|
||||
|
||||
/**
|
||||
* 事件优先级枚举
|
||||
*
|
||||
* 用于决定事件的追踪时机,优化性能和用户体验。
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const EVENT_PRIORITY = {
|
||||
/**
|
||||
* 关键事件 - 立即发送,不可延迟
|
||||
* 示例:登录、注册、支付、订阅购买
|
||||
*/
|
||||
CRITICAL: 'critical',
|
||||
|
||||
/**
|
||||
* 高优先级事件 - 立即发送
|
||||
* 示例:详情打开、搜索提交、关注操作、分享操作
|
||||
*/
|
||||
HIGH: 'high',
|
||||
|
||||
/**
|
||||
* 普通优先级事件 - 空闲时发送
|
||||
* 示例:列表查看、筛选应用、排序变更
|
||||
*/
|
||||
NORMAL: 'normal',
|
||||
|
||||
/**
|
||||
* 低优先级事件 - 空闲时发送,可批量合并
|
||||
* 示例:鼠标移动、滚动事件、hover 事件
|
||||
*/
|
||||
LOW: 'low',
|
||||
};
|
||||
|
||||
/**
|
||||
* Community 页面(新闻催化分析)事件优先级映射
|
||||
*
|
||||
* 映射规则:
|
||||
* - CRITICAL: 无(Community 页面无关键业务操作)
|
||||
* - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转)
|
||||
* - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序)
|
||||
* - LOW: 暂未使用
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
export const COMMUNITY_EVENT_PRIORITIES = {
|
||||
// ==================== 普通优先级(空闲时追踪)====================
|
||||
|
||||
/**
|
||||
* 页面浏览事件 - NORMAL
|
||||
* 触发时机:用户进入 Community 页面
|
||||
* 延迟原因:页面加载时避免阻塞渲染
|
||||
*/
|
||||
'Community Page Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻列表查看 - NORMAL
|
||||
* 触发时机:新闻列表加载完成
|
||||
* 延迟原因:避免阻塞列表渲染
|
||||
*/
|
||||
'News List Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻筛选应用 - NORMAL
|
||||
* 触发时机:用户应用筛选条件(重要性、日期、行业)
|
||||
* 延迟原因:筛选操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Filter Applied': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻排序变更 - NORMAL
|
||||
* 触发时机:用户切换排序方式(最新、最热、收益率)
|
||||
* 延迟原因:排序操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Sorted': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻标签页点击 - NORMAL
|
||||
* 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线)
|
||||
* 延迟原因:标签切换高频,延迟追踪不影响用户体验
|
||||
*/
|
||||
'News Tab Clicked': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
// ==================== 高优先级(立即追踪)====================
|
||||
|
||||
/**
|
||||
* 新闻文章点击 - HIGH
|
||||
* 触发时机:用户点击新闻卡片
|
||||
* 立即追踪原因:关键交互操作,需要准确记录点击位置和时间
|
||||
*/
|
||||
'News Article Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 新闻详情打开 - HIGH
|
||||
* 触发时机:打开新闻详情弹窗或页面
|
||||
* 立即追踪原因:关键交互操作,需要准确记录查看时间
|
||||
*/
|
||||
'News Detail Opened': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索查询提交 - HIGH
|
||||
* 触发时机:用户提交搜索关键词
|
||||
* 立即追踪原因:用户明确操作,需要准确记录搜索意图
|
||||
*/
|
||||
'Search Query Submitted': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索无结果 - HIGH
|
||||
* 触发时机:搜索返回 0 个结果
|
||||
* 立即追踪原因:重要的用户体验指标,需要及时发现问题
|
||||
*/
|
||||
'Search No Results': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关股票点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关股票
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Stock Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关概念点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关概念
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Concept Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件关注操作 - HIGH
|
||||
* 触发时机:用户点击关注按钮
|
||||
* 立即追踪原因:关键业务操作,需要准确记录关注行为
|
||||
*/
|
||||
'Event Followed': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件取消关注 - HIGH
|
||||
* 触发时机:用户取消关注事件
|
||||
* 立即追踪原因:关键业务操作,需要准确记录取关原因
|
||||
*/
|
||||
'Event Unfollowed': EVENT_PRIORITY.HIGH,
|
||||
};
|
||||
|
||||
/**
|
||||
* requestIdleCallback 配置
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const IDLE_CALLBACK_CONFIG = {
|
||||
/**
|
||||
* 超时时间(毫秒)
|
||||
* 即使浏览器不空闲,也会在此时间后强制执行追踪
|
||||
*
|
||||
* 设置为 2000ms 的原因:
|
||||
* - 足够长:避免在用户快速操作时阻塞主线程
|
||||
* - 足够短:确保用户快速关闭页面前也能发送事件
|
||||
* - 平衡点:2 秒是用户注意力的典型持续时间
|
||||
*/
|
||||
timeout: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取事件优先级
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {string} 事件优先级(CRITICAL | HIGH | NORMAL | LOW)
|
||||
*/
|
||||
export const getEventPriority = (eventName) => {
|
||||
return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否需要立即追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否立即追踪
|
||||
*/
|
||||
export const shouldTrackImmediately = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否可以延迟追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否可以延迟追踪
|
||||
*/
|
||||
export const canTrackIdle = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW;
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
EVENT_PRIORITY,
|
||||
COMMUNITY_EVENT_PRIORITIES,
|
||||
IDLE_CALLBACK_CONFIG,
|
||||
getEventPriority,
|
||||
shouldTrackImmediately,
|
||||
canTrackIdle,
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
|
||||
if (prevUser && prevUser.id === data.user.id) {
|
||||
return prevUser;
|
||||
}
|
||||
|
||||
// ✅ 识别用户身份到 PostHog
|
||||
identifyUser(data.user.id, {
|
||||
email: data.user.email,
|
||||
username: data.user.username,
|
||||
subscription_tier: data.user.subscription_tier,
|
||||
role: data.user.role,
|
||||
registration_date: data.user.created_at
|
||||
});
|
||||
|
||||
return data.user;
|
||||
});
|
||||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||||
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Logged In' 或 'User Signed Up'
|
||||
// 属性名:login_method (不是 loginType)
|
||||
|
||||
// ⚡ 移除toast,让调用者处理UI反馈,避免并发更新冲突
|
||||
// toast({
|
||||
// title: "登录成功",
|
||||
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Signed Up'(不是 'user_registered')
|
||||
// 属性名:login_method(不是 method)
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
@@ -286,58 +308,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 邮箱注册
|
||||
const registerWithEmail = async (email, code, username, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch(`/api/auth/register/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code,
|
||||
username,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
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', 'registerWithEmail', error);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送手机验证码
|
||||
const sendSmsCode = async (phone) => {
|
||||
try {
|
||||
@@ -367,35 +337,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const sendEmailCode = async (email) => {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/send-email-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '发送失败');
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast
|
||||
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast
|
||||
logger.error('AuthContext', 'sendEmailCode', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// 登出方法
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: user?.id || null,
|
||||
session_duration_minutes: user?.session_start
|
||||
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
|
||||
: null,
|
||||
});
|
||||
|
||||
// ✅ 重置 PostHog 用户会话
|
||||
resetUser();
|
||||
|
||||
// 清除本地状态
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
|
||||
updateUser,
|
||||
login,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
sendEmailCode,
|
||||
logout,
|
||||
hasRole,
|
||||
refreshSession,
|
||||
|
||||
@@ -353,13 +353,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
* 发送浏览器通知
|
||||
*/
|
||||
const sendBrowserNotification = useCallback((notificationData) => {
|
||||
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
|
||||
console.log('[NotificationContext] 通知数据:', notificationData);
|
||||
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
|
||||
logger.debug('NotificationContext', 'sendBrowserNotification 被调用', {
|
||||
notificationData,
|
||||
browserPermission
|
||||
});
|
||||
|
||||
if (browserPermission !== 'granted') {
|
||||
logger.warn('NotificationContext', 'Browser permission not granted');
|
||||
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
|
||||
logger.warn('NotificationContext', '浏览器权限未授予,无法发送通知');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 判断是否需要用户交互(紧急通知不自动关闭)
|
||||
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
|
||||
|
||||
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
|
||||
logger.debug('NotificationContext', '准备发送浏览器通知', {
|
||||
title,
|
||||
body: content,
|
||||
tag,
|
||||
@@ -390,12 +390,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
});
|
||||
|
||||
if (notification) {
|
||||
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
|
||||
logger.info('NotificationContext', '通知对象创建成功', { notification });
|
||||
|
||||
// 设置点击处理(聚焦窗口并跳转)
|
||||
if (link) {
|
||||
notification.onclick = () => {
|
||||
console.log('[NotificationContext] 通知被点击,跳转到:', link);
|
||||
logger.info('NotificationContext', '通知被点击,跳转到', { link });
|
||||
window.focus();
|
||||
// 使用 window.location 跳转(不需要 React Router)
|
||||
window.location.hash = link;
|
||||
@@ -405,7 +405,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ 通知对象创建失败!');
|
||||
logger.error('NotificationContext', '通知对象创建失败');
|
||||
}
|
||||
}, [browserPermission]);
|
||||
|
||||
@@ -640,19 +640,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
addNotificationRef.current = addNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
|
||||
logger.debug('NotificationContext', '已更新 addNotificationRef');
|
||||
}, [addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
adaptEventToNotificationRef.current = adaptEventToNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
|
||||
logger.debug('NotificationContext', '已更新 adaptEventToNotificationRef');
|
||||
}, [adaptEventToNotification]);
|
||||
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接(方案2:只注册一次)', 'color: #673AB7; font-weight: bold;');
|
||||
logger.info('NotificationContext', '初始化 Socket 连接(方案2:只注册一次)');
|
||||
|
||||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||||
socket.on('connect', () => {
|
||||
@@ -661,15 +660,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// 判断是首次连接还是重连
|
||||
if (isFirstConnect.current) {
|
||||
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
|
||||
logger.info('NotificationContext', '首次连接成功', {
|
||||
socketId: socket.getSocketId?.()
|
||||
});
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
isFirstConnect.current = false;
|
||||
logger.info('NotificationContext', 'Socket connected (first time)');
|
||||
} else {
|
||||
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
|
||||
logger.info('NotificationContext', '重连成功');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
|
||||
logger.info('NotificationContext', 'Socket reconnected');
|
||||
|
||||
// 清除之前的定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -684,20 +682,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
logger.info('NotificationContext', '重新订阅事件推送');
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] 订阅确认:', data);
|
||||
logger.info('NotificationContext', 'Events subscribed', data);
|
||||
logger.info('NotificationContext', '订阅成功', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
|
||||
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -705,8 +701,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket disconnected', { reason });
|
||||
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
|
||||
logger.warn('NotificationContext', 'Socket 已断开', { reason });
|
||||
});
|
||||
|
||||
// ========== 监听连接错误 ==========
|
||||
@@ -716,15 +711,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
|
||||
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
|
||||
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
|
||||
});
|
||||
|
||||
// ========== 监听重连失败 ==========
|
||||
socket.on('reconnect_failed', () => {
|
||||
logger.error('NotificationContext', 'Socket reconnect_failed');
|
||||
logger.error('NotificationContext', '重连失败');
|
||||
setConnectionStatus(CONNECTION_STATUS.FAILED);
|
||||
console.error('[NotificationContext] ❌ 重连失败');
|
||||
|
||||
toast({
|
||||
title: '连接失败',
|
||||
@@ -737,21 +730,17 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
|
||||
socket.on('new_event', (data) => {
|
||||
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('[NotificationContext] 原始事件数据:', data);
|
||||
console.log('[NotificationContext] 事件 ID:', data?.id);
|
||||
console.log('[NotificationContext] 事件标题:', data?.title);
|
||||
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
|
||||
console.log('[NotificationContext] 事件重要性:', data?.importance);
|
||||
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
logger.info('NotificationContext', '收到 new_event 事件', {
|
||||
id: data?.id,
|
||||
title: data?.title,
|
||||
eventType: data?.event_type || data?.type,
|
||||
importance: data?.importance
|
||||
});
|
||||
logger.debug('NotificationContext', '原始事件数据', data);
|
||||
|
||||
// ⚠️ 防御性检查:确保 ref 已初始化
|
||||
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
|
||||
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
|
||||
logger.error('NotificationContext', 'Refs not initialized', {
|
||||
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
|
||||
addNotificationRef: !!addNotificationRef.current,
|
||||
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
|
||||
});
|
||||
@@ -770,14 +759,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
|
||||
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
|
||||
return;
|
||||
}
|
||||
|
||||
processedEventIds.current.add(eventId);
|
||||
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
|
||||
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
|
||||
|
||||
// 限制 Set 大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
@@ -790,45 +777,41 @@ export const NotificationProvider = ({ children }) => {
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
|
||||
console.log('[NotificationContext] 正在转换事件格式...');
|
||||
logger.debug('NotificationContext', '正在转换事件格式');
|
||||
const notification = adaptEventToNotificationRef.current(data);
|
||||
console.log('[NotificationContext] 转换后的通知对象:', notification);
|
||||
logger.debug('NotificationContext', '转换后的通知对象', notification);
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的 addNotification 函数
|
||||
console.log('[NotificationContext] 准备添加通知到队列...');
|
||||
logger.debug('NotificationContext', '准备添加通知到队列');
|
||||
addNotificationRef.current(notification);
|
||||
console.log('[NotificationContext] ✅ 通知已添加到队列');
|
||||
logger.info('NotificationContext', '通知已添加到队列');
|
||||
|
||||
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
|
||||
if (eventUpdateCallbacks.current.size > 0) {
|
||||
console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`);
|
||||
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
|
||||
eventUpdateCallbacks.current.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', 'Event update callback error', error);
|
||||
console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
|
||||
logger.error('NotificationContext', '事件更新回调执行失败', error);
|
||||
}
|
||||
});
|
||||
console.log('[NotificationContext] ✅ 所有事件更新回调已触发');
|
||||
logger.debug('NotificationContext', '所有事件更新回调已触发');
|
||||
}
|
||||
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
});
|
||||
|
||||
// ========== 监听系统通知(兼容性) ==========
|
||||
socket.on('system_notification', (data) => {
|
||||
logger.info('NotificationContext', 'Received system notification', data);
|
||||
console.log('[NotificationContext] 📢 收到系统通知:', data);
|
||||
logger.info('NotificationContext', '收到系统通知', data);
|
||||
|
||||
if (addNotificationRef.current) {
|
||||
addNotificationRef.current(data);
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
|
||||
logger.error('NotificationContext', 'addNotificationRef 未初始化');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
|
||||
logger.info('NotificationContext', '所有监听器已注册(只注册一次)');
|
||||
|
||||
// ========== 获取最大重连次数 ==========
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
@@ -836,13 +819,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ========== 启动连接 ==========
|
||||
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
logger.info('NotificationContext', '调用 socket.connect()');
|
||||
socket.connect();
|
||||
|
||||
// ========== 清理函数(组件卸载时) ==========
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
|
||||
logger.info('NotificationContext', '清理 Socket 连接');
|
||||
|
||||
// 清理 reconnected 状态定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -868,7 +850,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 断开连接
|
||||
socket.disconnect();
|
||||
|
||||
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
|
||||
logger.info('NotificationContext', '清理完成');
|
||||
};
|
||||
}, []); // ⚠️ 空依赖数组,确保只执行一次
|
||||
|
||||
@@ -984,92 +966,6 @@ export const NotificationProvider = ({ children }) => {
|
||||
};
|
||||
}, [browserPermission, toast]);
|
||||
|
||||
// 🔧 开发环境调试:暴露方法到 window
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__TEST_NOTIFICATION__ = {
|
||||
// 手动触发网页通知
|
||||
testWebNotification: (type = 'event_alert', priority = 'normal') => {
|
||||
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
const testData = {
|
||||
id: `test_${Date.now()}`,
|
||||
type: type,
|
||||
priority: priority,
|
||||
title: '🧪 测试网页通知',
|
||||
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
|
||||
timestamp: Date.now(),
|
||||
clickable: true,
|
||||
link: '/home',
|
||||
};
|
||||
|
||||
console.log('测试数据:', testData);
|
||||
addNotification(testData);
|
||||
console.log('✅ 通知已添加到队列');
|
||||
},
|
||||
|
||||
// 测试所有类型
|
||||
testAllTypes: () => {
|
||||
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
|
||||
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
|
||||
types.forEach((type, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
|
||||
}, i * 2000); // 每 2 秒一个
|
||||
});
|
||||
},
|
||||
|
||||
// 测试所有优先级
|
||||
testAllPriorities: () => {
|
||||
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
|
||||
const priorities = ['normal', 'important', 'urgent'];
|
||||
priorities.forEach((priority, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
|
||||
}, i * 2000);
|
||||
});
|
||||
},
|
||||
|
||||
// 帮助
|
||||
help: () => {
|
||||
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
|
||||
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' type (通知类型):');
|
||||
console.log(' - "announcement" 公告通知(蓝色)');
|
||||
console.log(' - "stock_alert" 股票动向(红色/绿色)');
|
||||
console.log(' - "event_alert" 事件动向(橙色)');
|
||||
console.log(' - "analysis_report" 分析报告(紫色)');
|
||||
console.log('\n priority (优先级):');
|
||||
console.log(' - "normal" 普通(15秒自动关闭)');
|
||||
console.log(' - "important" 重要(30秒自动关闭)');
|
||||
console.log(' - "urgent" 紧急(不自动关闭)');
|
||||
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' // 测试紧急事件通知');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
|
||||
console.log('\n // 测试所有类型');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
|
||||
console.log('\n // 测试所有优先级');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
|
||||
console.log('\n');
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
|
||||
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
delete window.__TEST_NOTIFICATION__;
|
||||
}
|
||||
};
|
||||
}, [addNotification]); // 依赖 addNotification 函数
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
isConnected,
|
||||
|
||||
@@ -10,20 +10,17 @@
|
||||
* 全局 API:
|
||||
* - window.__DEBUG__ - 调试 API 主对象
|
||||
* - window.__DEBUG__.api - API 调试工具
|
||||
* - window.__DEBUG__.notification - 通知调试工具
|
||||
* - window.__DEBUG__.socket - Socket 调试工具
|
||||
* - window.__DEBUG__.help() - 显示帮助信息
|
||||
* - window.__DEBUG__.exportAll() - 导出所有日志
|
||||
*/
|
||||
|
||||
import { apiDebugger } from './apiDebugger';
|
||||
import { notificationDebugger } from './notificationDebugger';
|
||||
import { socketDebugger } from './socketDebugger';
|
||||
|
||||
class DebugToolkit {
|
||||
constructor() {
|
||||
this.api = apiDebugger;
|
||||
this.notification = notificationDebugger;
|
||||
this.socket = socketDebugger;
|
||||
}
|
||||
|
||||
@@ -47,7 +44,6 @@ class DebugToolkit {
|
||||
|
||||
// 初始化各个调试工具
|
||||
this.api.init();
|
||||
this.notification.init();
|
||||
this.socket.init();
|
||||
|
||||
// 暴露到全局
|
||||
@@ -69,22 +65,13 @@ class DebugToolkit {
|
||||
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
|
||||
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
|
||||
console.log('');
|
||||
console.log('%c2️⃣ 通知调试:', 'color: #9C27B0; font-weight: bold;');
|
||||
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
|
||||
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
|
||||
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
|
||||
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
|
||||
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
|
||||
console.log('');
|
||||
console.log('%c3️⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log('%c2️⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
|
||||
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
|
||||
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
|
||||
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
|
||||
console.log('');
|
||||
console.log('%c4️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('%c3️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' __DEBUG__.help() - 显示帮助信息');
|
||||
console.log(' __DEBUG__.exportAll() - 导出所有日志');
|
||||
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
|
||||
@@ -113,7 +100,6 @@ class DebugToolkit {
|
||||
const allLogs = {
|
||||
timestamp: new Date().toISOString(),
|
||||
api: this.api.getLogs(),
|
||||
notification: this.notification.getLogs(),
|
||||
socket: this.socket.getLogs(),
|
||||
};
|
||||
|
||||
@@ -138,15 +124,11 @@ class DebugToolkit {
|
||||
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
|
||||
const apiStats = this.api.printStats();
|
||||
|
||||
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStats = this.notification.printStats();
|
||||
|
||||
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStats = this.socket.printStats();
|
||||
|
||||
return {
|
||||
api: apiStats,
|
||||
notification: notificationStats,
|
||||
socket: socketStats,
|
||||
};
|
||||
}
|
||||
@@ -157,7 +139,6 @@ class DebugToolkit {
|
||||
clearAll() {
|
||||
console.log('[Debug Toolkit] Clearing all logs...');
|
||||
this.api.clearLogs();
|
||||
this.notification.clearLogs();
|
||||
this.socket.clearLogs();
|
||||
console.log('[Debug Toolkit] ✅ All logs cleared');
|
||||
}
|
||||
@@ -169,15 +150,11 @@ class DebugToolkit {
|
||||
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
|
||||
// 1. Socket 状态
|
||||
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log('\n%c[1/2] Socket 状态', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStatus = this.socket.getStatus();
|
||||
|
||||
// 2. 通知权限
|
||||
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStatus = this.notification.checkPermission();
|
||||
|
||||
// 3. API 错误
|
||||
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
|
||||
// 2. API 错误
|
||||
console.log('\n%c[2/2] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
|
||||
const recentErrors = this.api.getRecentErrors(5);
|
||||
if (recentErrors.length > 0) {
|
||||
console.table(
|
||||
@@ -193,11 +170,10 @@ class DebugToolkit {
|
||||
console.log('✅ 没有 API 错误');
|
||||
}
|
||||
|
||||
// 4. 汇总报告
|
||||
// 3. 汇总报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
socket: socketStatus,
|
||||
notification: notificationStatus,
|
||||
apiErrors: recentErrors.length,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
// src/debug/notificationDebugger.js
|
||||
/**
|
||||
* 通知系统调试工具
|
||||
* 扩展现有的 window.__NOTIFY_DEBUG__,添加更多生产环境调试能力
|
||||
*/
|
||||
|
||||
import { browserNotificationService } from '@services/browserNotificationService';
|
||||
|
||||
class NotificationDebugger {
|
||||
constructor() {
|
||||
this.eventLog = [];
|
||||
this.maxLogSize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调试工具
|
||||
*/
|
||||
init() {
|
||||
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录通知事件
|
||||
*/
|
||||
logEvent(eventType, data) {
|
||||
const logEntry = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
this.eventLog.unshift(logEntry);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`%c[Notification Event] ${eventType}`,
|
||||
'color: #9C27B0; font-weight: bold;',
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件日志
|
||||
*/
|
||||
getLogs() {
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.eventLog = [];
|
||||
console.log('[Notification Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `notification-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[Notification Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制发送浏览器通知(测试用)
|
||||
*/
|
||||
forceNotification(options = {}) {
|
||||
const defaultOptions = {
|
||||
title: '🧪 测试通知',
|
||||
body: `测试时间: ${new Date().toLocaleString()}`,
|
||||
tag: `test_${Date.now()}`,
|
||||
requireInteraction: false,
|
||||
autoClose: 5000,
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('[Notification Debugger] Sending test notification:', finalOptions);
|
||||
|
||||
const notification = browserNotificationService.sendNotification(finalOptions);
|
||||
|
||||
if (notification) {
|
||||
console.log('[Notification Debugger] ✅ Notification sent successfully');
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ Failed to send notification');
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查通知权限状态
|
||||
*/
|
||||
checkPermission() {
|
||||
const permission = browserNotificationService.getPermissionStatus();
|
||||
const isSupported = browserNotificationService.isSupported();
|
||||
|
||||
const status = {
|
||||
supported: isSupported,
|
||||
permission,
|
||||
canSend: isSupported && permission === 'granted',
|
||||
};
|
||||
|
||||
console.table(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
*/
|
||||
async requestPermission() {
|
||||
console.log('[Notification Debugger] Requesting notification permission...');
|
||||
const result = await browserNotificationService.requestPermission();
|
||||
console.log(`[Notification Debugger] Permission result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印事件统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.eventLog.length,
|
||||
byType: {},
|
||||
};
|
||||
|
||||
this.eventLog.forEach((log) => {
|
||||
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('=== Notification Stats ===');
|
||||
console.table(stats.byType);
|
||||
console.log(`Total events: ${stats.total}`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型过滤日志
|
||||
*/
|
||||
getLogsByType(eventType) {
|
||||
return this.eventLog.filter((log) => log.type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
getRecentEvents(count = 10) {
|
||||
return this.eventLog.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
|
||||
*/
|
||||
testWebNotification(type = 'event_alert', priority = 'normal') {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
console.log('[Notification Debugger] 调用测试 API');
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
console.error('💡 请确保:');
|
||||
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
|
||||
console.error(' 2. NotificationContext 已加载');
|
||||
console.error(' 3. 页面已刷新');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有通知类型
|
||||
*/
|
||||
testAllNotificationTypes() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllTypes();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有优先级
|
||||
*/
|
||||
testAllNotificationPriorities() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllPriorities();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const notificationDebugger = new NotificationDebugger();
|
||||
export default notificationDebugger;
|
||||
@@ -120,7 +120,7 @@ export function usePagination<T>(
|
||||
loadData(1, false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoLoad]);
|
||||
}, [autoLoad, loadFunction]);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -124,6 +124,7 @@ async function startApp() {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render the app with Router wrapper
|
||||
// ✅ StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router
|
||||
|
||||
@@ -10,6 +10,11 @@ let isInitialized = false;
|
||||
* Should be called once when the app starts
|
||||
*/
|
||||
export const initPostHog = () => {
|
||||
// 开发环境禁用 PostHog(减少日志噪音,仅生产环境启用)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复初始化
|
||||
if (isInitializing || isInitialized) {
|
||||
console.log('📊 PostHog 已初始化或正在初始化中,跳过重复调用');
|
||||
@@ -33,79 +38,68 @@ export const initPostHog = () => {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
// 📄 页面浏览追踪
|
||||
capture_pageview: true, // 自动捕获页面浏览事件
|
||||
capture_pageleave: true, // 自动捕获用户离开页面事件
|
||||
|
||||
// Session Recording Configuration
|
||||
// 📹 会话录制配置(Session Recording)
|
||||
session_recording: {
|
||||
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
|
||||
// Privacy: Mask sensitive input fields
|
||||
// 🔒 隐私保护:遮蔽敏感输入字段(录制时会自动打码)
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
'data-sensitive': true, // Custom attribute for sensitive fields
|
||||
password: true, // 遮蔽密码输入框
|
||||
email: true, // 遮蔽邮箱输入框
|
||||
phone: true, // 遮蔽手机号输入框
|
||||
'data-sensitive': true, // 遮蔽带有 data-sensitive 属性的字段(可在 HTML 中自定义)
|
||||
},
|
||||
|
||||
// Record canvas for charts/graphs
|
||||
// 📊 录制 Canvas 画布内容(用于记录图表、图形等可视化内容)
|
||||
recordCanvas: true,
|
||||
|
||||
// Network payload capture (useful for debugging API issues)
|
||||
// 🌐 网络请求数据捕获(用于调试 API 问题)
|
||||
networkPayloadCapture: {
|
||||
recordHeaders: true,
|
||||
recordBody: true,
|
||||
// Don't record sensitive endpoints
|
||||
recordHeaders: true, // 捕获请求头
|
||||
recordBody: true, // 捕获请求体
|
||||
// 🚫 敏感接口黑名单(不记录以下接口的数据)
|
||||
urlBlocklist: [
|
||||
'/api/auth/session',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/payment',
|
||||
'/api/auth/session', // 会话接口
|
||||
'/api/auth/login', // 登录接口
|
||||
'/api/auth/register', // 注册接口
|
||||
'/api/payment', // 支付接口
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Performance optimization
|
||||
batch_size: 10, // Send events in batches of 10
|
||||
batch_interval_ms: 3000, // Or every 3 seconds
|
||||
// ⚡ 性能优化:批量发送事件
|
||||
batch_size: 10, // 每 10 个事件发送一次
|
||||
batch_interval_ms: 3000, // 或每 3 秒发送一次(两个条件满足其一即发送)
|
||||
|
||||
// Privacy settings
|
||||
respect_dnt: true, // Respect Do Not Track browser setting
|
||||
persistence: 'localStorage+cookie', // Use both for reliability
|
||||
// 🔐 隐私设置
|
||||
respect_dnt: true, // 尊重浏览器的"禁止追踪"(Do Not Track)设置
|
||||
persistence: 'localStorage+cookie', // 同时使用 localStorage 和 Cookie 存储(提高可靠性)
|
||||
|
||||
// Feature flags (for A/B testing)
|
||||
// 🚩 功能开关(Feature Flags)- 用于 A/B 测试和灰度发布
|
||||
bootstrap: {
|
||||
featureFlags: {},
|
||||
featureFlags: {}, // 初始功能开关配置(可从服务端动态加载)
|
||||
},
|
||||
|
||||
// Autocapture settings
|
||||
// 🖱️ 自动捕获设置(Autocapture)
|
||||
autocapture: {
|
||||
// Automatically capture clicks on buttons, links, etc.
|
||||
// 自动捕获用户交互事件(点击、提交、修改等)
|
||||
dom_event_allowlist: ['click', 'submit', 'change'],
|
||||
|
||||
// Capture additional element properties
|
||||
capture_copied_text: false, // Don't capture copied text (privacy)
|
||||
},
|
||||
|
||||
// Development debugging
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
|
||||
}
|
||||
// 捕获额外的元素属性
|
||||
capture_copied_text: false, // 不捕获用户复制的文本(隐私保护)
|
||||
},
|
||||
});
|
||||
|
||||
isInitialized = true;
|
||||
console.log('📊 PostHog Analytics initialized');
|
||||
} catch (error) {
|
||||
// 忽略 AbortError(通常由热重载或快速导航引起)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('⚠️ PostHog 初始化请求被中断(可能是热重载),这是正常的');
|
||||
return;
|
||||
}
|
||||
console.error('❌ PostHog initialization failed:', error);
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
@@ -142,8 +136,6 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
last_login: new Date().toISOString(),
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
// console.log('👤 User identified:', userId); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
@@ -158,7 +150,6 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
// console.log('📝 User properties updated'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
@@ -176,15 +167,35 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📍 Event tracked:', eventName, properties);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 异步追踪事件(不阻塞主线程)
|
||||
* 使用 requestIdleCallback 在浏览器空闲时发送事件
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
export const trackEventAsync = (eventName, properties = {}) => {
|
||||
// 浏览器支持 requestIdleCallback 时使用(推荐)
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
trackEvent(eventName, properties);
|
||||
},
|
||||
{ timeout: 2000 } // 最多延迟 2 秒(防止永远不执行)
|
||||
);
|
||||
} else {
|
||||
// 降级方案:使用 setTimeout(兼容性更好)
|
||||
setTimeout(() => {
|
||||
trackEvent(eventName, properties);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
@@ -201,9 +212,6 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
...properties,
|
||||
});
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📄 Page view tracked:', pagePath);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
@@ -216,7 +224,6 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
// console.log('🔄 User session reset'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
@@ -228,7 +235,6 @@ export const resetUser = () => {
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
@@ -240,7 +246,6 @@ export const optOut = () => {
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,17 @@ export const homeRoutes = [
|
||||
}
|
||||
},
|
||||
|
||||
// 数据浏览器 - /home/data-browser
|
||||
{
|
||||
path: 'data-browser',
|
||||
component: lazyComponents.DataBrowser,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '数据浏览器',
|
||||
description: '化工商品数据分类树浏览器'
|
||||
}
|
||||
},
|
||||
|
||||
// 回退路由 - 匹配任何未定义的 /home/* 路径
|
||||
{
|
||||
path: '*',
|
||||
|
||||
@@ -38,6 +38,13 @@ export const lazyComponents = {
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
||||
|
||||
// 价值论坛模块
|
||||
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||
|
||||
// 数据浏览器模块
|
||||
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -63,4 +70,7 @@ export const {
|
||||
FinancialPanorama,
|
||||
MarketDataView,
|
||||
AgentChat,
|
||||
ValueForum,
|
||||
ForumPostDetail,
|
||||
DataBrowser,
|
||||
} = lazyComponents;
|
||||
|
||||
@@ -150,6 +150,28 @@ export const routeConfig = [
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 价值论坛模块 ====================
|
||||
{
|
||||
path: 'value-forum',
|
||||
component: lazyComponents.ValueForum,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '价值论坛',
|
||||
description: '投资者价值讨论社区'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'value-forum/post/:postId',
|
||||
component: lazyComponents.ForumPostDetail,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '帖子详情',
|
||||
description: '论坛帖子详细内容'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Agent模块 ====================
|
||||
{
|
||||
path: 'agent-chat',
|
||||
|
||||
@@ -144,8 +144,8 @@ export const WECHAT_STATUS = {
|
||||
WAITING: 'waiting',
|
||||
SCANNED: 'scanned',
|
||||
AUTHORIZED: 'authorized',
|
||||
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
LOGIN_SUCCESS: 'login_ready', // ✅ 修复:与后端返回的状态一致
|
||||
REGISTER_SUCCESS: 'register_ready', // ✅ 修复:与后端返回的状态一致
|
||||
EXPIRED: 'expired',
|
||||
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
|
||||
AUTH_FAILED: 'auth_failed', // 授权失败
|
||||
|
||||
280
src/services/categoryService.ts
Normal file
280
src/services/categoryService.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 商品分类树数据服务
|
||||
* 对接化工商品数据分类树API
|
||||
* API文档: category_tree_openapi.json
|
||||
*/
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
// 类型定义
|
||||
export interface TreeMetric {
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
source: 'SMM' | 'Mysteel';
|
||||
frequency: string;
|
||||
unit: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
level: number;
|
||||
children?: TreeNode[];
|
||||
metrics?: TreeMetric[];
|
||||
}
|
||||
|
||||
export interface CategoryTreeResponse {
|
||||
source: 'SMM' | 'Mysteel';
|
||||
total_metrics: number;
|
||||
tree: TreeNode[];
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface MetricDataPoint {
|
||||
date: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface MetricDataResponse {
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
source: string;
|
||||
frequency: string;
|
||||
unit: string;
|
||||
data: MetricDataPoint[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类树(支持深度控制)
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @param maxDepth 返回的最大层级深度(默认1层,推荐懒加载)
|
||||
* @returns 分类树数据
|
||||
*/
|
||||
export const fetchCategoryTree = async (
|
||||
source: 'SMM' | 'Mysteel',
|
||||
maxDepth: number = 1
|
||||
): Promise<CategoryTreeResponse> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/category-api/api/category-tree?source=${source}&max_depth=${maxDepth}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CategoryTreeResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchCategoryTree error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取特定节点及其子树
|
||||
* @param path 节点完整路径(用 | 分隔)
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @returns 节点数据及其子树
|
||||
*/
|
||||
export const fetchCategoryNode = async (
|
||||
path: string,
|
||||
source: 'SMM' | 'Mysteel'
|
||||
): Promise<TreeNode> => {
|
||||
try {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const response = await fetch(
|
||||
`/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TreeNode = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchCategoryNode error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export interface MetricSearchResult {
|
||||
source: string;
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
category_path: string;
|
||||
description?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
total: number;
|
||||
results: MetricSearchResult[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索指标
|
||||
* @param keywords 搜索关键词(支持空格分隔多个词)
|
||||
* @param source 数据源过滤(可选)
|
||||
* @param frequency 频率过滤(可选)
|
||||
* @param size 返回结果数量(默认100)
|
||||
* @returns 搜索结果
|
||||
*/
|
||||
export const searchMetrics = async (
|
||||
keywords: string,
|
||||
source?: 'SMM' | 'Mysteel',
|
||||
frequency?: string,
|
||||
size: number = 100
|
||||
): Promise<SearchResponse> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
keywords,
|
||||
size: size.toString(),
|
||||
});
|
||||
|
||||
if (source) params.append('source', source);
|
||||
if (frequency) params.append('frequency', frequency);
|
||||
|
||||
const response = await fetch(`/category-api/api/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: SearchResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('searchMetrics error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从树中提取所有指标(用于前端搜索)
|
||||
* @param nodes 树节点数组
|
||||
* @returns 所有指标的扁平化数组
|
||||
*/
|
||||
export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
|
||||
const metrics: TreeMetric[] = [];
|
||||
|
||||
const traverse = (node: TreeNode) => {
|
||||
if (node.metrics && node.metrics.length > 0) {
|
||||
metrics.push(...node.metrics);
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(traverse);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在树中查找节点
|
||||
* @param nodes 树节点数组
|
||||
* @param path 节点路径
|
||||
* @returns 找到的节点或 null
|
||||
*/
|
||||
export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取节点的所有父节点路径
|
||||
* @param path 节点路径(用 | 分隔)
|
||||
* @returns 父节点路径数组
|
||||
*/
|
||||
export const getParentPaths = (path: string): string[] => {
|
||||
const parts = path.split('|');
|
||||
const parentPaths: string[] = [];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
parentPaths.push(parts.slice(0, i).join('|'));
|
||||
}
|
||||
|
||||
return parentPaths;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指标数据详情
|
||||
* @param metricId 指标ID
|
||||
* @param startDate 开始日期(可选,格式:YYYY-MM-DD)
|
||||
* @param endDate 结束日期(可选,格式:YYYY-MM-DD)
|
||||
* @param limit 返回数据条数(可选,默认100)
|
||||
* @returns 指标数据
|
||||
*/
|
||||
export const fetchMetricData = async (
|
||||
metricId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit: number = 100
|
||||
): Promise<MetricDataResponse> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
metric_id: metricId,
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const response = await fetch(`/category-api/api/metric-data?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: MetricDataResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchMetricData error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
442
src/services/elasticsearchService.js
Normal file
442
src/services/elasticsearchService.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Elasticsearch 服务层
|
||||
* 用于价值论坛的帖子、评论存储和搜索
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
// Elasticsearch 配置
|
||||
// 使用 Nginx 代理路径避免 Mixed Content 问题
|
||||
const ES_CONFIG = {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? '/es-api' // 生产环境使用 Nginx 代理
|
||||
: 'http://222.128.1.157:19200', // 开发环境直连
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
// 创建 axios 实例
|
||||
const esClient = axios.create(ES_CONFIG);
|
||||
|
||||
// 索引名称
|
||||
const INDICES = {
|
||||
POSTS: 'forum_posts',
|
||||
COMMENTS: 'forum_comments',
|
||||
EVENTS: 'forum_events',
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化索引(创建索引和映射)
|
||||
*/
|
||||
export const initializeIndices = async () => {
|
||||
try {
|
||||
// 创建帖子索引
|
||||
await esClient.put(`/${INDICES.POSTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
title: { type: 'text', analyzer: 'ik_max_word' },
|
||||
content: { type: 'text', analyzer: 'ik_max_word' },
|
||||
images: { type: 'keyword' },
|
||||
tags: { type: 'keyword' },
|
||||
category: { type: 'keyword' },
|
||||
likes_count: { type: 'integer' },
|
||||
comments_count: { type: 'integer' },
|
||||
views_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
updated_at: { type: 'date' },
|
||||
is_pinned: { type: 'boolean' },
|
||||
status: { type: 'keyword' }, // active, deleted, hidden
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 创建评论索引
|
||||
await esClient.put(`/${INDICES.COMMENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
content: { type: 'text', analyzer: 'ik_max_word' },
|
||||
parent_id: { type: 'keyword' }, // 用于嵌套评论
|
||||
likes_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
status: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 创建事件时间轴索引
|
||||
await esClient.put(`/${INDICES.EVENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
event_type: { type: 'keyword' }, // news, price_change, announcement, etc.
|
||||
title: { type: 'text' },
|
||||
description: { type: 'text', analyzer: 'ik_max_word' },
|
||||
source: { type: 'keyword' },
|
||||
source_url: { type: 'keyword' },
|
||||
related_stocks: { type: 'keyword' },
|
||||
occurred_at: { type: 'date' },
|
||||
created_at: { type: 'date' },
|
||||
importance: { type: 'keyword' }, // high, medium, low
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Elasticsearch 索引初始化成功');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('索引已存在,跳过创建');
|
||||
} else {
|
||||
console.error('初始化索引失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 帖子相关操作 ====================
|
||||
|
||||
/**
|
||||
* 创建新帖子
|
||||
*/
|
||||
export const createPost = async (postData) => {
|
||||
try {
|
||||
const post = {
|
||||
id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...postData,
|
||||
likes_count: 0,
|
||||
comments_count: 0,
|
||||
views_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
is_pinned: false,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_doc/${post.id}`, post);
|
||||
return { ...post, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('创建帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子列表(支持分页、排序、筛选)
|
||||
*/
|
||||
export const getPosts = async ({ page = 1, size = 20, sort = 'created_at', order = 'desc', category = null, tags = [] }) => {
|
||||
try {
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const query = {
|
||||
bool: {
|
||||
must: [{ match: { status: 'active' } }],
|
||||
},
|
||||
};
|
||||
|
||||
if (category) {
|
||||
query.bool.must.push({ term: { category } });
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
query.bool.must.push({ terms: { tags } });
|
||||
}
|
||||
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||||
from,
|
||||
size,
|
||||
query,
|
||||
sort: [
|
||||
{ is_pinned: { order: 'desc' } },
|
||||
{ [sort]: { order } },
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.data.hits.total.value,
|
||||
posts: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||||
page,
|
||||
size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个帖子详情
|
||||
*/
|
||||
export const getPostById = async (postId) => {
|
||||
try {
|
||||
const response = await esClient.get(`/${INDICES.POSTS}/_doc/${postId}`);
|
||||
|
||||
// 增加浏览量
|
||||
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||
script: {
|
||||
source: 'ctx._source.views_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...response.data._source, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('获取帖子详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新帖子
|
||||
*/
|
||||
export const updatePost = async (postId, updateData) => {
|
||||
try {
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||
doc: {
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('更新帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除帖子(软删除)
|
||||
*/
|
||||
export const deletePost = async (postId) => {
|
||||
try {
|
||||
await updatePost(postId, { status: 'deleted' });
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 点赞帖子
|
||||
*/
|
||||
export const likePost = async (postId) => {
|
||||
try {
|
||||
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||
script: {
|
||||
source: 'ctx._source.likes_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索帖子
|
||||
*/
|
||||
export const searchPosts = async (keyword, { page = 1, size = 20 }) => {
|
||||
try {
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||||
from,
|
||||
size,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
multi_match: {
|
||||
query: keyword,
|
||||
fields: ['title^3', 'content', 'tags^2'],
|
||||
type: 'best_fields',
|
||||
},
|
||||
},
|
||||
{ match: { status: 'active' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
highlight: {
|
||||
fields: {
|
||||
title: {},
|
||||
content: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.data.hits.total.value,
|
||||
posts: response.data.hits.hits.map((hit) => ({
|
||||
...hit._source,
|
||||
_id: hit._id,
|
||||
highlight: hit.highlight,
|
||||
})),
|
||||
page,
|
||||
size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('搜索帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 评论相关操作 ====================
|
||||
|
||||
/**
|
||||
* 创建评论
|
||||
*/
|
||||
export const createComment = async (commentData) => {
|
||||
try {
|
||||
const comment = {
|
||||
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...commentData,
|
||||
likes_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const response = await esClient.post(`/${INDICES.COMMENTS}/_doc/${comment.id}`, comment);
|
||||
|
||||
// 增加帖子评论数
|
||||
await esClient.post(`/${INDICES.POSTS}/_update/${commentData.post_id}`, {
|
||||
script: {
|
||||
source: 'ctx._source.comments_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...comment, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('创建评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子的评论列表
|
||||
*/
|
||||
export const getCommentsByPostId = async (postId, { page = 1, size = 50 }) => {
|
||||
try {
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const response = await esClient.post(`/${INDICES.COMMENTS}/_search`, {
|
||||
from,
|
||||
size,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { post_id: postId } },
|
||||
{ match: { status: 'active' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [{ created_at: { order: 'asc' } }],
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.data.hits.total.value,
|
||||
comments: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||||
};
|
||||
} catch (error) {
|
||||
// 如果索引不存在(404),返回空结果
|
||||
if (error.response?.status === 404) {
|
||||
console.warn('评论索引不存在,返回空结果:', INDICES.COMMENTS);
|
||||
return { total: 0, comments: [] };
|
||||
}
|
||||
console.error('获取评论列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 点赞评论
|
||||
*/
|
||||
export const likeComment = async (commentId) => {
|
||||
try {
|
||||
await esClient.post(`/${INDICES.COMMENTS}/_update/${commentId}`, {
|
||||
script: {
|
||||
source: 'ctx._source.likes_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('点赞评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 事件时间轴相关操作 ====================
|
||||
|
||||
/**
|
||||
* 创建事件
|
||||
*/
|
||||
export const createEvent = async (eventData) => {
|
||||
try {
|
||||
const event = {
|
||||
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...eventData,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await esClient.post(`/${INDICES.EVENTS}/_doc/${event.id}`, event);
|
||||
return { ...event, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('创建事件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子的事件时间轴
|
||||
*/
|
||||
export const getEventsByPostId = async (postId) => {
|
||||
try {
|
||||
const response = await esClient.post(`/${INDICES.EVENTS}/_search`, {
|
||||
size: 100,
|
||||
query: {
|
||||
term: { post_id: postId },
|
||||
},
|
||||
sort: [{ occurred_at: { order: 'desc' } }],
|
||||
});
|
||||
|
||||
return response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id }));
|
||||
} catch (error) {
|
||||
// 如果索引不存在(404),返回空数组而不是抛出错误
|
||||
if (error.response?.status === 404) {
|
||||
console.warn('事件索引不存在,返回空数组:', INDICES.EVENTS);
|
||||
return [];
|
||||
}
|
||||
console.error('获取事件时间轴失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
initializeIndices,
|
||||
// 帖子操作
|
||||
createPost,
|
||||
getPosts,
|
||||
getPostById,
|
||||
updatePost,
|
||||
deletePost,
|
||||
likePost,
|
||||
searchPosts,
|
||||
// 评论操作
|
||||
createComment,
|
||||
getCommentsByPostId,
|
||||
likeComment,
|
||||
// 事件操作
|
||||
createEvent,
|
||||
getEventsByPostId,
|
||||
};
|
||||
@@ -166,7 +166,27 @@ export const eventService = {
|
||||
// 帖子相关API
|
||||
getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => {
|
||||
try {
|
||||
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
|
||||
const result = await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
|
||||
|
||||
// ⚡ 数据转换:将后端的 user 字段映射为前端期望的 author 字段
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
result.data = result.data.map(post => ({
|
||||
...post,
|
||||
author: post.user ? {
|
||||
id: post.user.id,
|
||||
username: post.user.username,
|
||||
avatar: post.user.avatar_url || post.user.avatar // 兼容 avatar_url 和 avatar
|
||||
} : {
|
||||
id: 'anonymous',
|
||||
username: 'Anonymous',
|
||||
avatar: null
|
||||
}
|
||||
// 保留原始的 user 字段(如果其他地方需要)
|
||||
// user: post.user
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
|
||||
return { success: false, data: [], pagination: {} };
|
||||
|
||||
227
src/theme/forumTheme.js
Normal file
227
src/theme/forumTheme.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 价值论坛黑金主题配置
|
||||
* 采用深色背景 + 金色点缀的高端配色方案
|
||||
*/
|
||||
|
||||
export const forumColors = {
|
||||
// 主色调 - 黑金渐变
|
||||
primary: {
|
||||
50: '#FFF9E6',
|
||||
100: '#FFEEBA',
|
||||
200: '#FFE38D',
|
||||
300: '#FFD860',
|
||||
400: '#FFCD33',
|
||||
500: '#FFC107', // 主金色
|
||||
600: '#FFB300',
|
||||
700: '#FFA000',
|
||||
800: '#FF8F00',
|
||||
900: '#FF6F00',
|
||||
},
|
||||
|
||||
// 背景色系 - 深黑渐变
|
||||
background: {
|
||||
main: '#0A0A0A', // 主背景 - 极黑
|
||||
secondary: '#121212', // 次级背景
|
||||
card: '#1A1A1A', // 卡片背景
|
||||
hover: '#222222', // 悬停背景
|
||||
elevated: '#2A2A2A', // 提升背景(模态框等)
|
||||
},
|
||||
|
||||
// 文字色系
|
||||
text: {
|
||||
primary: '#FFFFFF', // 主文字 - 纯白
|
||||
secondary: '#B8B8B8', // 次要文字 - 灰色
|
||||
tertiary: '#808080', // 三级文字 - 深灰
|
||||
muted: '#5A5A5A', // 弱化文字
|
||||
gold: '#FFC107', // 金色强调文字
|
||||
goldGradient: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', // 金色渐变
|
||||
},
|
||||
|
||||
// 边框色系
|
||||
border: {
|
||||
default: '#333333',
|
||||
light: '#404040',
|
||||
gold: '#FFC107',
|
||||
goldGlow: 'rgba(255, 193, 7, 0.3)',
|
||||
},
|
||||
|
||||
// 功能色
|
||||
semantic: {
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#F44336',
|
||||
info: '#2196F3',
|
||||
},
|
||||
|
||||
// 金色渐变系列
|
||||
gradients: {
|
||||
goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
|
||||
goldSecondary: 'linear-gradient(135deg, #FFC107 0%, #FF8F00 100%)',
|
||||
goldSubtle: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 165, 0, 0.05) 100%)',
|
||||
blackGold: 'linear-gradient(135deg, #0A0A0A 0%, #1A1A1A 50%, #2A2020 100%)',
|
||||
cardHover: 'linear-gradient(135deg, #1A1A1A 0%, #252525 100%)',
|
||||
},
|
||||
|
||||
// 阴影色系
|
||||
shadows: {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.5)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.4)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.7), 0 4px 6px -2px rgba(0, 0, 0, 0.5)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 10px 10px -5px rgba(0, 0, 0, 0.6)',
|
||||
gold: '0 0 20px rgba(255, 193, 7, 0.3), 0 0 40px rgba(255, 193, 7, 0.1)',
|
||||
goldHover: '0 0 30px rgba(255, 193, 7, 0.5), 0 0 60px rgba(255, 193, 7, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 论坛组件样式配置
|
||||
*/
|
||||
export const forumComponentStyles = {
|
||||
// 按钮样式
|
||||
Button: {
|
||||
baseStyle: {
|
||||
fontWeight: '600',
|
||||
borderRadius: 'md',
|
||||
transition: 'all 0.3s ease',
|
||||
},
|
||||
variants: {
|
||||
gold: {
|
||||
bg: forumColors.gradients.goldPrimary,
|
||||
color: '#0A0A0A',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: forumColors.shadows.goldHover,
|
||||
_disabled: {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
goldOutline: {
|
||||
bg: 'transparent',
|
||||
color: forumColors.primary[500],
|
||||
border: '2px solid',
|
||||
borderColor: forumColors.primary[500],
|
||||
_hover: {
|
||||
bg: forumColors.gradients.goldSubtle,
|
||||
boxShadow: forumColors.shadows.gold,
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
bg: forumColors.background.card,
|
||||
color: forumColors.text.primary,
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.default,
|
||||
_hover: {
|
||||
bg: forumColors.background.hover,
|
||||
borderColor: forumColors.border.light,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 卡片样式
|
||||
Card: {
|
||||
baseStyle: {
|
||||
container: {
|
||||
bg: forumColors.background.card,
|
||||
borderRadius: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.default,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: forumColors.border.gold,
|
||||
boxShadow: forumColors.shadows.gold,
|
||||
transform: 'translateY(-4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 输入框样式
|
||||
Input: {
|
||||
variants: {
|
||||
forum: {
|
||||
field: {
|
||||
bg: forumColors.background.secondary,
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.default,
|
||||
color: forumColors.text.primary,
|
||||
_placeholder: {
|
||||
color: forumColors.text.tertiary,
|
||||
},
|
||||
_hover: {
|
||||
borderColor: forumColors.border.light,
|
||||
},
|
||||
_focus: {
|
||||
borderColor: forumColors.border.gold,
|
||||
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 标签样式
|
||||
Tag: {
|
||||
variants: {
|
||||
gold: {
|
||||
container: {
|
||||
bg: forumColors.gradients.goldSubtle,
|
||||
color: forumColors.primary[500],
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.gold,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 论坛专用动画配置
|
||||
*/
|
||||
export const forumAnimations = {
|
||||
fadeIn: {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
|
||||
slideIn: {
|
||||
initial: { opacity: 0, x: -20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: 20 },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
|
||||
scaleIn: {
|
||||
initial: { opacity: 0, scale: 0.9 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.9 },
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
|
||||
goldGlow: {
|
||||
animate: {
|
||||
boxShadow: [
|
||||
forumColors.shadows.gold,
|
||||
forumColors.shadows.goldHover,
|
||||
forumColors.shadows.gold,
|
||||
],
|
||||
},
|
||||
transition: {
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
colors: forumColors,
|
||||
components: forumComponentStyles,
|
||||
animations: forumAnimations,
|
||||
};
|
||||
@@ -53,3 +53,13 @@ export type {
|
||||
CommentAuthor,
|
||||
CreateCommentParams,
|
||||
} from './comment';
|
||||
|
||||
// 投资规划相关类型
|
||||
export type {
|
||||
EventType,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
InvestmentEvent,
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
148
src/types/investment.ts
Normal file
148
src/types/investment.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 投资规划相关类型定义
|
||||
* 用于 InvestmentPlanningCenter 组件及其子组件
|
||||
*/
|
||||
|
||||
import { UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件类型枚举
|
||||
*/
|
||||
export type EventType = 'plan' | 'review' | 'reminder' | 'analysis';
|
||||
|
||||
/**
|
||||
* 事件来源
|
||||
*/
|
||||
export type EventSource = 'user' | 'future' | 'system';
|
||||
|
||||
/**
|
||||
* 事件状态
|
||||
*/
|
||||
export type EventStatus = 'active' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* 投资事件接口
|
||||
* 表示日历中的投资计划、复盘或其他事件
|
||||
*/
|
||||
export interface InvestmentEvent {
|
||||
/** 事件唯一标识符 */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 事件描述/详细内容 */
|
||||
description?: string;
|
||||
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
event_date: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 事件来源(用户创建/系统生成/未来事件) */
|
||||
source?: EventSource;
|
||||
|
||||
/** 重要度 (1-5) */
|
||||
importance?: number;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks?: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status?: EventStatus;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
|
||||
/** 事件内容(用于计划/复盘的详细内容) */
|
||||
content?: string;
|
||||
|
||||
/** 日期字段(兼容旧数据) */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据类型
|
||||
* 用于创建/编辑投资计划或复盘
|
||||
*/
|
||||
export interface PlanFormData {
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
date: string;
|
||||
|
||||
/** 标题 */
|
||||
title: string;
|
||||
|
||||
/** 内容/描述 */
|
||||
content: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning Context 值类型
|
||||
* 用于在 InvestmentPlanningCenter 的子组件间共享数据
|
||||
*/
|
||||
export interface PlanningContextValue {
|
||||
/** 所有事件列表 */
|
||||
allEvents: InvestmentEvent[];
|
||||
|
||||
/** 设置事件列表 */
|
||||
setAllEvents: React.Dispatch<React.SetStateAction<InvestmentEvent[]>>;
|
||||
|
||||
/** 重新加载所有数据 */
|
||||
loadAllData: () => Promise<void>;
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 设置加载状态 */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
/** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */
|
||||
activeTab: number;
|
||||
|
||||
/** 设置激活的标签页 */
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
/** Chakra UI Toast 实例 */
|
||||
toast: {
|
||||
(options?: UseToastOptions): string | number | undefined;
|
||||
close: (id: string | number) => void;
|
||||
closeAll: (options?: { positions?: Array<'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'> }) => void;
|
||||
update: (id: string | number, options: Omit<UseToastOptions, 'id'>) => void;
|
||||
isActive: (id: string | number) => boolean;
|
||||
};
|
||||
|
||||
// 颜色主题变量(基于当前主题模式)
|
||||
/** 背景色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// src/utils/logger.js
|
||||
// 统一日志工具
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
// 支持开发环境或显式开启调试模式
|
||||
// 生产环境下可以通过设置 REACT_APP_ENABLE_DEBUG=true 来开启调试日志
|
||||
const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.REACT_APP_ENABLE_DEBUG === 'true';
|
||||
|
||||
// ========== 日志限流配置 ==========
|
||||
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
|
||||
|
||||
337
src/utils/trackingHelpers.js
Normal file
337
src/utils/trackingHelpers.js
Normal file
@@ -0,0 +1,337 @@
|
||||
// src/utils/trackingHelpers.js
|
||||
// PostHog 追踪性能优化工具 - 使用 requestIdleCallback 延迟非关键事件
|
||||
|
||||
import { shouldTrackImmediately } from '../constants/tracking';
|
||||
|
||||
/**
|
||||
* requestIdleCallback Polyfill
|
||||
* Safari 和旧浏览器不支持 requestIdleCallback,使用 setTimeout 降级
|
||||
*
|
||||
* @param {Function} callback - 回调函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.timeout - 超时时间(毫秒)
|
||||
* @returns {number} 定时器 ID
|
||||
*/
|
||||
const requestIdleCallbackPolyfill = (callback, options = {}) => {
|
||||
const timeout = options.timeout || 2000;
|
||||
const start = Date.now();
|
||||
|
||||
return setTimeout(() => {
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
|
||||
});
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* cancelIdleCallback Polyfill
|
||||
*
|
||||
* @param {number} id - 定时器 ID
|
||||
*/
|
||||
const cancelIdleCallbackPolyfill = (id) => {
|
||||
clearTimeout(id);
|
||||
};
|
||||
|
||||
// 使用原生 API 或 polyfill
|
||||
const requestIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.requestIdleCallback
|
||||
? window.requestIdleCallback.bind(window)
|
||||
: requestIdleCallbackPolyfill;
|
||||
|
||||
const cancelIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.cancelIdleCallback
|
||||
? window.cancelIdleCallback.bind(window)
|
||||
: cancelIdleCallbackPolyfill;
|
||||
|
||||
// ==================== 待发送事件队列 ====================
|
||||
|
||||
/**
|
||||
* 待发送事件队列(用于批量发送优化)
|
||||
* @type {Array<{trackFn: Function, args: Array}>}
|
||||
*/
|
||||
let pendingEvents = [];
|
||||
|
||||
/**
|
||||
* 已调度的 idle callback ID(防止重复调度)
|
||||
* @type {number|null}
|
||||
*/
|
||||
let scheduledCallbackId = null;
|
||||
|
||||
/**
|
||||
* 刷新待发送事件队列
|
||||
* 立即执行所有待发送的追踪事件
|
||||
*/
|
||||
const flushPendingEvents = () => {
|
||||
if (pendingEvents.length === 0) return;
|
||||
|
||||
const eventsToFlush = [...pendingEvents];
|
||||
pendingEvents = [];
|
||||
|
||||
eventsToFlush.forEach(({ trackFn, args }) => {
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to flush event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c✅ [trackingHelpers] Flushed ${eventsToFlush.length} pending event(s)`,
|
||||
'color: #10B981; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理空闲时执行待发送事件
|
||||
*
|
||||
* @param {IdleDeadline} deadline - 空闲时间信息
|
||||
*/
|
||||
const processIdleEvents = (deadline) => {
|
||||
scheduledCallbackId = null;
|
||||
|
||||
// 如果超时或队列为空,强制刷新
|
||||
if (deadline.didTimeout || pendingEvents.length === 0) {
|
||||
flushPendingEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// 在空闲时间内尽可能多地处理事件
|
||||
while (pendingEvents.length > 0 && deadline.timeRemaining() > 0) {
|
||||
const { trackFn, args } = pendingEvents.shift();
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有未处理的事件,继续调度
|
||||
if (pendingEvents.length > 0) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 在浏览器空闲时追踪事件(非关键事件优化)
|
||||
*
|
||||
* 使用 requestIdleCallback API 延迟事件追踪到浏览器空闲时执行,
|
||||
* 避免阻塞主线程,提升页面交互响应速度。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 页面浏览事件(page_viewed)
|
||||
* - 列表查看事件(list_viewed)
|
||||
* - 筛选/排序事件(filter_applied, sorted)
|
||||
* - 低优先级交互事件
|
||||
*
|
||||
* **不适用场景**:
|
||||
* - 关键业务事件(登录、支付、关注)
|
||||
* - 用户明确操作事件(按钮点击、详情打开)
|
||||
* - 需要实时追踪的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 track, trackPageView)
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventIdle } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 延迟追踪页面浏览事件
|
||||
* trackEventIdle(trackEvent, 'page_viewed', { page: '/community' });
|
||||
*
|
||||
* // 延迟追踪筛选事件
|
||||
* trackEventIdle(track, 'news_filter_applied', { importance: 'high' });
|
||||
*/
|
||||
export const trackEventIdle = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到待发送队列
|
||||
pendingEvents.push({ trackFn, args });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⏱️ [trackingHelpers] Event queued for idle execution (queue: ${pendingEvents.length})`,
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有已调度的 callback,调度一个新的
|
||||
if (scheduledCallbackId === null) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000, // 2秒超时保护,确保事件不会无限延迟
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即追踪事件(关键事件)
|
||||
*
|
||||
* 同步执行追踪,不延迟。用于需要实时追踪的关键业务事件。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 关键业务事件(登录、注册、支付、订阅)
|
||||
* - 用户明确操作(按钮点击、详情打开、搜索提交)
|
||||
* - 高优先级交互事件(关注、分享、评论)
|
||||
* - 需要准确时序的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventImmediate } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 立即追踪登录事件
|
||||
* trackEventImmediate(trackEvent, 'user_logged_in', { method: 'password' });
|
||||
*
|
||||
* // 立即追踪详情打开事件
|
||||
* trackEventImmediate(track, 'news_detail_opened', { news_id: 123 });
|
||||
*/
|
||||
export const trackEventImmediate = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
trackFn(...args);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⚡ [trackingHelpers] Event tracked immediately`,
|
||||
'color: #F59E0B; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event immediately:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能追踪包装器
|
||||
*
|
||||
* 根据事件优先级自动选择立即追踪或空闲时追踪。
|
||||
* 使用 `shouldTrackImmediately()` 判断事件优先级,简化调用方代码。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 业务代码不需要关心事件优先级细节
|
||||
* - 统一的追踪接口,自动优化性能
|
||||
* - 易于维护和扩展
|
||||
*
|
||||
* **优先级规则**(由 `src/constants/tracking.js` 配置):
|
||||
* - CRITICAL / HIGH → 立即追踪(`trackEventImmediate`)
|
||||
* - NORMAL / LOW → 空闲时追踪(`trackEventIdle`)
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 `track` from `usePostHogTrack`)
|
||||
* @param {string} eventName - 事件名称(需在 `tracking.js` 中定义优先级)
|
||||
* @param {Object} properties - 事件属性
|
||||
*
|
||||
* @example
|
||||
* import { smartTrack } from '@/utils/trackingHelpers';
|
||||
* import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
*
|
||||
* const { track } = usePostHogTrack();
|
||||
*
|
||||
* // 自动根据优先级选择追踪方式
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: 123 });
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: 30 });
|
||||
*/
|
||||
export const smartTrack = (trackFn, eventName, properties = {}) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventName || typeof eventName !== 'string') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: eventName must be a string');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据事件优先级选择追踪方式
|
||||
if (shouldTrackImmediately(eventName)) {
|
||||
// 高优先级事件:立即追踪
|
||||
trackEventImmediate(trackFn, eventName, properties);
|
||||
} else {
|
||||
// 普通优先级事件:空闲时追踪
|
||||
trackEventIdle(trackFn, eventName, properties);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面卸载前刷新所有待发送事件
|
||||
*
|
||||
* 在 beforeunload 事件中调用,确保页面关闭前发送所有待发送的追踪事件。
|
||||
* 防止用户快速关闭页面时丢失事件数据。
|
||||
*
|
||||
* **使用方式**:
|
||||
* ```javascript
|
||||
* import { flushPendingEventsBeforeUnload } from '@utils/trackingHelpers';
|
||||
*
|
||||
* useEffect(() => {
|
||||
* window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* return () => {
|
||||
* window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* };
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export const flushPendingEventsBeforeUnload = () => {
|
||||
// 取消已调度的 idle callback
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
|
||||
// 立即刷新所有待发送事件
|
||||
flushPendingEvents();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
'%c🔄 [trackingHelpers] Flushed pending events before unload',
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前待发送事件数量(调试用)
|
||||
*
|
||||
* @returns {number} 待发送事件数量
|
||||
*/
|
||||
export const getPendingEventsCount = () => {
|
||||
return pendingEvents.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空待发送事件队列(测试用)
|
||||
*/
|
||||
export const clearPendingEvents = () => {
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
pendingEvents = [];
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
trackEventIdle,
|
||||
trackEventImmediate,
|
||||
smartTrack,
|
||||
flushPendingEventsBeforeUnload,
|
||||
getPendingEventsCount,
|
||||
clearPendingEvents,
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
|
||||
// 扩展 Day.js 插件
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
@@ -12,7 +18,7 @@ import moment from 'moment';
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -25,18 +31,18 @@ export const getCurrentTradingTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ export const getCurrentTradingTimeRange = () => {
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -67,13 +73,13 @@ export const getMarketReviewTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
@@ -102,15 +108,15 @@ export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = moment(startTime);
|
||||
const endMoment = moment(endTime);
|
||||
const startMoment = dayjs(startTime);
|
||||
const endMoment = dayjs(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = moment(event.created_at);
|
||||
const eventTime = dayjs(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
@@ -138,8 +144,8 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = moment(startTime).format('MM-DD HH:mm');
|
||||
const endStr = moment(endTime).format('MM-DD HH:mm');
|
||||
const startStr = dayjs(startTime).format('MM-DD HH:mm');
|
||||
const endStr = dayjs(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
@@ -152,7 +158,7 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = moment(date).day();
|
||||
const day = dayjs(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
@@ -164,7 +170,7 @@ export const isTradingDay = (date) => {
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = moment(date).subtract(1, 'day');
|
||||
let prevDay = dayjs(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
|
||||
@@ -63,7 +63,7 @@ let dynamicNewsCardRenderCount = 0;
|
||||
* @param {Object} trackingFunctions - PostHog 追踪函数集合
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
const DynamicNewsCardComponent = forwardRef(({
|
||||
filters = {},
|
||||
popularKeywords = [],
|
||||
lastUpdateTime,
|
||||
@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
'fourRowData.total': fourRowData.total,
|
||||
});
|
||||
|
||||
// 根据模式选择数据源
|
||||
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式:data 是数组 [...]
|
||||
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
|
||||
const modeData = useMemo(
|
||||
() => currentMode === 'four-row' ? fourRowData : verticalData,
|
||||
[currentMode, fourRowData, verticalData]
|
||||
);
|
||||
const {
|
||||
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
|
||||
loading = false,
|
||||
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
cachedPageCount = 0
|
||||
} = modeData;
|
||||
|
||||
// 传递给 usePagination 的数据
|
||||
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
|
||||
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
|
||||
// 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
|
||||
const allCachedEventsByPage = useMemo(
|
||||
() => currentMode === 'vertical' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
const allCachedEvents = useMemo(
|
||||
() => currentMode === 'four-row' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
|
||||
// 🔍 调试:选择的数据源
|
||||
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
|
||||
@@ -227,8 +236,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
// ========== 纵向模式 ==========
|
||||
// 只在第1页时刷新,避免打断用户浏览其他页
|
||||
if (state.currentPage === 1) {
|
||||
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表');
|
||||
handlePageChange(1); // 清空缓存并刷新第1页
|
||||
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 强制刷新列表');
|
||||
handlePageChange(1, true); // ⚡ 传递 force = true,强制刷新第1页
|
||||
toast({
|
||||
title: '检测到新事件',
|
||||
status: 'info',
|
||||
@@ -722,6 +731,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
);
|
||||
});
|
||||
|
||||
DynamicNewsCard.displayName = 'DynamicNewsCard';
|
||||
DynamicNewsCardComponent.displayName = 'DynamicNewsCard';
|
||||
|
||||
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
|
||||
const DynamicNewsCard = React.memo(DynamicNewsCardComponent);
|
||||
|
||||
export default DynamicNewsCard;
|
||||
|
||||
@@ -30,7 +30,7 @@ import VerticalModeLayout from './VerticalModeLayout';
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
const EventScrollList = React.memo(({
|
||||
events,
|
||||
displayEvents,
|
||||
loadNextPage,
|
||||
@@ -144,6 +144,6 @@ const EventScrollList = ({
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default EventScrollList;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
* @param {string} mode - 当前模式 'vertical' | 'four-row'
|
||||
* @param {Function} onModeChange - 模式切换回调
|
||||
*/
|
||||
const ModeToggleButtons = ({ mode, onModeChange }) => {
|
||||
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
|
||||
return (
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
@@ -28,6 +28,6 @@ const ModeToggleButtons = ({ mode, onModeChange }) => {
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default ModeToggleButtons;
|
||||
|
||||
@@ -35,7 +35,7 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel'
|
||||
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
|
||||
* @param {string} borderColor - 边框颜色
|
||||
*/
|
||||
const VerticalModeLayout = ({
|
||||
const VerticalModeLayout = React.memo(({
|
||||
display = 'flex',
|
||||
events,
|
||||
selectedEvent,
|
||||
@@ -182,6 +182,6 @@ const VerticalModeLayout = ({
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default VerticalModeLayout;
|
||||
|
||||
@@ -25,7 +25,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||||
* @param {boolean} props.loading - 加载状态
|
||||
*/
|
||||
const VirtualizedFourRowGrid = forwardRef(({
|
||||
const VirtualizedFourRowGridComponent = forwardRef(({
|
||||
display = 'block',
|
||||
events,
|
||||
columnsPerRow = 4,
|
||||
@@ -387,6 +387,9 @@ const VirtualizedFourRowGrid = forwardRef(({
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid';
|
||||
VirtualizedFourRowGridComponent.displayName = 'VirtualizedFourRowGrid';
|
||||
|
||||
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
|
||||
const VirtualizedFourRowGrid = React.memo(VirtualizedFourRowGridComponent);
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
|
||||
@@ -158,7 +158,11 @@ export const usePagination = ({
|
||||
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
|
||||
|
||||
// 翻页处理(第1页强制刷新 + 其他页缓存)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
const handlePageChange = useCallback(async (newPage, force = false) => {
|
||||
// force 参数:是否强制刷新(绕过"重复点击"检查)
|
||||
// - true: 强制刷新(Socket 新事件触发)
|
||||
// - false: 正常翻页(用户点击分页按钮)
|
||||
|
||||
// 边界检查 1: 检查页码范围
|
||||
if (newPage < 1 || newPage > totalPages) {
|
||||
console.log(`%c⚠️ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
|
||||
@@ -166,13 +170,19 @@ export const usePagination = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 边界检查 2: 检查是否重复点击
|
||||
if (newPage === currentPage) {
|
||||
// 边界检查 2: 检查是否重复点击(强制刷新时绕过此检查)
|
||||
if (!force && newPage === currentPage) {
|
||||
console.log(`%c⚠️ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
|
||||
logger.debug('usePagination', '页码未改变', { newPage });
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚡ 如果是强制刷新(force = true),即使页码相同也继续执行
|
||||
if (force && newPage === currentPage) {
|
||||
console.log(`%c🔄 [翻页] 强制刷新当前页: ${newPage}`, 'color: #10B981; font-weight: bold;');
|
||||
logger.info('usePagination', '强制刷新当前页', { newPage });
|
||||
}
|
||||
|
||||
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
|
||||
if (loadingPage === newPage) {
|
||||
console.log(`%c⚠️ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
@@ -98,7 +98,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{moment(event.created_at).format('YYYY年MM月DD日')}
|
||||
{dayjs(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -26,7 +26,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,9 +105,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
|
||||
const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
@@ -28,9 +28,9 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
<FaCalendarAlt color="gray" size={12} />
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
涨跌幅数据:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
@@ -89,16 +89,16 @@ const RelatedConceptsSection = ({
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -137,7 +137,7 @@ const CompactEventCard = ({
|
||||
<Text>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
<Text>•</Text>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -127,7 +127,7 @@ const DetailedEventCard = ({
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { getChangeColor } from '../../../../utils/colorUtils';
|
||||
|
||||
@@ -33,7 +33,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
|
||||
* @param {Function} props.onToggleFollow - 切换关注事件
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
*/
|
||||
const DynamicNewsEventCard = ({
|
||||
const DynamicNewsEventCard = React.memo(({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = ({
|
||||
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
|
||||
*/
|
||||
const getTradingPeriod = (timestamp) => {
|
||||
const eventTime = moment(timestamp);
|
||||
const eventTime = dayjs(timestamp);
|
||||
const hour = eventTime.hour();
|
||||
const minute = eventTime.minute();
|
||||
const timeInMinutes = hour * 60 + minute;
|
||||
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = ({
|
||||
color={timeLabelStyle.textColor}
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{periodLabel && (
|
||||
<>
|
||||
{' • '}
|
||||
@@ -317,6 +317,6 @@ const DynamicNewsEventCard = ({
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default DynamicNewsEventCard;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/EventCard/EventTimeline.js
|
||||
import React from 'react';
|
||||
import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 事件时间轴组件
|
||||
@@ -56,7 +56,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
color={timelineStyle.textColor}
|
||||
lineHeight="1.2"
|
||||
>
|
||||
{moment(createdAt).format('MM-DD')}
|
||||
{dayjs(createdAt).format('MM-DD')}
|
||||
</Text>
|
||||
{/* 时间 HH:mm */}
|
||||
<Text
|
||||
@@ -66,7 +66,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
lineHeight="1.2"
|
||||
mt={0.5}
|
||||
>
|
||||
{moment(createdAt).format('HH:mm')}
|
||||
{dayjs(createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 时间轴竖线 */}
|
||||
|
||||
@@ -39,7 +39,7 @@ import KeywordsCarousel from './KeywordsCarousel';
|
||||
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
|
||||
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
|
||||
*/
|
||||
const HorizontalDynamicNewsEventCard = ({
|
||||
const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
@@ -227,6 +227,6 @@ const HorizontalDynamicNewsEventCard = ({
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default HorizontalDynamicNewsEventCard;
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
// src/views/Community/components/EventDetailModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
|
||||
const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventDetail, setEventDetail] = useState(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
|
||||
const loadEventDetail = async () => {
|
||||
if (!event) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setEventDetail(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDetailModal', 'loadEventDetail', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!event) return;
|
||||
|
||||
setCommentsLoading(true);
|
||||
try {
|
||||
// 使用统一的posts API获取评论
|
||||
const result = await eventService.getPosts(event.id);
|
||||
if (result.success) {
|
||||
setComments(result.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDetailModal', 'loadComments', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && event) {
|
||||
loadEventDetail();
|
||||
loadComments();
|
||||
}
|
||||
}, [visible, event]);
|
||||
|
||||
const getImportanceColor = (importance) => {
|
||||
const colors = {
|
||||
S: 'red',
|
||||
A: 'orange',
|
||||
B: 'blue',
|
||||
C: 'green'
|
||||
};
|
||||
return colors[importance] || 'default';
|
||||
};
|
||||
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderPriceTag = (value, label) => {
|
||||
if (value === null || value === undefined) return `${label}: --`;
|
||||
|
||||
const color = value > 0 ? '#ff4d4f' : '#52c41a';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}: <span style={{ color }}>{prefix}{value.toFixed(2)}%</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!commentText.trim()) {
|
||||
message.warning('请输入评论内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 使用统一的createPost API
|
||||
const result = await eventService.createPost(event.id, {
|
||||
content: commentText.trim(),
|
||||
content_type: 'text'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
message.success('评论发布成功');
|
||||
setCommentText('');
|
||||
// 重新加载评论列表
|
||||
loadComments();
|
||||
} else {
|
||||
throw new Error(result.message || '评论失败');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '评论失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={eventDetail?.title || '事件详情'}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{eventDetail && (
|
||||
<>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{moment(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{eventDetail.creator?.username || 'Anonymous'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="重要性">
|
||||
<Badge color={getImportanceColor(eventDetail.importance)} text={`${eventDetail.importance}级`} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="浏览数">
|
||||
{eventDetail.view_count || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涨幅统计" span={2}>
|
||||
<Tag>{renderPriceTag(eventDetail.related_avg_chg, '平均涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_max_chg, '最大涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事件描述" span={2}>
|
||||
{eventDetail.description}(AI合成)
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{eventDetail.keywords && eventDetail.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h4>相关概念</h4>
|
||||
{eventDetail.keywords.map((keyword, index) => (
|
||||
<Tag key={index} color="blue" style={{ marginBottom: 8 }}>
|
||||
{keyword}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetail.related_stocks && eventDetail.related_stocks.length > 0 && (
|
||||
<div>
|
||||
<h4>相关股票</h4>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={eventDetail.related_stocks}
|
||||
renderItem={stock => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
}}
|
||||
>
|
||||
股票详情
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={`${stock.stock_name} (${stock.stock_code})`}
|
||||
description={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : ''}
|
||||
/>
|
||||
{stock.change !== null && (
|
||||
<Tag color={stock.change > 0 ? 'red' : 'green'}>
|
||||
{stock.change > 0 ? '+' : ''}{stock.change.toFixed(2)}%
|
||||
</Tag>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 讨论区 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h4>讨论区</h4>
|
||||
|
||||
{/* 评论列表 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Spin spinning={commentsLoading}>
|
||||
{comments.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无评论"
|
||||
style={{ padding: '20px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={comments}
|
||||
renderItem={comment => (
|
||||
<List.Item key={comment.id}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<strong>{comment.author?.username || 'Anonymous'}</strong>
|
||||
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
|
||||
{moment(comment.created_at).format('MM-DD HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
marginTop: 8
|
||||
}}>
|
||||
{comment.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 评论输入框(登录后可用,未登录后端会返回401) */}
|
||||
<div>
|
||||
<h4>发表评论</h4>
|
||||
<Input.TextArea
|
||||
placeholder="说点什么..."
|
||||
rows={3}
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
<div style={{ textAlign: 'right', marginTop: 8 }}>
|
||||
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
@@ -1,387 +0,0 @@
|
||||
/* src/views/Community/components/EventList.css */
|
||||
|
||||
/* 时间轴容器样式 */
|
||||
.event-timeline {
|
||||
padding: 0 0 0 24px;
|
||||
}
|
||||
|
||||
/* 时间轴圆点样式 */
|
||||
.timeline-dot {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* 时间轴事件卡片 */
|
||||
.timeline-event-card {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
transform: translateX(8px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 重要性标记线 */
|
||||
.importance-marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-marker {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* 事件标题 */
|
||||
.event-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.event-title a:hover {
|
||||
color: #40a9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 事件元信息 */
|
||||
.event-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.event-meta .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.event-meta .separator {
|
||||
margin: 0;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
/* 事件描述 */
|
||||
.event-description {
|
||||
margin: 0 0 12px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 事件统计标签 */
|
||||
.event-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag {
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 事件操作区域 */
|
||||
.event-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.event-actions > span {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.event-actions .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 事件按钮 */
|
||||
.event-buttons {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-sm {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 重要性指示器 */
|
||||
.importance-indicator {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-badge {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-avatar {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-indicator .ant-avatar {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 分页容器 */
|
||||
.pagination-container {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.event-timeline {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timeline-event-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importance-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色主题支持(可选) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.timeline-event-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴项目动画 */
|
||||
.ant-timeline-item {
|
||||
animation: fadeInLeft 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-timeline-item:nth-child(1) { animation-delay: 0.1s; }
|
||||
.ant-timeline-item:nth-child(2) { animation-delay: 0.2s; }
|
||||
.ant-timeline-item:nth-child(3) { animation-delay: 0.3s; }
|
||||
.ant-timeline-item:nth-child(4) { animation-delay: 0.4s; }
|
||||
.ant-timeline-item:nth-child(5) { animation-delay: 0.5s; }
|
||||
.ant-timeline-item:nth-child(6) { animation-delay: 0.6s; }
|
||||
.ant-timeline-item:nth-child(7) { animation-delay: 0.7s; }
|
||||
.ant-timeline-item:nth-child(8) { animation-delay: 0.8s; }
|
||||
.ant-timeline-item:nth-child(9) { animation-delay: 0.9s; }
|
||||
.ant-timeline-item:nth-child(10) { animation-delay: 1s; }
|
||||
|
||||
/* 时间轴连接线样式 */
|
||||
.ant-timeline-item-tail {
|
||||
border-left-style: dashed;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
/* 涨跌幅标签特殊样式 */
|
||||
.event-stats .ant-tag[color="#ff4d4f"] {
|
||||
background-color: #fff1f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag[color="#52c41a"] {
|
||||
background-color: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
/* 快速查看和详细信息按钮悬停效果 */
|
||||
.event-buttons .ant-btn-default:hover {
|
||||
color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 工具提示样式 */
|
||||
.ant-tooltip-inner {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 徽章计数样式 */
|
||||
.importance-indicator .ant-badge-count {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* 加载状态动画 */
|
||||
.timeline-event-card.loading {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊重要性等级样式增强 */
|
||||
.timeline-event-card[data-importance="S"] {
|
||||
border-left: 4px solid #722ed1;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="A"] {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="B"] {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="C"] {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
/* 时间轴左侧内容区域优化 */
|
||||
.ant-timeline-item-content {
|
||||
padding-bottom: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 确保最后一个时间轴项目没有连接线 */
|
||||
.ant-timeline-item:last-child .ant-timeline-item-tail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 打印样式优化 */
|
||||
@media print {
|
||||
.timeline-event-card {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.importance-marker {
|
||||
width: 2px !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Flex,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
useToast,
|
||||
Center,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// 导入工具函数和常量
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import { useEventNotifications } from '../../../hooks/useEventNotifications';
|
||||
import { browserNotificationService } from '../../../services/browserNotificationService';
|
||||
import { useNotification } from '../../../contexts/NotificationContext';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import EventCard from './EventCard';
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
|
||||
|
||||
// 从 NotificationContext 获取推送权限相关状态和方法
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
|
||||
// 实时事件推送集成
|
||||
const { isConnected } = useEventNotifications({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
enabled: true,
|
||||
onNewEvent: (event) => {
|
||||
console.log('\n[EventList DEBUG] ========== EventList 收到新事件 ==========');
|
||||
console.log('[EventList DEBUG] 事件数据:', event);
|
||||
console.log('[EventList DEBUG] 事件 ID:', event?.id);
|
||||
console.log('[EventList DEBUG] 事件标题:', event?.title);
|
||||
logger.info('EventList', '收到新事件推送', event);
|
||||
|
||||
// 发送浏览器原生通知
|
||||
console.log('[EventList DEBUG] 准备发送浏览器原生通知');
|
||||
console.log('[EventList DEBUG] 通知权限状态:', browserPermission);
|
||||
if (browserPermission === 'granted') {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const notification = browserNotificationService.sendNotification({
|
||||
title: `🔔 ${importance.label}级事件`,
|
||||
body: event.title,
|
||||
tag: `event_${event.id}`,
|
||||
data: {
|
||||
link: `/event-detail/${event.id}`,
|
||||
eventId: event.id,
|
||||
},
|
||||
autoClose: 10000, // 10秒后自动关闭
|
||||
});
|
||||
|
||||
if (notification) {
|
||||
browserNotificationService.setupClickHandler(notification, navigate);
|
||||
console.log('[EventList DEBUG] ✓ 浏览器原生通知已发送');
|
||||
} else {
|
||||
console.log('[EventList DEBUG] ⚠️ 浏览器原生通知发送失败');
|
||||
}
|
||||
} else {
|
||||
console.log('[EventList DEBUG] ⚠️ 浏览器通知权限未授予,跳过原生通知');
|
||||
}
|
||||
|
||||
console.log('[EventList DEBUG] 准备更新事件列表');
|
||||
// 将新事件添加到列表顶部(防止重复)
|
||||
setLocalEvents((prevEvents) => {
|
||||
console.log('[EventList DEBUG] 当前事件列表数量:', prevEvents.length);
|
||||
const exists = prevEvents.some(e => e.id === event.id);
|
||||
console.log('[EventList DEBUG] 事件是否已存在:', exists);
|
||||
if (exists) {
|
||||
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
|
||||
console.log('[EventList DEBUG] ⚠️ 事件已存在,跳过添加');
|
||||
return prevEvents;
|
||||
}
|
||||
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
|
||||
console.log('[EventList DEBUG] ✓ 新事件添加到列表顶部');
|
||||
// 添加到顶部,最多保留 100 个
|
||||
const updatedEvents = [event, ...prevEvents].slice(0, 100);
|
||||
console.log('[EventList DEBUG] 更新后事件列表数量:', updatedEvents.length);
|
||||
return updatedEvents;
|
||||
});
|
||||
console.log('[EventList DEBUG] ✓ 事件列表更新完成');
|
||||
console.log('[EventList DEBUG] ========== EventList 处理完成 ==========\n');
|
||||
}
|
||||
});
|
||||
|
||||
// 同步外部 events 到 localEvents
|
||||
useEffect(() => {
|
||||
setLocalEvents(events);
|
||||
}, [events]);
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
localEvents.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 localEvents 更新时重跑
|
||||
}, [localEvents]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理推送开关切换
|
||||
const handlePushToggle = async (e) => {
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
if (isChecked) {
|
||||
// 用户想开启推送
|
||||
logger.info('EventList', '用户请求开启推送');
|
||||
const permission = await requestBrowserPermission();
|
||||
|
||||
if (permission === 'denied') {
|
||||
// 权限被拒绝,显示设置指引
|
||||
logger.warn('EventList', '用户拒绝了推送权限');
|
||||
toast({
|
||||
title: '推送权限被拒绝',
|
||||
description: '如需开启推送,请在浏览器设置中允许通知权限',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
} else if (permission === 'granted') {
|
||||
logger.info('EventList', '推送权限已授予');
|
||||
}
|
||||
} else {
|
||||
// 用户想关闭推送 - 提示需在浏览器设置中操作
|
||||
logger.info('EventList', '用户尝试关闭推送');
|
||||
toast({
|
||||
title: '关闭推送通知',
|
||||
description: '如需关闭,请在浏览器设置中撤销通知权限',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 时间轴样式配置(固定使用轻量卡片样式)
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// 计算要显示的页码数组(智能分页)
|
||||
const getPageNumbers = () => {
|
||||
const delta = 2; // 当前页左右各显示2个页码
|
||||
const range = [];
|
||||
const rangeWithDots = [];
|
||||
|
||||
// 始终显示第1页
|
||||
range.push(1);
|
||||
|
||||
// 显示当前页附近的页码
|
||||
for (let i = current - delta; i <= current + delta; i++) {
|
||||
if (i > 1 && i < totalPages) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 始终显示最后一页(如果总页数>1)
|
||||
if (totalPages > 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
|
||||
// 去重并排序
|
||||
const uniqueRange = [...new Set(range)].sort((a, b) => a - b);
|
||||
|
||||
// 添加省略号
|
||||
let prev = 0;
|
||||
for (const page of uniqueRange) {
|
||||
if (page - prev === 2) {
|
||||
// 如果只差一个页码,直接显示
|
||||
rangeWithDots.push(prev + 1);
|
||||
} else if (page - prev > 2) {
|
||||
// 如果差距大于2,显示省略号
|
||||
rangeWithDots.push('...');
|
||||
}
|
||||
rangeWithDots.push(page);
|
||||
prev = page;
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<Text key={`ellipsis-${index}`} px={2} color="gray.500">
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
size="sm"
|
||||
variant={current === page ? 'solid' : 'ghost'}
|
||||
colorScheme={current === page ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" pb={8}>
|
||||
{/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制(固定sticky) - 铺满全宽 */}
|
||||
<Box
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(26, 32, 44, 0.9)')}
|
||||
backdropFilter="blur(10px)"
|
||||
boxShadow="sm"
|
||||
mb={4}
|
||||
py={2}
|
||||
w="100%"
|
||||
>
|
||||
<Container maxW="container.xl">
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧占位 */}
|
||||
<Box key="left-spacer" flex="1" />
|
||||
|
||||
{/* 中间:分页器 */}
|
||||
{pagination.total > 0 && localEvents.length > 0 ? (
|
||||
<Flex key="pagination-controls" align="center" gap={2}>
|
||||
<Button
|
||||
key="prev-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current - 1)}
|
||||
isDisabled={pagination.current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
|
||||
第 {pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)} 页
|
||||
</Text>
|
||||
<Button
|
||||
key="next-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current + 1)}
|
||||
isDisabled={pagination.current === Math.ceil(pagination.total / pagination.pageSize)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
|
||||
共 {pagination.total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box key="center-spacer" flex="1" />
|
||||
)}
|
||||
|
||||
{/* 右侧:控制按钮 */}
|
||||
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
|
||||
{/* WebSocket 连接状态 */}
|
||||
<Badge
|
||||
key="websocket-status"
|
||||
colorScheme={isConnected ? 'green' : 'red'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{isConnected ? '🟢 实时' : '🔴 离线'}
|
||||
</Badge>
|
||||
|
||||
{/* 桌面推送开关 */}
|
||||
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
推送
|
||||
</FormLabel>
|
||||
<Tooltip
|
||||
label={
|
||||
browserPermission === 'granted'
|
||||
? '桌面推送已开启'
|
||||
: browserPermission === 'denied'
|
||||
? '推送权限被拒绝,请在浏览器设置中允许通知权限'
|
||||
: '点击开启桌面推送通知'
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Switch
|
||||
id="push-notification"
|
||||
size="sm"
|
||||
isChecked={browserPermission === 'granted'}
|
||||
onChange={handlePushToggle}
|
||||
colorScheme="green"
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
|
||||
{/* 视图切换控制 */}
|
||||
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
精简
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
size="sm"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 事件列表内容 */}
|
||||
<Container maxW="container.xl">
|
||||
{localEvents.length > 0 ? (
|
||||
<VStack key="event-list" align="stretch" spacing={0}>
|
||||
{localEvents.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
<EventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isCompactMode={isCompactMode}
|
||||
isFollowing={!!followingMap[event.id]}
|
||||
followerCount={followCountMap[event.id] ?? (event.follower_count || 0)}
|
||||
onEventClick={onEventClick}
|
||||
onTitleClick={handleTitleClick}
|
||||
onViewDetail={handleViewDetailClick}
|
||||
onToggleFollow={toggleFollow}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center key="empty-state" h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
|
||||
<Text key="empty-text" color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
key="bottom-pagination"
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -1,818 +0,0 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Flex,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Circle,
|
||||
Stat,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
ButtonGroup,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
Center,
|
||||
Link,
|
||||
Spacer,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ViewIcon,
|
||||
ChatIcon,
|
||||
StarIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
WarningTwoIcon,
|
||||
CheckCircleIcon,
|
||||
TriangleUpIcon,
|
||||
TriangleDownIcon,
|
||||
ArrowForwardIcon,
|
||||
ExternalLinkIcon,
|
||||
ViewOffIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
const getPriceChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.500';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨用红色,根据涨幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'red.600'; // 深红色:3%以上
|
||||
if (absValue >= 1) return 'red.500'; // 中红色:1-3%
|
||||
return 'red.400'; // 浅红色:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌用绿色,根据跌幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'green.600'; // 深绿色:3%以上
|
||||
if (absValue >= 1) return 'green.500'; // 中绿色:1-3%
|
||||
return 'green.400'; // 浅绿色:0-1%
|
||||
}
|
||||
return 'gray.500';
|
||||
};
|
||||
|
||||
const getPriceChangeBg = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.50';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨背景色
|
||||
if (absValue >= 3) return 'red.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'red.50'; // 中色背景:1-3%
|
||||
return 'red.50'; // 浅色背景:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌背景色
|
||||
if (absValue >= 3) return 'green.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'green.50'; // 中色背景:1-3%
|
||||
return 'green.50'; // 浅色背景:0-1%
|
||||
}
|
||||
return 'gray.50';
|
||||
};
|
||||
|
||||
const getPriceChangeBorderColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.300';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨边框色
|
||||
if (absValue >= 3) return 'red.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'red.400'; // 中边框:1-3%
|
||||
return 'red.300'; // 浅边框:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌边框色
|
||||
if (absValue >= 3) return 'green.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'green.400'; // 中边框:1-3%
|
||||
return 'green.300'; // 浅边框:0-1%
|
||||
}
|
||||
return 'gray.300';
|
||||
};
|
||||
|
||||
// 重要性等级配置 - 金融配色方案
|
||||
const importanceLevels = {
|
||||
'S': {
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
},
|
||||
'A': {
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
},
|
||||
'B': {
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
},
|
||||
'C': {
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
}
|
||||
};
|
||||
|
||||
const getImportanceConfig = (importance) => {
|
||||
return importanceLevels[importance] || importanceLevels['C'];
|
||||
};
|
||||
|
||||
// 自定义的涨跌箭头组件(修复颜色问题)
|
||||
const PriceArrow = ({ value }) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
|
||||
const color = value > 0 ? 'red.500' : 'green.500';
|
||||
|
||||
return <Icon color={color} boxSize="16px" />;
|
||||
};
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
events.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 events 更新时重跑
|
||||
}, [events]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const renderPriceChange = (value, label) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
|
||||
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
// 根据涨跌幅大小选择不同的颜色深浅
|
||||
let colorScheme = 'gray';
|
||||
let variant = 'solid';
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨用红色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'red';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'red';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'red';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
} else {
|
||||
// 下跌用绿色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'green';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'green';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'green';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
size="lg"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
variant={variant}
|
||||
boxShadow="sm"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={Icon} boxSize="16px" />
|
||||
<TagLabel fontSize="sm" fontWeight="bold">
|
||||
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 精简模式的事件渲染
|
||||
const renderCompactEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="32px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
boxShadow="sm"
|
||||
border="2px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="60px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 精简事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={3}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
|
||||
{/* 左侧:标题和时间 */}
|
||||
<VStack align="start" spacing={2} flex="1" minW="200px">
|
||||
<Heading
|
||||
size="sm"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
noOfLines={1}
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
<HStack spacing={2} fontSize="xs" color={mutedColor}>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
|
||||
<Text>•</Text>
|
||||
<Text>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅指标 */}
|
||||
<HStack spacing={3}>
|
||||
<Tooltip label="平均涨幅" placement="top">
|
||||
<Box
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="1px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getPriceChangeColor(event.related_avg_chg)}
|
||||
>
|
||||
{event.related_avg_chg != null
|
||||
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
|
||||
: '--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
colorScheme="yellow"
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
|
||||
const renderDetailedEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="40px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
boxShadow="md"
|
||||
border="3px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="100px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={4}
|
||||
>
|
||||
<CardBody p={5}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题和重要性标签 */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Tooltip
|
||||
label="点击查看事件详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
openDelay={500}
|
||||
>
|
||||
<Heading
|
||||
size="md"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
colorScheme={importance.color.split('.')[0]}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{importance.label}优先级
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* 元信息 */}
|
||||
<HStack spacing={4} fontSize="sm">
|
||||
<HStack
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
color="blue.700"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
</HStack>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
|
||||
{event.description}
|
||||
</Text>
|
||||
|
||||
{/* 价格变化指标 */}
|
||||
<Box
|
||||
bg={useColorModeValue('gradient.subtle', 'gray.700')}
|
||||
bgGradient="linear(to-r, gray.50, white)"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
平均涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
|
||||
{event.related_avg_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_max_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
最大涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
|
||||
{event.related_max_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_max_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_week_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
周涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
|
||||
{event.related_week_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_week_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 统计信息和操作按钮 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
<HStack spacing={6}>
|
||||
<Tooltip label="浏览量" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ViewIcon />
|
||||
<Text fontSize="sm">{event.view_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="帖子数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ChatIcon />
|
||||
<Text fontSize="sm">{event.post_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="关注数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<StarIcon />
|
||||
<Text fontSize="sm">{followerCount}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}}
|
||||
>
|
||||
快速查看
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详细信息
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={current === pageNum ? 'solid' : 'ghost'}
|
||||
colorScheme={current === pageNum ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{totalPages > 5 && <Text>...</Text>}
|
||||
{totalPages > 5 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={current === totalPages ? 'solid' : 'ghost'}
|
||||
colorScheme={current === totalPages ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
{/* 视图切换控制 */}
|
||||
<Flex justify="flex-end" mb={6}>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||
精简模式
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
{isCompactMode
|
||||
? renderCompactEvent(event)
|
||||
: renderDetailedEvent(event)
|
||||
}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -1,75 +0,0 @@
|
||||
// src/views/Community/components/EventListSection.js
|
||||
// 事件列表区域组件(包含Loading、Empty、List三种状态)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
VStack,
|
||||
Spinner,
|
||||
Text
|
||||
} from '@chakra-ui/react';
|
||||
import EventList from './EventList';
|
||||
|
||||
/**
|
||||
* 事件列表区域组件
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {Object} pagination - 分页信息
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
*/
|
||||
const EventListSection = ({
|
||||
loading,
|
||||
events,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail
|
||||
}) => {
|
||||
// ✅ 最小高度,避免加载后高度突变
|
||||
const minHeight = '600px';
|
||||
|
||||
// Loading 状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty 状态
|
||||
if (!events || events.length === 0) {
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// List 状态
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<EventList
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
onEventClick={onEventClick}
|
||||
onViewDetail={onViewDetail}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventListSection;
|
||||
@@ -1,42 +0,0 @@
|
||||
// src/views/Community/components/EventTimelineHeader.js
|
||||
// 事件时间轴标题组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 事件时间轴标题组件
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
*/
|
||||
const EventTimelineHeader = ({ lastUpdateTime }) => {
|
||||
return (
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="green">全网监控</Badge>
|
||||
<Badge colorScheme="orange">智能捕获</Badge>
|
||||
<Badge colorScheme="purple">深度分析</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTimelineHeader;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
@@ -181,9 +181,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
<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-date">{dayjs(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
<span className="time-hour">{dayjs(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// src/views/Community/components/ImportanceLegend.js
|
||||
import React from 'react';
|
||||
import { Card, Space, Badge } from 'antd';
|
||||
|
||||
const ImportanceLegend = () => {
|
||||
const levels = [
|
||||
{ level: 'S', color: '#ff4d4f', description: '重大事件,市场影响深远' },
|
||||
{ level: 'A', color: '#faad14', description: '重要事件,影响较大' },
|
||||
{ level: 'B', color: '#1890ff', description: '普通事件,有一定影响' },
|
||||
{ level: 'C', color: '#52c41a', description: '参考事件,影响有限' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="重要性等级说明" className="importance-legend">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{levels.map(item => (
|
||||
<div key={item.level} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Badge
|
||||
color={item.color}
|
||||
text={
|
||||
<span>
|
||||
<strong style={{ marginRight: 8 }}>{item.level}级</strong>
|
||||
{item.description}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportanceLegend;
|
||||
@@ -1,78 +0,0 @@
|
||||
// src/views/Community/components/IndustryCascader.js
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, Form, Cascader } from 'antd';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const IndustryCascader = ({ onFilterChange, loading }) => {
|
||||
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
|
||||
|
||||
// 使用 Redux 获取行业数据
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = useCallback(async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// Cascader 选择变化
|
||||
const handleIndustryCascaderChange = (value, selectedOptions) => {
|
||||
setIndustryCascaderValue(value);
|
||||
|
||||
if (value && value.length > 0) {
|
||||
// value[0] = 分类体系名称
|
||||
// value[1...n] = 行业代码(一级~四级)
|
||||
const industryCode = value[value.length - 1]; // 最后一级的 code
|
||||
const classification = value[0]; // 分类体系名称
|
||||
|
||||
onFilterChange('industry_classification', classification);
|
||||
onFilterChange('industry_code', industryCode);
|
||||
|
||||
logger.debug('IndustryCascader', 'Cascader 选择变化', {
|
||||
value,
|
||||
classification,
|
||||
industryCode,
|
||||
path: selectedOptions.map(o => o.label).join(' > ')
|
||||
});
|
||||
} else {
|
||||
// 清空
|
||||
onFilterChange('industry_classification', '');
|
||||
onFilterChange('industry_code', '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="industry-cascader" title="行业分类" style={{ marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="选择行业分类体系和具体行业">
|
||||
<Cascader
|
||||
options={industryData || []}
|
||||
value={industryCascaderValue}
|
||||
onChange={handleIndustryCascaderChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
changeOnSelect
|
||||
placeholder={industryLoading ? "加载中..." : "请选择行业分类体系和具体行业"}
|
||||
disabled={loading || industryLoading}
|
||||
loading={industryLoading}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels.join(' > ')}
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option => option.label.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndustryCascader;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
|
||||
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
|
||||
} from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
|
||||
@@ -33,7 +33,7 @@ const InvestmentCalendar = () => {
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(moment());
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||
|
||||
// 新增状态
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
|
||||
@@ -344,7 +344,7 @@ const InvestmentCalendar = () => {
|
||||
render: (time) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text>{moment(time).format('HH:mm')}</Text>
|
||||
<Text>{dayjs(time).format('HH:mm')}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
// src/views/Community/components/MarketReviewCard.js
|
||||
// 市场复盘组件(左右布局:事件列表 | 事件详情)
|
||||
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import CompactEventCard from './EventCard/CompactEventCard';
|
||||
import EventHeader from './EventCard/EventHeader';
|
||||
import EventStats from './EventCard/EventStats';
|
||||
import EventFollowButton from './EventCard/EventFollowButton';
|
||||
import EventPriceDisplay from './EventCard/EventPriceDisplay';
|
||||
import EventDescription from './EventCard/EventDescription';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
/**
|
||||
* 市场复盘 - 左右布局卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Function} onToggleFollow - 切换关注回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const MarketReviewCard = forwardRef(({
|
||||
events,
|
||||
loading,
|
||||
lastUpdateTime,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
onToggleFollow,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const selectedBg = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
// 选中的事件
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
// 时间轴样式配置
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (event) => {
|
||||
setSelectedEvent(event);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染右侧事件详情
|
||||
const renderEventDetail = () => {
|
||||
if (!selectedEvent) {
|
||||
return (
|
||||
<Center h="full" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
请从左侧选择事件查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(selectedEvent.importance);
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
h="full"
|
||||
>
|
||||
<CardBody p={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 第一行:标题+优先级 | 统计+关注 */}
|
||||
<Flex align="center" justify="space-between" gap={3}>
|
||||
{/* 左侧:标题 + 优先级标签 */}
|
||||
<EventHeader
|
||||
title={selectedEvent.title}
|
||||
importance={selectedEvent.importance}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onViewDetail) {
|
||||
onViewDetail(e, selectedEvent.id);
|
||||
}
|
||||
}}
|
||||
linkColor={linkColor}
|
||||
compact={false}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* 右侧:统计数据 + 关注按钮 */}
|
||||
<HStack spacing={4} flexShrink={0}>
|
||||
{/* 统计数据 */}
|
||||
<EventStats
|
||||
viewCount={selectedEvent.view_count}
|
||||
postCount={selectedEvent.post_count}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
size="md"
|
||||
spacing={4}
|
||||
display="flex"
|
||||
mutedColor={mutedColor}
|
||||
/>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={false}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
|
||||
size="sm"
|
||||
showCount={false}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:价格标签 | 时间+作者 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
{/* 左侧:价格标签 */}
|
||||
<EventPriceDisplay
|
||||
avgChange={selectedEvent.related_avg_chg}
|
||||
maxChange={selectedEvent.related_max_chg}
|
||||
weekChange={selectedEvent.related_week_chg}
|
||||
compact={false}
|
||||
/>
|
||||
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:描述文字 */}
|
||||
<EventDescription
|
||||
description={selectedEvent.description}
|
||||
textColor={textColor}
|
||||
minLength={200}
|
||||
noOfLines={10}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>市场复盘</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="orange">复盘</Badge>
|
||||
<Badge colorScheme="purple">总结</Badge>
|
||||
<Badge colorScheme="gray">完整</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody>
|
||||
{/* Loading 状态 */}
|
||||
{loading && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载复盘数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Empty 状态 */}
|
||||
{!loading && (!events || events.length === 0) && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无复盘数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 左右布局:事件列表 | 事件详情 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
|
||||
{/* 左侧:事件列表 (33.3%) */}
|
||||
<GridItem>
|
||||
<Box
|
||||
overflowY="auto"
|
||||
maxH="600px"
|
||||
pr={2}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: useColorModeValue('#f1f1f1', '#2D3748'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: useColorModeValue('#888', '#4A5568'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: useColorModeValue('#555', '#718096'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
onClick={() => handleEventClick(event)}
|
||||
cursor="pointer"
|
||||
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: selectedBg }}
|
||||
>
|
||||
<CompactEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={false}
|
||||
followerCount={event.follower_count || 0}
|
||||
onEventClick={() => handleEventClick(event)}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEventClick(event);
|
||||
}}
|
||||
onViewDetail={onViewDetail}
|
||||
onToggleFollow={() => {}}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧:事件详情 (66.7%) */}
|
||||
<GridItem>
|
||||
{renderEventDetail()}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
MarketReviewCard.displayName = 'MarketReviewCard';
|
||||
|
||||
export default MarketReviewCard;
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { Drawer, Spin, Button, Alert } from 'antd';
|
||||
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { Tabs as AntdTabs } from 'antd';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Services and Utils
|
||||
import { eventService } from '../../../services/eventService';
|
||||
@@ -167,7 +167,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
if (fixedCharts.length === 0) return null;
|
||||
|
||||
const formattedEventTime = event?.start_time
|
||||
? moment(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
? dayjs(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
: undefined;
|
||||
|
||||
return fixedCharts.map(({ stock }, index) => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -27,7 +27,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
|
||||
// 稳定的事件时间,避免因为格式化导致的重复请求
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(times) && times.length > 0) {
|
||||
try {
|
||||
const eventMinute = moment(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const parseMinuteTime = (timeStr) => {
|
||||
const [h, m] = String(timeStr).split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Button } from 'antd';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -31,7 +31,7 @@ const StockTable = ({
|
||||
|
||||
// 稳定的事件时间,避免重复渲染
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 切换行展开状态
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../../../../services/eventService';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -19,7 +19,7 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
|
||||
const date = eventTime ? dayjs(eventTime).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}|${chartType}`;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const shouldRefreshData = (cacheKey) => {
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
// 如果是今天的数据且交易时间内,允许更频繁的更新
|
||||
const today = moment().format('YYYY-MM-DD');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isToday = cacheKey.includes(today);
|
||||
const currentHour = new Date().getHours();
|
||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
||||
@@ -76,7 +76,7 @@ export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline
|
||||
|
||||
// 3. 发起新请求
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
|
||||
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, chartType, normalizedEventTime)
|
||||
.then((res) => {
|
||||
|
||||
@@ -1,898 +0,0 @@
|
||||
// src/views/Community/components/UnifiedSearchBox.js
|
||||
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined, CloseCircleOutlined, StockOutlined
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
|
||||
import { stockService } from '../../../services/stockService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import PopularKeywords from './PopularKeywords';
|
||||
import TradingTimeFilter from './TradingTimeFilter';
|
||||
|
||||
const { Option } = AntSelect;
|
||||
|
||||
const UnifiedSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
popularKeywords = [],
|
||||
filters = {},
|
||||
mode, // 显示模式(如:vertical, horizontal 等)
|
||||
pageSize, // 每页显示数量
|
||||
trackingFunctions = {} // PostHog 追踪函数集合
|
||||
}) => {
|
||||
|
||||
// 其他状态
|
||||
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
|
||||
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
|
||||
const [industryValue, setIndustryValue] = useState([]);
|
||||
|
||||
// 筛选条件状态
|
||||
const [sort, setSort] = useState('new'); // 排序方式
|
||||
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
|
||||
const [tradingTimeRange, setTradingTimeRange] = useState(null); // 交易时段筛选
|
||||
|
||||
// ✅ 本地输入状态 - 管理用户的实时输入
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 使用 Redux 获取行业数据
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
|
||||
// 加载行业数据函数
|
||||
const loadIndustryData = useCallback(() => {
|
||||
if (!industryData) {
|
||||
dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// 搜索触发函数
|
||||
const triggerSearch = useCallback((params) => {
|
||||
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
|
||||
params: params,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
onSearch(params);
|
||||
}, [onSearch]);
|
||||
|
||||
// ✅ 创建防抖的搜索函数(300ms 延迟)
|
||||
const debouncedSearchRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
|
||||
debouncedSearchRef.current = debounce((params) => {
|
||||
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
|
||||
params: params,
|
||||
delayMs: 300
|
||||
});
|
||||
triggerSearch(params);
|
||||
}, 300);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
};
|
||||
}, [triggerSearch]);
|
||||
|
||||
// 加载所有股票数据
|
||||
useEffect(() => {
|
||||
const loadStocks = async () => {
|
||||
const response = await stockService.getAllStocks();
|
||||
if (response.success && response.data) {
|
||||
setAllStocks(response.data);
|
||||
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
|
||||
count: response.data.length
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadStocks();
|
||||
}, []);
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await loadIndustryData();
|
||||
}
|
||||
};
|
||||
|
||||
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
|
||||
// 辅助函数:递归查找行业代码的完整路径
|
||||
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
for (const item of data) {
|
||||
const newPath = [...currentPath, item.value];
|
||||
|
||||
if (item.value === targetCode) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
const found = findIndustryPath(targetCode, item.children, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// ✅ 从 props.filters 初始化筛选条件和输入框值
|
||||
useEffect(() => {
|
||||
if (!filters) return;
|
||||
|
||||
// 初始化排序
|
||||
if (filters.sort) setSort(filters.sort);
|
||||
|
||||
// 初始化重要性(字符串解析为数组)
|
||||
if (filters.importance) {
|
||||
const importanceArray = filters.importance === 'all'
|
||||
? [] // 'all' 对应空数组(不显示任何选中)
|
||||
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
|
||||
setImportance(importanceArray);
|
||||
logger.debug('UnifiedSearchBox', '初始化重要性', {
|
||||
filters_importance: filters.importance,
|
||||
importanceArray
|
||||
});
|
||||
} else {
|
||||
setImportance([]);
|
||||
}
|
||||
|
||||
// ✅ 初始化行业分类(需要 industryData 加载完成)
|
||||
// ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
|
||||
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
|
||||
const path = findIndustryPath(filters.industry_code, industryData);
|
||||
if (path) {
|
||||
setIndustryValue(path);
|
||||
logger.debug('UnifiedSearchBox', '初始化行业分类', {
|
||||
industry_code: filters.industry_code,
|
||||
path
|
||||
});
|
||||
}
|
||||
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
|
||||
// 如果 filters 中没有行业代码,但本地有值,清空本地值
|
||||
setIndustryValue([]);
|
||||
logger.debug('UnifiedSearchBox', '清空行业分类(filters中无值)');
|
||||
}
|
||||
|
||||
// ✅ 同步 filters.q 到输入框显示值
|
||||
if (filters.q) {
|
||||
setInputValue(filters.q);
|
||||
} else if (!filters.q) {
|
||||
// 如果 filters 中没有搜索关键词,清空输入框
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
// ✅ 初始化时间筛选(从 filters 中恢复)
|
||||
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
||||
|
||||
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
||||
// 根据参数推断按钮 key
|
||||
let inferredKey = 'custom';
|
||||
let inferredLabel = '';
|
||||
|
||||
if (filters.recent_days) {
|
||||
// 推断是否是预设按钮
|
||||
if (filters.recent_days === '7') {
|
||||
inferredKey = 'week';
|
||||
inferredLabel = '近一周';
|
||||
} else if (filters.recent_days === '30') {
|
||||
inferredKey = 'month';
|
||||
inferredLabel = '近一月';
|
||||
} else {
|
||||
inferredLabel = `近${filters.recent_days}天`;
|
||||
}
|
||||
} else if (filters.start_date && filters.end_date) {
|
||||
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
|
||||
}
|
||||
|
||||
// 从 filters 重建 tradingTimeRange 状态
|
||||
const timeRange = {
|
||||
start_date: filters.start_date || '',
|
||||
end_date: filters.end_date || '',
|
||||
recent_days: filters.recent_days || '',
|
||||
label: inferredLabel,
|
||||
key: inferredKey
|
||||
};
|
||||
setTradingTimeRange(timeRange);
|
||||
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
|
||||
filters_time: {
|
||||
start_date: filters.start_date,
|
||||
end_date: filters.end_date,
|
||||
recent_days: filters.recent_days
|
||||
},
|
||||
tradingTimeRange: timeRange
|
||||
});
|
||||
} else if (!hasTimeInFilters && tradingTimeRange) {
|
||||
// 如果 filters 中没有时间参数,但本地有值,清空本地值
|
||||
setTradingTimeRange(null);
|
||||
logger.debug('UnifiedSearchBox', '清空时间筛选(filters中无值)');
|
||||
}
|
||||
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
|
||||
|
||||
// AutoComplete 搜索股票(模糊匹配 code 或 name)
|
||||
const handleSearch = (value) => {
|
||||
if (!value || !allStocks || allStocks.length === 0) {
|
||||
setStockOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 stockService 进行模糊搜索
|
||||
const results = stockService.fuzzySearch(value, allStocks, 10);
|
||||
|
||||
// 转换为 AutoComplete 选项格式
|
||||
const options = results.map(stock => ({
|
||||
value: stock.code,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StockOutlined style={{ color: '#1890ff' }} />
|
||||
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
|
||||
<span style={{ color: '#666' }}>{stock.name}</span>
|
||||
</div>
|
||||
),
|
||||
// 保存完整的股票信息,用于选中后显示
|
||||
stockInfo: stock
|
||||
}));
|
||||
|
||||
setStockOptions(options);
|
||||
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
|
||||
query: value,
|
||||
resultCount: options.length
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
|
||||
const handleStockSelect = (_value, option) => {
|
||||
const stockInfo = option.stockInfo;
|
||||
if (stockInfo) {
|
||||
logger.debug('UnifiedSearchBox', '选中股票', {
|
||||
code: stockInfo.code,
|
||||
name: stockInfo.name
|
||||
});
|
||||
|
||||
// 🎯 追踪股票点击
|
||||
if (trackingFunctions.trackRelatedStockClicked) {
|
||||
trackingFunctions.trackRelatedStockClicked({
|
||||
stockCode: stockInfo.code,
|
||||
stockName: stockInfo.name,
|
||||
source: 'search_box_autocomplete',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新输入框显示
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
|
||||
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: stockInfo.code, // 使用股票代码作为搜索关键词
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 重要性变化(立即执行)- 支持多选
|
||||
const handleImportanceChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '重要性值改变', {
|
||||
oldValue: importance,
|
||||
newValue: value
|
||||
});
|
||||
|
||||
setImportance(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 转换为逗号分隔字符串传给后端(空数组表示"全部")
|
||||
const importanceStr = value.length === 0 ? 'all' : value.join(',');
|
||||
|
||||
// 🎯 追踪筛选操作
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'importance',
|
||||
filterValue: importanceStr,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ importance: importanceStr });
|
||||
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
|
||||
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 排序变化(立即触发搜索)
|
||||
const handleSortChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '排序值改变', {
|
||||
oldValue: sort,
|
||||
newValue: value
|
||||
});
|
||||
setSort(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪排序操作
|
||||
if (trackingFunctions.trackNewsSorted) {
|
||||
trackingFunctions.trackNewsSorted({
|
||||
sortBy: value,
|
||||
previousSortBy: sort,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ sort: value });
|
||||
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 行业分类变化(立即触发搜索)
|
||||
const handleIndustryChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '行业分类值改变', {
|
||||
oldValue: industryValue,
|
||||
newValue: value
|
||||
});
|
||||
setIndustryValue(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪行业筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'industry',
|
||||
filterValue: value?.[value.length - 1] || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({
|
||||
industry_code: value?.[value.length - 1] || ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
|
||||
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
|
||||
const handleKeywordClick = (keyword) => {
|
||||
// 更新输入框显示
|
||||
setInputValue(keyword);
|
||||
|
||||
// 立即触发搜索(取消之前的防抖)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪热门关键词点击
|
||||
if (trackingFunctions.trackNewsSearched) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: keyword,
|
||||
searchType: 'popular_keyword',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: keyword,
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
|
||||
keyword,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 交易时段筛选变化(立即触发搜索)
|
||||
const handleTradingTimeChange = (timeConfig) => {
|
||||
if (!timeConfig) {
|
||||
// 清空筛选
|
||||
setTradingTimeRange(null);
|
||||
|
||||
// 🎯 追踪时间筛选清空
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: 'cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
return;
|
||||
}
|
||||
|
||||
const { range, type, label, key } = timeConfig;
|
||||
let params = {};
|
||||
|
||||
if (type === 'recent_days') {
|
||||
// 近一周/近一月使用 recent_days
|
||||
params.recent_days = range;
|
||||
params.start_date = '';
|
||||
params.end_date = '';
|
||||
} else {
|
||||
// 其他使用 start_date + end_date
|
||||
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.recent_days = '';
|
||||
}
|
||||
|
||||
setTradingTimeRange({ ...params, label, key });
|
||||
|
||||
// 🎯 追踪时间筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: label,
|
||||
timeRangeType: type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const searchParams = buildFilterParams({ ...params, mode });
|
||||
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
|
||||
timeConfig,
|
||||
params: searchParams
|
||||
});
|
||||
triggerSearch(searchParams);
|
||||
};
|
||||
|
||||
// 主搜索(点击搜索按钮或回车)
|
||||
const handleMainSearch = () => {
|
||||
// 取消之前的防抖
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
|
||||
industry_code: ''
|
||||
});
|
||||
|
||||
// 🎯 追踪搜索操作
|
||||
if (trackingFunctions.trackNewsSearched && inputValue) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: inputValue,
|
||||
searchType: 'main_search',
|
||||
filters: params,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('UnifiedSearchBox', '主搜索触发', {
|
||||
inputValue,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 处理输入变化 - 更新本地输入状态
|
||||
const handleInputChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '输入变化', { value });
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
|
||||
const buildFilterParams = useCallback((overrides = {}) => {
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
|
||||
overrides: overrides,
|
||||
currentState: {
|
||||
sort,
|
||||
importance,
|
||||
industryValue,
|
||||
'filters.q': filters.q,
|
||||
mode,
|
||||
pageSize
|
||||
}
|
||||
});
|
||||
|
||||
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
|
||||
const sortValue = overrides.sort ?? sort;
|
||||
let actualSort = sortValue;
|
||||
let returnType;
|
||||
|
||||
if (sortValue === 'returns_avg') {
|
||||
actualSort = 'returns';
|
||||
returnType = 'avg';
|
||||
} else if (sortValue === 'returns_week') {
|
||||
actualSort = 'returns';
|
||||
returnType = 'week';
|
||||
}
|
||||
|
||||
// 处理重要性参数:数组转换为逗号分隔字符串
|
||||
let importanceValue = overrides.importance ?? importance;
|
||||
if (Array.isArray(importanceValue)) {
|
||||
importanceValue = importanceValue.length === 0
|
||||
? 'all'
|
||||
: importanceValue.join(',');
|
||||
}
|
||||
|
||||
const result = {
|
||||
// 基础参数(overrides 优先级高于本地状态)
|
||||
sort: actualSort,
|
||||
importance: importanceValue,
|
||||
|
||||
|
||||
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
|
||||
// 交易时段筛选参数
|
||||
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
|
||||
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
|
||||
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
|
||||
|
||||
// 最终 overrides 具有最高优先级
|
||||
...overrides,
|
||||
page: 1,
|
||||
per_page: overrides.mode === 'four-row' ? 30: 10
|
||||
};
|
||||
|
||||
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
|
||||
delete result.per_page;
|
||||
|
||||
// 添加 return_type 参数(如果需要)
|
||||
if (returnType) {
|
||||
result.return_type = returnType;
|
||||
}
|
||||
|
||||
// 添加 mode 和 per_page 参数(如果提供了的话)
|
||||
if (mode !== undefined && mode !== null) {
|
||||
result.mode = mode;
|
||||
}
|
||||
if (pageSize !== undefined && pageSize !== null) {
|
||||
result.per_page = pageSize; // 后端实际使用的参数
|
||||
}
|
||||
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
|
||||
return result;
|
||||
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
|
||||
|
||||
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
|
||||
const handleReset = () => {
|
||||
console.log('%c🔄 [重置] 开始重置筛选条件', 'color: #FF4D4F; font-weight: bold;');
|
||||
|
||||
// 重置所有筛选器状态
|
||||
setInputValue(''); // 清空输入框
|
||||
setStockOptions([]);
|
||||
setIndustryValue([]);
|
||||
setSort('new');
|
||||
setImportance([]); // 改为空数组
|
||||
setTradingTimeRange(null); // 清空交易时段筛选
|
||||
|
||||
// 🎯 追踪筛选重置
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'reset',
|
||||
filterValue: 'all_filters_cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 输出重置后的完整参数
|
||||
const resetParams = {
|
||||
q: '',
|
||||
industry_code: '',
|
||||
sort: 'new',
|
||||
importance: 'all', // 传给后端时转为'all'
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: '',
|
||||
page: 1,
|
||||
_forceRefresh: Date.now() // 添加强制刷新标志,确保每次重置都触发更新
|
||||
};
|
||||
|
||||
console.log('%c🔄 [重置] 重置参数', 'color: #FF4D4F;', resetParams);
|
||||
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
|
||||
|
||||
console.log('%c🔄 [重置] 调用 onSearch', 'color: #FF4D4F;', typeof onSearch);
|
||||
onSearch(resetParams);
|
||||
|
||||
console.log('%c✅ [重置] 重置完成', 'color: #52C41A; font-weight: bold;');
|
||||
};
|
||||
|
||||
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
|
||||
const filterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
|
||||
// 搜索关键词标签 - 从 filters.q 读取
|
||||
if (filters.q) {
|
||||
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
|
||||
}
|
||||
|
||||
// 行业标签
|
||||
if (industryValue && industryValue.length > 0 && industryData) {
|
||||
// 递归查找每个层级的 label
|
||||
const findLabel = (code, data) => {
|
||||
for (const item of data) {
|
||||
if (code.startsWith(item.value)) {
|
||||
if (item.value === code) {
|
||||
return item.label;
|
||||
} else {
|
||||
return findLabel(code, item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 只显示最后一级的 label
|
||||
const lastLevelCode = industryValue[industryValue.length - 1];
|
||||
const lastLevelLabel = findLabel(lastLevelCode, industryData);
|
||||
|
||||
tags.push({
|
||||
key: 'industry',
|
||||
label: `行业: ${lastLevelLabel}`
|
||||
});
|
||||
}
|
||||
|
||||
// 交易时段筛选标签
|
||||
if (tradingTimeRange?.label) {
|
||||
tags.push({
|
||||
key: 'trading_time',
|
||||
label: `时间: ${tradingTimeRange.label}`
|
||||
});
|
||||
}
|
||||
|
||||
// 重要性标签(多选合并显示为单个标签)
|
||||
if (importance && importance.length > 0) {
|
||||
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
|
||||
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
|
||||
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
|
||||
}
|
||||
|
||||
// 排序标签(排除默认值 'new')
|
||||
if (sort && sort !== 'new') {
|
||||
let sortLabel;
|
||||
if (sort === 'hot') sortLabel = '最热';
|
||||
else if (sort === 'importance') sortLabel = '重要性';
|
||||
else if (sort === 'returns_avg') sortLabel = '平均收益率';
|
||||
else if (sort === 'returns_week') sortLabel = '周收益率';
|
||||
else sortLabel = sort;
|
||||
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}, [filters.q, industryValue, importance, sort, tradingTimeRange]);
|
||||
|
||||
// ✅ 移除单个标签 - 构建新参数并触发搜索
|
||||
const handleRemoveTag = (key) => {
|
||||
logger.debug('UnifiedSearchBox', '移除标签', { key });
|
||||
|
||||
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
if (key === 'search') {
|
||||
// 清除搜索关键词和输入框,立即触发搜索
|
||||
setInputValue(''); // 清空输入框
|
||||
const params = buildFilterParams({ q: '' });
|
||||
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'industry') {
|
||||
// 清除行业选择
|
||||
setIndustryValue([]);
|
||||
const params = buildFilterParams({ industry_code: '' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'trading_time') {
|
||||
// 清除交易时段筛选
|
||||
setTradingTimeRange(null);
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
} else if (key === 'importance') {
|
||||
// 重置重要性为空数组(传给后端为'all')
|
||||
setImportance([]);
|
||||
const params = buildFilterParams({ importance: 'all' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'sort') {
|
||||
// 重置排序为默认值
|
||||
setSort('new');
|
||||
const params = buildFilterParams({ sort: 'new' });
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{padding: '8px'}}>
|
||||
{/* 第三行:行业 + 重要性 + 排序 */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
|
||||
{/* 左侧:筛选器组 */}
|
||||
<Space size="small" wrap>
|
||||
<span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
|
||||
{/* 行业分类 */}
|
||||
<Cascader
|
||||
value={industryValue}
|
||||
onChange={handleIndustryChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
options={industryData || []}
|
||||
placeholder="行业分类"
|
||||
changeOnSelect
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
}}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels.join(' > ')}
|
||||
disabled={industryLoading}
|
||||
style={{ width: 160 }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* 重要性 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
|
||||
<AntSelect
|
||||
mode="multiple"
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
placeholder="全部"
|
||||
maxTagCount={3}
|
||||
>
|
||||
<Option value="S">极高</Option>
|
||||
<Option value="A">高</Option>
|
||||
<Option value="B">中</Option>
|
||||
<Option value="C">低</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
{/* 搜索图标(可点击) + 搜索框 */}
|
||||
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
|
||||
<SearchOutlined
|
||||
onClick={handleMainSearch}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
padding: '5px 8px',
|
||||
background: '#e6f7ff',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#1890ff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#096dd9';
|
||||
e.currentTarget.style.background = '#bae7ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#1890ff';
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
}}
|
||||
/>
|
||||
<AutoComplete
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onFocus={onSearchFocus}
|
||||
options={stockOptions}
|
||||
placeholder="请输入股票代码/股票名称/相关话题"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMainSearch();
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="small"
|
||||
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||
/>
|
||||
</Space.Compact>
|
||||
|
||||
{/* 重置按钮 - 现代化设计 */}
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleReset}
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
backgroundColor: '#fff',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
padding: '4px 10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff4d4f';
|
||||
e.currentTarget.style.color = '#ff4d4f';
|
||||
e.currentTarget.style.backgroundColor = '#fff1f0';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.backgroundColor = '#fff';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* 右侧:排序 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>排序:</span>
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">最热</Option>
|
||||
<Option value="importance">重要性</Option>
|
||||
<Option value="returns_avg">平均收益率</Option>
|
||||
<Option value="returns_week">周收益率</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
|
||||
<Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
|
||||
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
|
||||
|
||||
{/* 交易时段筛选 */}
|
||||
<TradingTimeFilter
|
||||
value={tradingTimeRange?.key || null}
|
||||
onChange={handleTradingTimeChange}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 第二行:热门概念 */}
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<PopularKeywords
|
||||
keywords={popularKeywords}
|
||||
onKeywordClick={handleKeywordClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedSearchBox;
|
||||
@@ -1,10 +1,12 @@
|
||||
// src/views/Community/hooks/useCommunityEvents.js
|
||||
// 新闻催化分析页面事件追踪 Hook
|
||||
// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { smartTrack } from '@/utils/trackingHelpers';
|
||||
|
||||
/**
|
||||
* 新闻催化分析(Community)事件追踪 Hook
|
||||
@@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger';
|
||||
export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
// 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪)
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useCommunityEvents', '📰 Community Page Viewed');
|
||||
@@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} params.industryFilter - 行业筛选
|
||||
*/
|
||||
const trackNewsListViewed = useCallback((params = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
total_count: params.totalCount || 0,
|
||||
sort_by: params.sortBy || 'new',
|
||||
importance_filter: params.importance || 'all',
|
||||
@@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
tab_name: tabName,
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} filters.industryCode - 行业代码
|
||||
*/
|
||||
const trackNewsFilterApplied = useCallback((filters = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
importance: filters.importance || 'all',
|
||||
date_range: filters.dateRange || 'all',
|
||||
industry_classification: filters.industryClassification || 'all',
|
||||
@@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_SORTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, {
|
||||
sort_by: sortBy,
|
||||
previous_sort: previousSort,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const trackNewsSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
@@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
// 如果没有搜索结果,额外追踪(高优先级,立即发送)
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'community_news',
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'news_related_stocks',
|
||||
@@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_code: concept.code,
|
||||
concept_name: concept.name || '',
|
||||
source: 'news_related_concepts',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
fetchHotEvents
|
||||
} from '../../store/slices/communityDataSlice';
|
||||
} from '@/store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useNotification } from '@/contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme';
|
||||
import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
@@ -96,6 +97,15 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化)
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && !loading) {
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function CenterDashboard() {
|
||||
const [w, e, c] = await Promise.all([
|
||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
fetch(base + `/api/account/events/comments?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
]);
|
||||
const jw = await w.json();
|
||||
const je = await e.json();
|
||||
|
||||
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* CalendarPanel - 投资日历面板组件
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { DateClickArg } from '@fullcalendar/interaction';
|
||||
import { EventClickArg } from '@fullcalendar/common';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 新事件表单数据类型
|
||||
*/
|
||||
interface NewEventForm {
|
||||
title: string;
|
||||
description: string;
|
||||
type: EventType;
|
||||
importance: number;
|
||||
stocks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FullCalendar 事件类型
|
||||
*/
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
date: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
extendedProps: InvestmentEvent & {
|
||||
isSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarPanel 组件
|
||||
* 日历视图面板,显示所有投资事件
|
||||
*/
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setActiveTab,
|
||||
toast,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [newEvent, setNewEvent] = useState<NewEventForm>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 转换数据为 FullCalendar 格式
|
||||
const calendarEvents: CalendarEvent[] = allEvents.map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info: DateClickArg): void => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info: EventClickArg): void => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('CalendarPanel', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadAllData();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除事件
|
||||
const handleDeleteEvent = async (eventId: number): Promise<void> => {
|
||||
if (!eventId) {
|
||||
logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('CalendarPanel', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到计划或复盘标签页
|
||||
const handleViewDetails = (event: InvestmentEvent): void => {
|
||||
if (event.type === 'plan') {
|
||||
setActiveTab(1); // 跳转到"我的计划"标签页
|
||||
} else if (event.type === 'review') {
|
||||
setActiveTab(2); // 跳转到"我的复盘"标签页
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
if (!selectedDate) setSelectedDate(dayjs());
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={calendarEvents}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.source === 'future' ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : event.type === 'plan' ? (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="green" variant="subtle">我的复盘</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{event.importance && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.importance}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack>
|
||||
{!event.source || event.source === 'user' ? (
|
||||
<>
|
||||
<Tooltip label="查看详情">
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleViewDetails(event)}
|
||||
aria-label="查看详情"
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
aria-label="删除事件"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{event.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="review">投资复盘</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -52,13 +52,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -140,12 +140,12 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
moment(event.start).isSame(clickedDate, 'day')
|
||||
dayjs(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -154,7 +154,7 @@ export default function InvestmentCalendarChakra() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
@@ -173,7 +173,7 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function InvestmentCalendarChakra() {
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
|
||||
@@ -66,13 +66,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import '../components/InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 创建 Context 用于跨标签页共享数据
|
||||
const PlanningDataContext = createContext();
|
||||
@@ -232,11 +232,11 @@ function CalendarPanel() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
moment(event.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -245,11 +245,11 @@ function CalendarPanel() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
moment(ev.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -262,7 +262,7 @@ function CalendarPanel() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -368,7 +368,7 @@ function CalendarPanel() {
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
@@ -619,7 +619,7 @@ function PlansPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -638,13 +638,13 @@ function PlansPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -795,7 +795,7 @@ function PlansPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
@@ -1043,7 +1043,7 @@ function ReviewsPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1062,13 +1062,13 @@ function ReviewsPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1205,7 +1205,7 @@ function ReviewsPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件,~200 行)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - PlansPanel (计划面板,懒加载)
|
||||
* - ReviewsPanel (复盘面板,懒加载)
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiFileText,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
const CalendarPanel = lazy(() =>
|
||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||
);
|
||||
const PlansPanel = lazy(() =>
|
||||
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
|
||||
);
|
||||
const ReviewsPanel = lazy(() =>
|
||||
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
|
||||
);
|
||||
|
||||
/**
|
||||
* 面板加载占位符
|
||||
*/
|
||||
const PanelLoadingFallback: React.FC = () => (
|
||||
<Center py={12}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* InvestmentPlanningCenter 主组件
|
||||
*/
|
||||
const InvestmentPlanningCenter: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
*/
|
||||
const loadAllData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllEvents(data.data || []);
|
||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = {
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
toast,
|
||||
bgColor,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
};
|
||||
|
||||
// 计算各类型事件数量
|
||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={5} />
|
||||
<Heading size="md">投资规划中心</Heading>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiCalendar} mr={2} />
|
||||
日历视图
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 日历视图面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<PlansPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<ReviewsPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PlanningDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentPlanningCenter;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user