Compare commits
187 Commits
feature
...
origin_pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb8cb78e6 | ||
|
|
8e5623d723 | ||
|
|
57b4841b4c | ||
|
|
9e23b370fe | ||
|
|
34bc3d1d6f | ||
| 7f2a4dd36a | |||
| 45ff13f4d0 | |||
| a00b8bb73d | |||
| 46ba421f42 | |||
| 6cd300b5ae | |||
| 617300ac8f | |||
| 25163789ca | |||
| fbf6813615 | |||
| 800151771c | |||
| 9a723f04f1 | |||
| 2756e6e379 | |||
| 87d8b25768 | |||
| 6228bef5ad | |||
| dff37adbbc | |||
| 2a228c8d6c | |||
| 95eb86c06a | |||
| 6899b9d0d2 | |||
| a8edb8bde3 | |||
| d8dc79d32c | |||
| e29f391f10 | |||
| 30788648af | |||
| c886d78ff6 | |||
| 3a058fd805 | |||
| d1d8d1a25d | |||
| fc5d2058c4 | |||
| 322b1dd845 | |||
|
|
f01eff6eb7 | ||
|
|
4860cac3ca | ||
|
|
207701bbde | ||
|
|
033f29e90c | ||
| bd9fdefdea | |||
| 4dc27a35ff | |||
|
|
0f3219143f | ||
|
|
00aabfacea | ||
|
|
7b49062986 | ||
|
|
52c3e25218 | ||
|
|
4979293320 | ||
| 463ca7cf60 | |||
|
|
b30cbd6c62 | ||
|
|
11789b5ec7 | ||
|
|
63fb8a3aa8 | ||
| 7366769083 | |||
|
|
2da71a3c03 | ||
| a46247f81b | |||
|
|
44b8c64907 | ||
| 315d606945 | |||
|
|
5ceffc53d6 | ||
| 446d8f0870 | |||
| e7ba8c4c2d | |||
| a1c76a257c | |||
|
|
3574f5391f | ||
|
|
fef9087c47 | ||
|
|
b0b42e9d3d | ||
|
|
09f15d2e03 | ||
|
|
a6718e1be5 | ||
|
|
e93e307ad8 | ||
|
|
16d60ef773 | ||
|
|
4d389bcc10 | ||
|
|
c10af30ad4 | ||
|
|
3c060b7aa5 | ||
|
|
72e9456aba | ||
|
|
0e82c96c5a | ||
|
|
9c93843f75 | ||
|
|
184c26d323 | ||
|
|
e80227840a | ||
|
|
e4490b54e0 | ||
| 83cd875690 | |||
| 25d3bf4d95 | |||
| 7adb4ea8af | |||
| 3eff0554f9 | |||
|
|
0e015901ea | ||
| 2a122b0013 | |||
| 663d73609a | |||
| 389a45fc0a | |||
| 67c7fa49e8 | |||
| a3810499cc | |||
| 83c6abdfba | |||
| dcc88251df | |||
|
|
6271736969 | ||
|
|
319a78d34c | ||
|
|
8799964961 | ||
|
|
42808501b0 | ||
|
|
291362b88d | ||
|
|
f5328ec3a1 | ||
|
|
52cf950b21 | ||
|
|
f9b580c871 | ||
|
|
8b25d5d91c | ||
|
|
c6b3b56cb8 | ||
|
|
42f1b2f24e | ||
|
|
935c933cb8 | ||
|
|
f4b58b42cc | ||
|
|
5ff8db8899 | ||
|
|
116594d9b1 | ||
|
|
ca5adb3ad2 | ||
|
|
8eaaef1666 | ||
|
|
ebb737427f | ||
|
|
31e5a4ee48 | ||
|
|
273ff5f72d | ||
|
|
a5e001d975 | ||
|
|
c5d6247f49 | ||
|
|
ad933e9fb2 | ||
|
|
adf6fc7780 | ||
|
|
6930878ff6 | ||
|
|
ed24a14fbf | ||
|
|
25a6ff164b | ||
|
|
612b58c983 | ||
|
|
27b68e928e | ||
|
|
e6ffb0dc74 | ||
|
|
2355004dfb | ||
|
|
c5dcb4897d | ||
|
|
dc0c8e2c60 | ||
|
|
2e89469d05 | ||
|
|
e617eddd46 | ||
|
|
22186eb54a | ||
|
|
c3ef837221 | ||
|
|
870b1f5996 | ||
|
|
bc2a3b71c0 | ||
|
|
ff7b8abe9d | ||
|
|
cb44c18e57 | ||
|
|
623ec73c62 | ||
|
|
4c08ef57ff | ||
|
|
ca52d3bd87 | ||
|
|
62ae2e0803 | ||
|
|
7e781731c4 | ||
|
|
0765f8a800 | ||
|
|
70dbf3b492 | ||
|
|
aa1a93c65b | ||
|
|
f9e4265dd6 | ||
| 1361a2b5b2 | |||
|
|
263ecd77b3 | ||
|
|
b6862aff4f | ||
|
|
327cfc09e2 | ||
|
|
f5d340aa05 | ||
|
|
0da18e868a | ||
|
|
0f7693939a | ||
|
|
becd0268a6 | ||
|
|
8bd7801753 | ||
|
|
d4c731730f | ||
|
|
fe9b3034a1 | ||
|
|
ea0428321b | ||
|
|
d95bd51206 | ||
|
|
69d4b8bae0 | ||
|
|
bf89c0e13e | ||
|
|
4e7fcaad5c | ||
|
|
41baf16d45 | ||
|
|
c5b8fe91c3 | ||
|
|
f919ce255a | ||
|
|
64de7d055b | ||
|
|
b223be2f01 | ||
|
|
188783a8d2 | ||
|
|
d7f27e428b | ||
|
|
f9387ffbd9 | ||
|
|
be0c53b588 | ||
|
|
de1b31c70e | ||
|
|
d96ebd6b8c | ||
|
|
67127aa615 | ||
|
|
e7c495a8b1 | ||
|
|
e0cfa6fab2 | ||
|
|
c51d3811e5 | ||
|
|
8fe13c9fa4 | ||
|
|
e6c422887c | ||
|
|
7e110111c4 | ||
|
|
38d1b51af3 | ||
|
|
c7334191e5 | ||
|
|
7fdc9e26af | ||
|
|
7f01a391e0 | ||
|
|
58db08ca22 | ||
|
|
bf75f9b387 | ||
|
|
2a59e9edb2 | ||
|
|
87476226c3 | ||
|
|
76360102bb | ||
|
|
1a3987afe0 | ||
|
|
a512f3bd7e | ||
|
|
ffa6c2f761 | ||
|
|
64a441b717 | ||
|
|
5b9155a30c | ||
|
|
6e5eaa9089 | ||
| 1ed54d7ee0 | |||
|
|
8ed65b062b | ||
|
|
868b4ccebc | ||
|
|
67981f21a2 | ||
|
|
0a10270ab0 |
Binary file not shown.
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
Binary file not shown.
547
app.py
547
app.py
@@ -706,11 +706,38 @@ class SubscriptionPlan(db.Model):
|
||||
monthly_price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
yearly_price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
features = db.Column(db.Text, nullable=True)
|
||||
pricing_options = db.Column(db.Text, nullable=True) # JSON格式:[{"months": 1, "price": 99}, {"months": 12, "price": 999}]
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
def to_dict(self):
|
||||
# 解析pricing_options(如果存在)
|
||||
pricing_opts = None
|
||||
if self.pricing_options:
|
||||
try:
|
||||
pricing_opts = json.loads(self.pricing_options)
|
||||
except:
|
||||
pricing_opts = None
|
||||
|
||||
# 如果没有pricing_options,则从monthly_price和yearly_price生成默认选项
|
||||
if not pricing_opts:
|
||||
pricing_opts = [
|
||||
{
|
||||
'months': 1,
|
||||
'price': float(self.monthly_price) if self.monthly_price else 0,
|
||||
'label': '月付',
|
||||
'cycle_key': 'monthly'
|
||||
},
|
||||
{
|
||||
'months': 12,
|
||||
'price': float(self.yearly_price) if self.yearly_price else 0,
|
||||
'label': '年付',
|
||||
'cycle_key': 'yearly',
|
||||
'discount_percent': 20 # 年付默认20%折扣
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
@@ -718,6 +745,7 @@ class SubscriptionPlan(db.Model):
|
||||
'description': self.description,
|
||||
'monthly_price': float(self.monthly_price) if self.monthly_price else 0,
|
||||
'yearly_price': float(self.yearly_price) if self.yearly_price else 0,
|
||||
'pricing_options': pricing_opts, # 新增:灵活计费周期选项
|
||||
'features': json.loads(self.features) if self.features else [],
|
||||
'is_active': self.is_active,
|
||||
'sort_order': self.sort_order
|
||||
@@ -776,6 +804,10 @@ class PaymentOrder(db.Model):
|
||||
'plan_name': self.plan_name,
|
||||
'billing_cycle': self.billing_cycle,
|
||||
'amount': float(self.amount) if self.amount else 0,
|
||||
'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None,
|
||||
'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0,
|
||||
'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None,
|
||||
'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False,
|
||||
'qr_code_url': self.qr_code_url,
|
||||
'status': self.status,
|
||||
'is_expired': self.is_expired(),
|
||||
@@ -786,6 +818,107 @@ class PaymentOrder(db.Model):
|
||||
}
|
||||
|
||||
|
||||
class PromoCode(db.Model):
|
||||
"""优惠码表"""
|
||||
__tablename__ = 'promo_codes'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
description = db.Column(db.String(200), nullable=True)
|
||||
|
||||
# 折扣类型和值
|
||||
discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount'
|
||||
discount_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
# 适用范围
|
||||
applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式
|
||||
applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式
|
||||
min_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
|
||||
# 使用限制
|
||||
max_uses = db.Column(db.Integer, nullable=True)
|
||||
max_uses_per_user = db.Column(db.Integer, default=1)
|
||||
current_uses = db.Column(db.Integer, default=0)
|
||||
|
||||
# 有效期
|
||||
valid_from = db.Column(db.DateTime, nullable=False)
|
||||
valid_until = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# 状态
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_by = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'discount_type': self.discount_type,
|
||||
'discount_value': float(self.discount_value) if self.discount_value else 0,
|
||||
'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None,
|
||||
'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None,
|
||||
'min_amount': float(self.min_amount) if self.min_amount else None,
|
||||
'max_uses': self.max_uses,
|
||||
'max_uses_per_user': self.max_uses_per_user,
|
||||
'current_uses': self.current_uses,
|
||||
'valid_from': self.valid_from.isoformat() if self.valid_from else None,
|
||||
'valid_until': self.valid_until.isoformat() if self.valid_until else None,
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
|
||||
class PromoCodeUsage(db.Model):
|
||||
"""优惠码使用记录表"""
|
||||
__tablename__ = 'promo_code_usage'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
|
||||
|
||||
original_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
discount_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
final_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
used_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 关系
|
||||
promo_code = db.relationship('PromoCode', backref='usages')
|
||||
order = db.relationship('PaymentOrder', backref='promo_usage')
|
||||
|
||||
|
||||
class SubscriptionUpgrade(db.Model):
|
||||
"""订阅升级/降级记录表"""
|
||||
__tablename__ = 'subscription_upgrades'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
|
||||
|
||||
# 原订阅信息
|
||||
from_plan = db.Column(db.String(20), nullable=False)
|
||||
from_cycle = db.Column(db.String(10), nullable=False)
|
||||
from_end_date = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# 新订阅信息
|
||||
to_plan = db.Column(db.String(20), nullable=False)
|
||||
to_cycle = db.Column(db.String(10), nullable=False)
|
||||
to_end_date = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# 价格计算
|
||||
remaining_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
actual_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both'
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 关系
|
||||
order = db.relationship('PaymentOrder', backref='upgrade_record')
|
||||
|
||||
|
||||
# ============================================
|
||||
# 模拟盘相关模型
|
||||
# ============================================
|
||||
@@ -982,8 +1115,15 @@ def get_user_subscription_safe(user_id):
|
||||
return DefaultSub()
|
||||
|
||||
|
||||
def activate_user_subscription(user_id, plan_type, billing_cycle):
|
||||
"""激活用户订阅"""
|
||||
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: 是否从当前时间开始延长(用于升级场景)
|
||||
"""
|
||||
try:
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
@@ -993,7 +1133,9 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
|
||||
subscription.subscription_type = plan_type
|
||||
subscription.subscription_status = 'active'
|
||||
subscription.billing_cycle = billing_cycle
|
||||
subscription.start_date = beijing_now()
|
||||
|
||||
if not extend_from_now or not subscription.start_date:
|
||||
subscription.start_date = beijing_now()
|
||||
|
||||
if billing_cycle == 'monthly':
|
||||
subscription.end_date = beijing_now() + timedelta(days=30)
|
||||
@@ -1007,6 +1149,195 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
|
||||
return None
|
||||
|
||||
|
||||
def validate_promo_code(code, plan_name, billing_cycle, amount, user_id):
|
||||
"""验证优惠码
|
||||
|
||||
Returns:
|
||||
tuple: (promo_code_obj, error_message)
|
||||
"""
|
||||
try:
|
||||
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
|
||||
|
||||
if not promo:
|
||||
return None, "优惠码不存在或已失效"
|
||||
|
||||
# 检查有效期
|
||||
now = beijing_now()
|
||||
if now < promo.valid_from:
|
||||
return None, "优惠码尚未生效"
|
||||
if now > promo.valid_until:
|
||||
return None, "优惠码已过期"
|
||||
|
||||
# 检查使用次数
|
||||
if promo.max_uses and promo.current_uses >= promo.max_uses:
|
||||
return None, "优惠码已被使用完"
|
||||
|
||||
# 检查每用户使用次数
|
||||
if promo.max_uses_per_user:
|
||||
user_usage_count = PromoCodeUsage.query.filter_by(
|
||||
promo_code_id=promo.id,
|
||||
user_id=user_id
|
||||
).count()
|
||||
if user_usage_count >= promo.max_uses_per_user:
|
||||
return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
|
||||
|
||||
# 检查适用套餐
|
||||
if promo.applicable_plans:
|
||||
try:
|
||||
applicable = json.loads(promo.applicable_plans)
|
||||
if plan_name not in applicable:
|
||||
return None, "该优惠码不适用于此套餐"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查适用周期
|
||||
if promo.applicable_cycles:
|
||||
try:
|
||||
applicable = json.loads(promo.applicable_cycles)
|
||||
if billing_cycle not in applicable:
|
||||
return None, "该优惠码不适用于此计费周期"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查最低消费
|
||||
if promo.min_amount and amount < float(promo.min_amount):
|
||||
return None, f"需满{float(promo.min_amount):.2f}元才可使用此优惠码"
|
||||
|
||||
return promo, None
|
||||
except Exception as e:
|
||||
return None, f"验证优惠码时出错: {str(e)}"
|
||||
|
||||
|
||||
def calculate_discount(promo_code, amount):
|
||||
"""计算优惠金额"""
|
||||
try:
|
||||
if promo_code.discount_type == 'percentage':
|
||||
discount = amount * (float(promo_code.discount_value) / 100)
|
||||
else: # fixed_amount
|
||||
discount = float(promo_code.discount_value)
|
||||
|
||||
# 确保折扣不超过总金额
|
||||
return min(discount, amount)
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_remaining_value(subscription, current_plan):
|
||||
"""计算当前订阅的剩余价值"""
|
||||
try:
|
||||
if not subscription or not subscription.end_date:
|
||||
return 0
|
||||
|
||||
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):
|
||||
"""计算升级所需价格
|
||||
|
||||
Returns:
|
||||
dict: 包含价格计算结果的字典
|
||||
"""
|
||||
try:
|
||||
# 1. 获取当前订阅
|
||||
current_sub = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
|
||||
# 2. 获取目标套餐
|
||||
to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first()
|
||||
if not to_plan:
|
||||
return {'error': '目标套餐不存在'}
|
||||
|
||||
# 3. 计算目标套餐价格
|
||||
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
|
||||
|
||||
# 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 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
|
||||
|
||||
return result
|
||||
|
||||
# 5. 升级场景:计算剩余价值
|
||||
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
|
||||
if not current_plan:
|
||||
return {'error': '当前套餐信息不存在'}
|
||||
|
||||
remaining_value = calculate_remaining_value(current_sub, current_plan)
|
||||
|
||||
# 6. 计算升级差价
|
||||
upgrade_amount = max(0, new_price - remaining_value)
|
||||
|
||||
# 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'
|
||||
|
||||
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,
|
||||
'discount_amount': 0,
|
||||
'final_amount': upgrade_amount,
|
||||
'promo_code': 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)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, upgrade_amount)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = upgrade_amount - discount
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def initialize_subscription_plans_safe():
|
||||
"""安全地初始化订阅套餐"""
|
||||
try:
|
||||
@@ -1112,16 +1443,24 @@ def get_subscription_plans():
|
||||
'data': [plan.to_dict() for plan in plans]
|
||||
})
|
||||
except Exception as e:
|
||||
# 返回默认套餐
|
||||
# 返回默认套餐(包含pricing_options以兼容新前端)
|
||||
default_plans = [
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'pro',
|
||||
'display_name': 'Pro版本',
|
||||
'description': '适合个人投资者的基础功能套餐',
|
||||
'monthly_price': 0.01,
|
||||
'yearly_price': 0.08,
|
||||
'features': ['基础股票分析工具', '历史数据查询', '基础财务报表'],
|
||||
'monthly_price': 198,
|
||||
'yearly_price': 2000,
|
||||
'pricing_options': [
|
||||
{'months': 1, 'price': 198, 'label': '月付', 'cycle_key': 'monthly'},
|
||||
{'months': 3, 'price': 534, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10},
|
||||
{'months': 6, 'price': 950, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20},
|
||||
{'months': 12, 'price': 2000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 16},
|
||||
{'months': 24, 'price': 3600, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 24},
|
||||
{'months': 36, 'price': 5040, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 29}
|
||||
],
|
||||
'features': ['基础股票分析工具', '历史数据查询', '基础财务报表', '简单投资计划记录', '标准客服支持'],
|
||||
'is_active': True,
|
||||
'sort_order': 1
|
||||
},
|
||||
@@ -1130,9 +1469,17 @@ def get_subscription_plans():
|
||||
'name': 'max',
|
||||
'display_name': 'Max版本',
|
||||
'description': '适合专业投资者的全功能套餐',
|
||||
'monthly_price': 0.1,
|
||||
'yearly_price': 0.8,
|
||||
'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送'],
|
||||
'monthly_price': 998,
|
||||
'yearly_price': 10000,
|
||||
'pricing_options': [
|
||||
{'months': 1, 'price': 998, 'label': '月付', 'cycle_key': 'monthly'},
|
||||
{'months': 3, 'price': 2695, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10},
|
||||
{'months': 6, 'price': 4790, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20},
|
||||
{'months': 12, 'price': 10000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 17},
|
||||
{'months': 24, 'price': 18000, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 25},
|
||||
{'months': 36, 'price': 25200, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 30}
|
||||
],
|
||||
'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送', 'API访问', '优先客服支持'],
|
||||
'is_active': True,
|
||||
'sort_order': 2
|
||||
}
|
||||
@@ -1189,9 +1536,90 @@ def get_subscription_info():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/promo-code/validate', methods=['POST'])
|
||||
def validate_promo_code_api():
|
||||
"""验证优惠码"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
code = data.get('code', '').strip()
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
amount = data.get('amount', 0)
|
||||
|
||||
if not code or not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 验证优惠码
|
||||
promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id'])
|
||||
|
||||
if error:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'valid': False,
|
||||
'error': error
|
||||
})
|
||||
|
||||
# 计算折扣
|
||||
discount_amount = calculate_discount(promo, amount)
|
||||
final_amount = amount - discount_amount
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'valid': True,
|
||||
'promo_code': promo.to_dict(),
|
||||
'discount_amount': discount_amount,
|
||||
'final_amount': final_amount
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'验证失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/subscription/calculate-price', methods=['POST'])
|
||||
def calculate_subscription_price():
|
||||
"""计算订阅价格(支持升级和优惠码)"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result['error']
|
||||
}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'计算失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/payment/create-order', methods=['POST'])
|
||||
def create_payment_order():
|
||||
"""创建支付订单"""
|
||||
"""创建支付订单(支持升级和优惠码)"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
@@ -1199,23 +1627,21 @@ 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
|
||||
|
||||
if not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 获取套餐信息
|
||||
try:
|
||||
plan = SubscriptionPlan.query.filter_by(name=plan_name, is_active=True).first()
|
||||
if not plan:
|
||||
# 如果表不存在,使用默认价格
|
||||
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
|
||||
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
|
||||
else:
|
||||
amount = plan.monthly_price if billing_cycle == 'monthly' else plan.yearly_price
|
||||
except:
|
||||
# 默认价格
|
||||
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
|
||||
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
|
||||
# 计算价格(包括升级和优惠码)
|
||||
price_result = calculate_upgrade_price(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)
|
||||
|
||||
# 创建订单
|
||||
try:
|
||||
@@ -1225,10 +1651,52 @@ def create_payment_order():
|
||||
billing_cycle=billing_cycle,
|
||||
amount=amount
|
||||
)
|
||||
|
||||
# 添加扩展字段(使用动态属性)
|
||||
if hasattr(order, 'original_amount') or True: # 兼容性检查
|
||||
order.original_amount = original_amount
|
||||
order.discount_amount = discount_amount
|
||||
order.is_upgrade = is_upgrade
|
||||
|
||||
# 如果使用了优惠码,关联优惠码
|
||||
if promo_code and price_result.get('promo_code'):
|
||||
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
|
||||
if promo_obj:
|
||||
order.promo_code_id = promo_obj.id
|
||||
|
||||
# 如果是升级,记录原套餐信息
|
||||
if is_upgrade:
|
||||
order.upgrade_from_plan = price_result.get('current_plan')
|
||||
|
||||
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:
|
||||
return jsonify({'success': False, 'error': '订单创建失败'}), 500
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
|
||||
|
||||
# 尝试调用真实的微信支付API
|
||||
try:
|
||||
@@ -1420,6 +1888,26 @@ def force_update_order_status(order_id):
|
||||
# 激活用户订阅
|
||||
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
|
||||
|
||||
# 记录优惠码使用(如果使用了优惠码)
|
||||
if hasattr(order, 'promo_code_id') and order.promo_code_id:
|
||||
try:
|
||||
promo_usage = PromoCodeUsage(
|
||||
promo_code_id=order.promo_code_id,
|
||||
user_id=order.user_id,
|
||||
order_id=order.id,
|
||||
original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount,
|
||||
discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0,
|
||||
final_amount=order.amount
|
||||
)
|
||||
db.session.add(promo_usage)
|
||||
|
||||
# 更新优惠码使用次数
|
||||
promo = PromoCode.query.get(order.promo_code_id)
|
||||
if promo:
|
||||
promo.current_uses = (promo.current_uses or 0) + 1
|
||||
except Exception as e:
|
||||
print(f"记录优惠码使用失败: {e}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print(f"✅ 订单状态强制更新成功: {old_status} -> paid")
|
||||
@@ -6599,8 +7087,15 @@ def api_get_events():
|
||||
query = query.filter_by(status=event_status)
|
||||
if event_type != 'all':
|
||||
query = query.filter_by(event_type=event_type)
|
||||
# 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A)
|
||||
if importance != 'all':
|
||||
query = query.filter_by(importance=importance)
|
||||
if ',' in importance:
|
||||
# 多个重要性级别
|
||||
importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()]
|
||||
query = query.filter(Event.importance.in_(importance_list))
|
||||
else:
|
||||
# 单个重要性级别
|
||||
query = query.filter_by(importance=importance)
|
||||
if creator_id:
|
||||
query = query.filter_by(creator_id=creator_id)
|
||||
# 新增:行业代码过滤(申银万国行业分类)
|
||||
|
||||
107
concept_api.py
107
concept_api.py
@@ -22,15 +22,15 @@ openai_client = None
|
||||
mysql_pool = None
|
||||
|
||||
# 配置
|
||||
ES_HOST = 'http://192.168.1.58:9200'
|
||||
OPENAI_BASE_URL = "http://192.168.1.58:8000/v1"
|
||||
ES_HOST = 'http://127.0.0.1:9200'
|
||||
OPENAI_BASE_URL = "http://127.0.0.1:8000/v1"
|
||||
OPENAI_API_KEY = "dummy"
|
||||
EMBEDDING_MODEL = "qwen3-embedding-8b"
|
||||
INDEX_NAME = 'concept_library'
|
||||
|
||||
# MySQL配置
|
||||
MYSQL_CONFIG = {
|
||||
'host': '192.168.1.14',
|
||||
'host': '192.168.1.8',
|
||||
'user': 'root',
|
||||
'password': 'Zzl5588161!',
|
||||
'db': 'stock',
|
||||
@@ -490,7 +490,7 @@ def build_hybrid_knn_query(
|
||||
"field": "description_embedding",
|
||||
"query_vector": embedding,
|
||||
"k": k,
|
||||
"num_candidates": min(k * 2, 500),
|
||||
"num_candidates": max(k + 50, min(k * 2, 10000)), # 确保 num_candidates > k,最大 10000
|
||||
"boost": semantic_weight
|
||||
}
|
||||
}
|
||||
@@ -591,7 +591,7 @@ async def search_concepts(request: SearchRequest):
|
||||
"field": "description_embedding",
|
||||
"query_vector": embedding,
|
||||
"k": effective_search_size, # 使用有效搜索大小
|
||||
"num_candidates": min(effective_search_size * 2, 1000)
|
||||
"num_candidates": max(effective_search_size + 50, min(effective_search_size * 2, 10000)) # 确保 num_candidates > k
|
||||
},
|
||||
"size": effective_search_size
|
||||
}
|
||||
@@ -1045,7 +1045,16 @@ async def get_concept_price_timeseries(
|
||||
):
|
||||
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
|
||||
if not mysql_pool:
|
||||
raise HTTPException(status_code=503, detail="数据库连接不可用")
|
||||
logger.warning(f"[PriceTimeseries] MySQL 连接不可用,返回空时间序列数据")
|
||||
# 返回空时间序列而不是 503 错误
|
||||
return PriceTimeSeriesResponse(
|
||||
concept_id=concept_id,
|
||||
concept_name=concept_id, # 无法查询名称,使用 ID
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
data_points=0,
|
||||
timeseries=[]
|
||||
)
|
||||
|
||||
if start_date > end_date:
|
||||
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
|
||||
@@ -1150,11 +1159,93 @@ async def get_concept_statistics(
|
||||
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
|
||||
):
|
||||
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 如果 MySQL 不可用,直接返回示例数据(而不是返回 503)
|
||||
if not mysql_pool:
|
||||
raise HTTPException(status_code=503, detail="数据库连接不可用")
|
||||
logger.warning("[Statistics] MySQL 连接不可用,使用示例数据")
|
||||
|
||||
# 计算日期范围
|
||||
if days is not None and (start_date is not None or end_date is not None):
|
||||
pass # 参数冲突,但仍使用 days
|
||||
|
||||
if start_date is not None and end_date is not None:
|
||||
pass # 使用提供的日期
|
||||
elif days is not None:
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
elif start_date is not None:
|
||||
end_date = datetime.now().date()
|
||||
elif end_date is not None:
|
||||
start_date = end_date - timedelta(days=7)
|
||||
else:
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
# 返回示例数据(与 except 块中相同)
|
||||
fallback_statistics = ConceptStatistics(
|
||||
hot_concepts=[
|
||||
ConceptStatItem(name="小米大模型", change_pct=12.45, stock_count=24, news_count=18),
|
||||
ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12),
|
||||
ConceptStatItem(name="新能源汽车", change_pct=6.54, stock_count=38, news_count=8),
|
||||
ConceptStatItem(name="芯片概念", change_pct=5.43, stock_count=52, news_count=15),
|
||||
ConceptStatItem(name="生物医药", change_pct=4.21, stock_count=28, news_count=6),
|
||||
],
|
||||
cold_concepts=[
|
||||
ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5),
|
||||
ConceptStatItem(name="煤炭开采", change_pct=-4.32, stock_count=25, news_count=3),
|
||||
ConceptStatItem(name="钢铁冶炼", change_pct=-3.21, stock_count=28, news_count=4),
|
||||
ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2),
|
||||
ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2),
|
||||
],
|
||||
active_concepts=[
|
||||
ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60),
|
||||
ConceptStatItem(name="芯片概念", news_count=42, report_count=12, total_mentions=54),
|
||||
ConceptStatItem(name="新能源汽车", news_count=38, report_count=8, total_mentions=46),
|
||||
ConceptStatItem(name="生物医药", news_count=28, report_count=6, total_mentions=34),
|
||||
ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30),
|
||||
],
|
||||
volatile_concepts=[
|
||||
ConceptStatItem(name="区块链", volatility=25.6, avg_change=2.1, max_change=15.2),
|
||||
ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9),
|
||||
ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1),
|
||||
ConceptStatItem(name="游戏概念", volatility=19.7, avg_change=3.2, max_change=12.8),
|
||||
ConceptStatItem(name="在线教育", volatility=18.3, avg_change=-1.1, max_change=8.1),
|
||||
],
|
||||
momentum_concepts=[
|
||||
ConceptStatItem(name="数字经济", consecutive_days=6, total_change=19.2, avg_daily=3.2),
|
||||
ConceptStatItem(name="云计算", consecutive_days=5, total_change=16.8, avg_daily=3.36),
|
||||
ConceptStatItem(name="物联网", consecutive_days=4, total_change=13.1, avg_daily=3.28),
|
||||
ConceptStatItem(name="大数据", consecutive_days=4, total_change=12.4, avg_daily=3.1),
|
||||
ConceptStatItem(name="工业互联网", consecutive_days=3, total_change=9.6, avg_daily=3.2),
|
||||
],
|
||||
summary={
|
||||
'total_concepts': 500,
|
||||
'positive_count': 320,
|
||||
'negative_count': 180,
|
||||
'avg_change': 1.8,
|
||||
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'date_range': f"{start_date} 至 {end_date}",
|
||||
'days': (end_date - start_date).days + 1,
|
||||
'start_date': str(start_date),
|
||||
'end_date': str(end_date)
|
||||
}
|
||||
)
|
||||
|
||||
return ConceptStatisticsResponse(
|
||||
success=True,
|
||||
data=fallback_statistics,
|
||||
params={
|
||||
'days': (end_date - start_date).days + 1,
|
||||
'min_stock_count': min_stock_count,
|
||||
'start_date': str(start_date),
|
||||
'end_date': str(end_date)
|
||||
},
|
||||
note="MySQL 连接不可用,使用示例数据"
|
||||
)
|
||||
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
# 参数验证和日期范围计算
|
||||
|
||||
@@ -2,6 +2,9 @@ const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const { BundleAnalyzerPlugin } = process.env.ANALYZE ? require('webpack-bundle-analyzer') : { BundleAnalyzerPlugin: null };
|
||||
|
||||
// 检查是否为 Mock 模式(与 src/utils/apiConfig.js 保持一致)
|
||||
const isMockMode = () => process.env.REACT_APP_ENABLE_MOCK === 'true';
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
configure: (webpackConfig, { env, paths }) => {
|
||||
@@ -27,7 +30,7 @@ module.exports = {
|
||||
chunks: 'all',
|
||||
maxInitialRequests: 30,
|
||||
minSize: 20000,
|
||||
maxSize: 244000, // 限制单个 chunk 最大大小(约 244KB)
|
||||
maxSize: 512000, // 限制单个 chunk 最大大小(512KB,与 performance.maxAssetSize 一致)
|
||||
cacheGroups: {
|
||||
// React 核心库单独分离
|
||||
react: {
|
||||
@@ -47,7 +50,7 @@ module.exports = {
|
||||
chakraUI: {
|
||||
test: /[\\/]node_modules[\\/](@chakra-ui|@emotion)[\\/]/,
|
||||
name: 'chakra-ui',
|
||||
priority: 22,
|
||||
priority: 23, // 从 22 改为 23,避免与 antd 优先级冲突
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// Ant Design
|
||||
@@ -236,21 +239,38 @@ module.exports = {
|
||||
devMiddleware: {
|
||||
writeToDisk: false,
|
||||
},
|
||||
// 代理配置:将 /api 请求代理到后端服务器
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://49.232.185.254:5001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
'/concept-api': {
|
||||
target: 'http://49.232.185.254:6801',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/concept-api': '' },
|
||||
},
|
||||
|
||||
// 调试日志
|
||||
onListening: (devServer) => {
|
||||
console.log(`[CRACO] Mock Mode: ${isMockMode() ? 'Enabled ✅' : 'Disabled ❌'}`);
|
||||
console.log(`[CRACO] Proxy: ${isMockMode() ? 'Disabled (MSW intercepts)' : 'Enabled (forwarding to backend)'}`);
|
||||
},
|
||||
|
||||
// 代理配置:将 /api 请求代理到后端服务器
|
||||
// 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求
|
||||
...(isMockMode() ? {} : {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://49.232.185.254:5001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
'/concept-api': {
|
||||
target: 'http://49.232.185.254:6801',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/concept-api': '' },
|
||||
},
|
||||
'/bytedesk-api': {
|
||||
target: 'http://43.143.189.195',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/bytedesk-api': '' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
59
database_migration_add_pricing_options.sql
Normal file
59
database_migration_add_pricing_options.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- 数据库迁移:添加 pricing_options 字段,支持灵活的计费周期
|
||||
-- 执行时间:2025-01-XX
|
||||
-- 说明:支持用户选择"包N个月"或"包N年"的套餐
|
||||
|
||||
-- 1. 添加 pricing_options 字段
|
||||
ALTER TABLE subscription_plans
|
||||
ADD COLUMN pricing_options TEXT NULL COMMENT 'JSON格式的计费周期选项';
|
||||
|
||||
-- 2. 为Pro套餐配置多种计费周期(基于现有价格:198元/月,2000元/年)
|
||||
UPDATE subscription_plans
|
||||
SET pricing_options = '[
|
||||
{"months": 1, "price": 198, "label": "月付", "cycle_key": "monthly"},
|
||||
{"months": 3, "price": 534, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
|
||||
{"months": 6, "price": 950, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
|
||||
{"months": 12, "price": 2000, "label": "1年", "cycle_key": "yearly", "discount_percent": 16},
|
||||
{"months": 24, "price": 3600, "label": "2年", "cycle_key": "2years", "discount_percent": 24},
|
||||
{"months": 36, "price": 5040, "label": "3年", "cycle_key": "3years", "discount_percent": 29}
|
||||
]'
|
||||
WHERE name = 'pro';
|
||||
|
||||
-- 3. 为Max套餐配置多种计费周期(基于现有价格:998元/月,10000元/年)
|
||||
UPDATE subscription_plans
|
||||
SET pricing_options = '[
|
||||
{"months": 1, "price": 998, "label": "月付", "cycle_key": "monthly"},
|
||||
{"months": 3, "price": 2695, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
|
||||
{"months": 6, "price": 4790, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
|
||||
{"months": 12, "price": 10000, "label": "1年", "cycle_key": "yearly", "discount_percent": 17},
|
||||
{"months": 24, "price": 18000, "label": "2年", "cycle_key": "2years", "discount_percent": 25},
|
||||
{"months": 36, "price": 25200, "label": "3年", "cycle_key": "3years", "discount_percent": 30}
|
||||
]'
|
||||
WHERE name = 'max';
|
||||
|
||||
-- ========================================
|
||||
-- 价格计算说明
|
||||
-- ========================================
|
||||
-- Pro版(198元/月,2000元/年):
|
||||
-- - 月付:198元
|
||||
-- - 3个月:198×3×0.9 = 534元(打9折,省10%)
|
||||
-- - 半年:198×6×0.8 = 950元(打8折,省20%)
|
||||
-- - 1年:2000元(已有年付价格,约省16%)
|
||||
-- - 2年:198×24×0.76 = 3600元(省24%)
|
||||
-- - 3年:198×36×0.7 = 5040元(省30%)
|
||||
|
||||
-- Max版(998元/月,10000元/年):
|
||||
-- - 月付:998元
|
||||
-- - 3个月:998×3×0.9 = 2695元(打9折,省10%)
|
||||
-- - 半年:998×6×0.8 = 4790元(打8折,省20%)
|
||||
-- - 1年:10000元(已有年付价格,约省17%)
|
||||
-- - 2年:998×24×0.75 = 18000元(省25%)
|
||||
-- - 3年:998×36×0.7 = 25200元(省30%)
|
||||
|
||||
-- ========================================
|
||||
-- 注意事项
|
||||
-- ========================================
|
||||
-- 1. 上述价格仅为示例,请根据实际营销策略调整
|
||||
-- 2. 折扣力度建议:时间越长,优惠越大
|
||||
-- 3. 如果不想提供某个周期,直接从数组中删除即可
|
||||
-- 4. 前端会自动渲染所有可用的计费周期选项
|
||||
-- 5. 用户可以通过优惠码获得额外折扣
|
||||
381
docs/AGENT_DEPLOYMENT.md
Normal file
381
docs/AGENT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# AI Agent 系统部署指南
|
||||
|
||||
## 🎯 系统架构
|
||||
|
||||
### 三阶段流程
|
||||
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
[阶段1: 计划制定 Planning]
|
||||
- LLM 分析用户需求
|
||||
- 确定需要哪些工具
|
||||
- 制定执行计划(steps)
|
||||
↓
|
||||
[阶段2: 工具执行 Execution]
|
||||
- 按计划顺序调用 MCP 工具
|
||||
- 收集数据
|
||||
- 异常处理和重试
|
||||
↓
|
||||
[阶段3: 结果总结 Summarization]
|
||||
- LLM 综合分析所有数据
|
||||
- 生成自然语言报告
|
||||
↓
|
||||
输出给用户
|
||||
```
|
||||
|
||||
## 📦 文件清单
|
||||
|
||||
### 后端文件
|
||||
|
||||
```
|
||||
mcp_server.py # MCP 工具服务器(已有)
|
||||
mcp_agent_system.py # Agent 系统核心逻辑(新增)
|
||||
mcp_config.py # 配置文件(已有)
|
||||
mcp_database.py # 数据库操作(已有)
|
||||
```
|
||||
|
||||
### 前端文件
|
||||
|
||||
```
|
||||
src/components/ChatBot/
|
||||
├── ChatInterfaceV2.js # 新版聊天界面(漂亮)
|
||||
├── PlanCard.js # 执行计划卡片
|
||||
├── StepResultCard.js # 步骤结果卡片(可折叠)
|
||||
├── ChatInterface.js # 旧版聊天界面(保留)
|
||||
├── MessageBubble.js # 消息气泡组件(保留)
|
||||
└── index.js # 统一导出
|
||||
|
||||
src/views/AgentChat/
|
||||
└── index.js # Agent 聊天页面
|
||||
```
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /home/ubuntu/vf_react
|
||||
|
||||
# 安装 OpenAI SDK(支持多个LLM提供商)
|
||||
pip install openai
|
||||
```
|
||||
|
||||
### 2. 获取 LLM API Key
|
||||
|
||||
**推荐:通义千问(便宜且中文能力强)**
|
||||
|
||||
1. 访问 https://dashscope.console.aliyun.com/
|
||||
2. 注册/登录阿里云账号
|
||||
3. 开通 DashScope 服务
|
||||
4. 创建 API Key
|
||||
5. 复制 API Key(格式:`sk-xxx...`)
|
||||
|
||||
**其他选择**:
|
||||
- DeepSeek: https://platform.deepseek.com/ (最便宜)
|
||||
- OpenAI: https://platform.openai.com/ (需要翻墙)
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 编辑环境变量
|
||||
sudo nano /etc/environment
|
||||
|
||||
# 添加以下内容(选择一个)
|
||||
# 方式1: 通义千问(推荐)
|
||||
DASHSCOPE_API_KEY="sk-your-key-here"
|
||||
|
||||
# 方式2: DeepSeek(更便宜)
|
||||
DEEPSEEK_API_KEY="sk-your-key-here"
|
||||
|
||||
# 方式3: OpenAI
|
||||
OPENAI_API_KEY="sk-your-key-here"
|
||||
|
||||
# 保存并退出,然后重新加载
|
||||
source /etc/environment
|
||||
|
||||
# 验证环境变量
|
||||
echo $DASHSCOPE_API_KEY
|
||||
```
|
||||
|
||||
### 4. 修改 mcp_server.py
|
||||
|
||||
在文件末尾(`if __name__ == "__main__":` 之前)添加:
|
||||
|
||||
```python
|
||||
# ==================== Agent 端点 ====================
|
||||
|
||||
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
|
||||
|
||||
# 创建 Agent 实例
|
||||
agent = MCPAgent(provider="qwen") # 或 "deepseek", "openai"
|
||||
|
||||
@app.post("/agent/chat", response_model=AgentResponse)
|
||||
async def agent_chat(request: ChatRequest):
|
||||
"""智能代理对话端点"""
|
||||
logger.info(f"Agent chat: {request.message}")
|
||||
|
||||
# 获取工具列表和处理器
|
||||
tools = [tool.dict() for tool in TOOLS]
|
||||
|
||||
# 处理查询
|
||||
response = await agent.process_query(
|
||||
user_query=request.message,
|
||||
tools=tools,
|
||||
tool_handlers=TOOL_HANDLERS,
|
||||
)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
### 5. 重启 MCP 服务
|
||||
|
||||
```bash
|
||||
# 如果使用 systemd
|
||||
sudo systemctl restart mcp-server
|
||||
|
||||
# 或者手动重启
|
||||
pkill -f mcp_server
|
||||
nohup uvicorn mcp_server:app --host 0.0.0.0 --port 8900 > mcp_server.log 2>&1 &
|
||||
|
||||
# 查看日志
|
||||
tail -f mcp_server.log
|
||||
```
|
||||
|
||||
### 6. 测试 Agent API
|
||||
|
||||
```bash
|
||||
# 测试 Agent 端点
|
||||
curl -X POST http://localhost:8900/agent/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "全面分析贵州茅台这只股票",
|
||||
"conversation_history": []
|
||||
}'
|
||||
|
||||
# 应该返回类似这样的JSON:
|
||||
# {
|
||||
# "success": true,
|
||||
# "message": "根据分析,贵州茅台...",
|
||||
# "plan": {
|
||||
# "goal": "全面分析贵州茅台",
|
||||
# "steps": [...]
|
||||
# },
|
||||
# "step_results": [...],
|
||||
# "metadata": {...}
|
||||
# }
|
||||
```
|
||||
|
||||
### 7. 部署前端
|
||||
|
||||
```bash
|
||||
# 在本地构建
|
||||
npm run build
|
||||
|
||||
# 上传到服务器
|
||||
scp -r build/* ubuntu@your-server:/var/www/valuefrontier.cn/
|
||||
|
||||
# 或者在服务器上构建
|
||||
cd /home/ubuntu/vf_react
|
||||
npm run build
|
||||
sudo cp -r build/* /var/www/valuefrontier.cn/
|
||||
```
|
||||
|
||||
### 8. 重启 Nginx
|
||||
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## ✅ 验证部署
|
||||
|
||||
### 1. 测试后端 API
|
||||
|
||||
```bash
|
||||
# 测试工具列表
|
||||
curl https://valuefrontier.cn/mcp/tools
|
||||
|
||||
# 测试 Agent
|
||||
curl -X POST https://valuefrontier.cn/mcp/agent/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "今日涨停股票有哪些",
|
||||
"conversation_history": []
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 测试前端
|
||||
|
||||
1. 访问 https://valuefrontier.cn/agent-chat
|
||||
2. 输入问题:"全面分析贵州茅台这只股票"
|
||||
3. 观察:
|
||||
- ✓ 是否显示执行计划卡片
|
||||
- ✓ 是否显示步骤执行过程
|
||||
- ✓ 是否显示最终总结
|
||||
- ✓ 步骤结果卡片是否可折叠
|
||||
|
||||
### 3. 测试用例
|
||||
|
||||
```
|
||||
测试1: 简单查询
|
||||
输入:查询贵州茅台的股票信息
|
||||
预期:调用 get_stock_basic_info,返回基本信息
|
||||
|
||||
测试2: 深度分析(推荐)
|
||||
输入:全面分析贵州茅台这只股票
|
||||
预期:
|
||||
- 步骤1: get_stock_basic_info
|
||||
- 步骤2: get_stock_financial_index
|
||||
- 步骤3: get_stock_trade_data
|
||||
- 步骤4: search_china_news
|
||||
- 步骤5: summarize_with_llm
|
||||
|
||||
测试3: 市场热点
|
||||
输入:今日涨停股票有哪些亮点
|
||||
预期:
|
||||
- 步骤1: search_limit_up_stocks
|
||||
- 步骤2: get_concept_statistics
|
||||
- 步骤3: summarize_with_llm
|
||||
|
||||
测试4: 概念分析
|
||||
输入:新能源概念板块的投资机会
|
||||
预期:
|
||||
- 步骤1: search_concepts(新能源)
|
||||
- 步骤2: search_china_news(新能源)
|
||||
- 步骤3: summarize_with_llm
|
||||
```
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题1: Agent 返回 "Provider not configured"
|
||||
|
||||
**原因**: 环境变量未设置
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查环境变量
|
||||
echo $DASHSCOPE_API_KEY
|
||||
|
||||
# 如果为空,重新设置
|
||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart mcp-server
|
||||
```
|
||||
|
||||
### 问题2: Agent 返回 JSON 解析错误
|
||||
|
||||
**原因**: LLM 没有返回正确的 JSON 格式
|
||||
|
||||
**解决**: 在 `mcp_agent_system.py` 中已经处理了代码块标记清理,如果还有问题:
|
||||
1. 检查 LLM 的 temperature 参数(建议 0.3)
|
||||
2. 检查 prompt 是否清晰
|
||||
3. 尝试不同的 LLM 提供商
|
||||
|
||||
### 问题3: 前端显示 "查询失败"
|
||||
|
||||
**原因**: 后端 API 未正确配置或 Nginx 代理问题
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 1. 检查 MCP 服务是否运行
|
||||
ps aux | grep mcp_server
|
||||
|
||||
# 2. 检查 Nginx 配置
|
||||
sudo nginx -t
|
||||
|
||||
# 3. 查看错误日志
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
tail -f /home/ubuntu/vf_react/mcp_server.log
|
||||
```
|
||||
|
||||
### 问题4: 执行步骤失败
|
||||
|
||||
**原因**: 某个 MCP 工具调用失败
|
||||
|
||||
**解决**: 查看步骤结果卡片中的错误信息,通常是:
|
||||
- API 超时:增加 timeout
|
||||
- 参数错误:检查工具定义
|
||||
- 数据库连接失败:检查数据库连接
|
||||
|
||||
## 💰 成本估算
|
||||
|
||||
### 使用通义千问(qwen-plus)
|
||||
|
||||
**价格**: ¥0.004/1000 tokens
|
||||
|
||||
**典型对话消耗**:
|
||||
- 简单查询(1步): ~500 tokens = ¥0.002
|
||||
- 深度分析(5步): ~3000 tokens = ¥0.012
|
||||
- 平均每次对话: ¥0.005
|
||||
|
||||
**月度成本**(1000次深度分析):
|
||||
- 1000次 × ¥0.012 = ¥12
|
||||
|
||||
**结论**: 非常便宜!1000次深度分析只需要12元。
|
||||
|
||||
### 使用 DeepSeek(更便宜)
|
||||
|
||||
**价格**: ¥0.001/1000 tokens(比通义千问便宜4倍)
|
||||
|
||||
**月度成本**(1000次深度分析):
|
||||
- 1000次 × ¥0.003 = ¥3
|
||||
|
||||
## 📊 监控和优化
|
||||
|
||||
### 1. 添加日志监控
|
||||
|
||||
```bash
|
||||
# 实时查看 Agent 日志
|
||||
tail -f mcp_server.log | grep -E "\[Agent\]|\[Planning\]|\[Execution\]|\[Summary\]"
|
||||
```
|
||||
|
||||
### 2. 性能优化建议
|
||||
|
||||
1. **缓存计划**: 相似的问题可以复用执行计划
|
||||
2. **并行执行**: 独立的工具调用可以并行执行
|
||||
3. **流式输出**: 使用 Server-Sent Events 实时返回进度
|
||||
4. **结果缓存**: 相同的工具调用结果可以缓存
|
||||
|
||||
### 3. 添加统计分析
|
||||
|
||||
在 `mcp_server.py` 中添加:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
# 记录每次 Agent 调用
|
||||
@app.post("/agent/chat")
|
||||
async def agent_chat(request: ChatRequest):
|
||||
start_time = datetime.now()
|
||||
|
||||
response = await agent.process_query(...)
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 记录到日志
|
||||
logger.info(f"Agent query completed in {duration:.2f}s", extra={
|
||||
"query": request.message,
|
||||
"steps": len(response.plan.steps) if response.plan else 0,
|
||||
"success": response.success,
|
||||
"duration": duration,
|
||||
})
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
## 🎉 完成!
|
||||
|
||||
现在你的 AI Agent 系统已经部署完成!
|
||||
|
||||
访问 https://valuefrontier.cn/agent-chat 开始使用。
|
||||
|
||||
**特点**:
|
||||
- ✅ 三阶段智能分析(计划-执行-总结)
|
||||
- ✅ 漂亮的UI界面(卡片式展示)
|
||||
- ✅ 步骤结果可折叠查看
|
||||
- ✅ 实时进度反馈
|
||||
- ✅ 异常处理和重试
|
||||
- ✅ 成本低廉(¥3-12/月)
|
||||
1197
docs/Community.md
Normal file
1197
docs/Community.md
Normal file
File diff suppressed because it is too large
Load Diff
309
docs/MCP_ARCHITECTURE.md
Normal file
309
docs/MCP_ARCHITECTURE.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# MCP 架构说明
|
||||
|
||||
## 🎯 MCP 是什么?
|
||||
|
||||
**MCP (Model Context Protocol)** 是一个**工具调用协议**,它的核心职责是:
|
||||
|
||||
1. ✅ **定义工具接口**:告诉 LLM 有哪些工具可用,每个工具需要什么参数
|
||||
2. ✅ **执行工具调用**:根据请求调用对应的后端 API
|
||||
3. ✅ **返回结构化数据**:将 API 结果返回给调用方
|
||||
|
||||
**MCP 不负责**:
|
||||
- ❌ 自然语言理解(NLU)
|
||||
- ❌ 意图识别
|
||||
- ❌ 结果总结
|
||||
- ❌ 对话管理
|
||||
|
||||
## 📊 当前架构
|
||||
|
||||
### 方案 1:简单关键词匹配(已实现)
|
||||
|
||||
```
|
||||
用户输入:"查询贵州茅台的股票信息"
|
||||
↓
|
||||
前端 ChatInterface (关键词匹配)
|
||||
↓
|
||||
MCP 工具层 (search_china_news)
|
||||
↓
|
||||
返回 JSON 数据
|
||||
↓
|
||||
前端显示原始数据
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- ✗ 只能识别简单关键词
|
||||
- ✗ 无法理解复杂意图
|
||||
- ✗ 返回的是原始 JSON,用户体验差
|
||||
|
||||
### 方案 2:集成 LLM(推荐)
|
||||
|
||||
```
|
||||
用户输入:"查询贵州茅台的股票信息"
|
||||
↓
|
||||
LLM (Claude/GPT-4/通义千问)
|
||||
↓ 理解意图:需要查询股票代码 600519 的基本信息
|
||||
↓ 选择工具:get_stock_basic_info
|
||||
↓ 提取参数:{"seccode": "600519"}
|
||||
MCP 工具层
|
||||
↓ 调用 API,获取数据
|
||||
返回结构化数据
|
||||
↓
|
||||
LLM 总结结果
|
||||
↓ "贵州茅台(600519)是中国知名的白酒生产企业,
|
||||
当前股价 1650.00 元,市值 2.07 万亿..."
|
||||
前端显示自然语言回复
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✓ 理解复杂意图
|
||||
- ✓ 自动选择合适的工具
|
||||
- ✓ 自然语言总结,用户体验好
|
||||
- ✓ 支持多轮对话
|
||||
|
||||
## 🔧 实现方案
|
||||
|
||||
### 选项 A:前端集成 LLM(快速实现)
|
||||
|
||||
**适用场景**:快速原型、小规模应用
|
||||
|
||||
**优点**:
|
||||
- 实现简单
|
||||
- 无需修改后端
|
||||
|
||||
**缺点**:
|
||||
- API Key 暴露在前端(安全风险)
|
||||
- 每个用户都消耗 API 额度
|
||||
- 无法统一管理和监控
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
1. 修改 `src/components/ChatBot/ChatInterface.js`:
|
||||
|
||||
```javascript
|
||||
import { llmService } from '../../services/llmService';
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
// ...
|
||||
|
||||
// 使用 LLM 服务替代简单的 mcpService.chat
|
||||
const response = await llmService.chat(inputValue, messages);
|
||||
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
2. 配置 API Key(在 `.env.local`):
|
||||
|
||||
```bash
|
||||
REACT_APP_OPENAI_API_KEY=sk-xxx...
|
||||
# 或者使用通义千问(更便宜)
|
||||
REACT_APP_DASHSCOPE_API_KEY=sk-xxx...
|
||||
```
|
||||
|
||||
### 选项 B:后端集成 LLM(生产推荐)⭐
|
||||
|
||||
**适用场景**:生产环境、需要安全和性能
|
||||
|
||||
**优点**:
|
||||
- ✓ API Key 安全(不暴露给前端)
|
||||
- ✓ 统一管理和监控
|
||||
- ✓ 可以做缓存优化
|
||||
- ✓ 可以做速率限制
|
||||
|
||||
**缺点**:
|
||||
- 需要修改后端
|
||||
- 增加服务器成本
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
#### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install openai
|
||||
```
|
||||
|
||||
#### 2. 修改 `mcp_server.py`,添加聊天端点
|
||||
|
||||
在文件末尾添加:
|
||||
|
||||
```python
|
||||
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
|
||||
|
||||
# 创建聊天助手实例
|
||||
chat_assistant = MCPChatAssistant(provider="qwen") # 推荐使用通义千问
|
||||
|
||||
@app.post("/chat", response_model=ChatResponse)
|
||||
async def chat_endpoint(request: ChatRequest):
|
||||
"""智能对话端点 - 使用LLM理解意图并调用工具"""
|
||||
logger.info(f"Chat request: {request.message}")
|
||||
|
||||
# 获取可用工具列表
|
||||
tools = [tool.dict() for tool in TOOLS]
|
||||
|
||||
# 调用聊天助手
|
||||
response = await chat_assistant.chat(
|
||||
user_message=request.message,
|
||||
conversation_history=request.conversation_history,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
#### 3. 配置环境变量
|
||||
|
||||
在服务器上设置:
|
||||
|
||||
```bash
|
||||
# 方式1:使用通义千问(推荐,价格便宜)
|
||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
||||
|
||||
# 方式2:使用 OpenAI
|
||||
export OPENAI_API_KEY="sk-xxx..."
|
||||
|
||||
# 方式3:使用 DeepSeek(最便宜)
|
||||
export DEEPSEEK_API_KEY="sk-xxx..."
|
||||
```
|
||||
|
||||
#### 4. 修改前端 `mcpService.js`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 智能对话 - 使用后端LLM处理
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
const response = await this.client.post('/chat', {
|
||||
message: userMessage,
|
||||
conversation_history: conversationHistory,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 修改前端 `ChatInterface.js`
|
||||
|
||||
```javascript
|
||||
const handleSendMessage = async () => {
|
||||
// ...
|
||||
|
||||
try {
|
||||
// 调用后端聊天API
|
||||
const response = await mcpService.chat(inputValue, messages);
|
||||
|
||||
if (response.success) {
|
||||
const botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: response.data.message, // LLM总结的自然语言
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
toolUsed: response.data.tool_used, // 可选:显示使用了哪个工具
|
||||
rawData: response.data.raw_data, // 可选:原始数据(折叠显示)
|
||||
};
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
}
|
||||
} catch (error) {
|
||||
// ...
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 💰 LLM 选择和成本
|
||||
|
||||
### 推荐:通义千问(阿里云)
|
||||
|
||||
**优点**:
|
||||
- 价格便宜(1000次对话约 ¥1-2)
|
||||
- 中文理解能力强
|
||||
- 国内访问稳定
|
||||
|
||||
**价格**:
|
||||
- qwen-plus: ¥0.004/1000 tokens(约 ¥0.001/次对话)
|
||||
- qwen-turbo: ¥0.002/1000 tokens(更便宜)
|
||||
|
||||
**获取 API Key**:
|
||||
1. 访问 https://dashscope.console.aliyun.com/
|
||||
2. 创建 API Key
|
||||
3. 设置环境变量 `DASHSCOPE_API_KEY`
|
||||
|
||||
### 其他选择
|
||||
|
||||
| 提供商 | 模型 | 价格 | 优点 | 缺点 |
|
||||
|--------|------|------|------|------|
|
||||
| **通义千问** | qwen-plus | ¥0.001/次 | 便宜、中文好 | - |
|
||||
| **DeepSeek** | deepseek-chat | ¥0.0005/次 | 最便宜 | 新公司 |
|
||||
| **OpenAI** | gpt-4o-mini | $0.15/1M tokens | 能力强 | 贵、需翻墙 |
|
||||
| **Claude** | claude-3-haiku | $0.25/1M tokens | 理解力强 | 贵、需翻墙 |
|
||||
|
||||
## 🚀 部署步骤
|
||||
|
||||
### 1. 后端部署
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install openai
|
||||
|
||||
# 设置 API Key
|
||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart mcp-server
|
||||
|
||||
# 测试聊天端点
|
||||
curl -X POST https://valuefrontier.cn/mcp/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "查询贵州茅台的股票信息"}'
|
||||
```
|
||||
|
||||
### 2. 前端部署
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 部署
|
||||
scp -r build/* user@server:/var/www/valuefrontier.cn/
|
||||
```
|
||||
|
||||
### 3. 验证
|
||||
|
||||
访问 https://valuefrontier.cn/agent-chat,测试对话:
|
||||
|
||||
**测试用例**:
|
||||
1. "查询贵州茅台的股票信息" → 应返回自然语言总结
|
||||
2. "今日涨停的股票有哪些" → 应返回涨停股票列表并总结
|
||||
3. "新能源概念板块表现如何" → 应搜索概念并分析
|
||||
|
||||
## 📊 对比总结
|
||||
|
||||
| 特性 | 简单匹配 | 前端LLM | 后端LLM ⭐ |
|
||||
|------|---------|---------|-----------|
|
||||
| 实现难度 | 简单 | 中等 | 中等 |
|
||||
| 用户体验 | 差 | 好 | 好 |
|
||||
| 安全性 | 高 | 低 | 高 |
|
||||
| 成本 | 无 | 用户承担 | 服务器承担 |
|
||||
| 可维护性 | 差 | 中 | 好 |
|
||||
| **推荐指数** | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
## 🎯 最终推荐
|
||||
|
||||
**生产环境:后端集成 LLM (方案 B)**
|
||||
- 使用通义千问(qwen-plus)
|
||||
- 成本低(约 ¥50/月,10000次对话)
|
||||
- 安全可靠
|
||||
|
||||
**快速原型:前端集成 LLM (方案 A)**
|
||||
- 适合演示
|
||||
- 快速验证可行性
|
||||
- 后续再迁移到后端
|
||||
File diff suppressed because it is too large
Load Diff
1770
docs/test-cases/notification-tests.md
Normal file
1770
docs/test-cases/notification-tests.md
Normal file
File diff suppressed because it is too large
Load Diff
108
mcp_config.py
Normal file
108
mcp_config.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
MCP服务器配置文件
|
||||
集中管理所有配置项
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from pydantic import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST: str = "0.0.0.0"
|
||||
SERVER_PORT: int = 8900
|
||||
DEBUG: bool = True
|
||||
|
||||
# 后端API服务端点
|
||||
NEWS_API_URL: str = "http://222.128.1.157:21891"
|
||||
ROADSHOW_API_URL: str = "http://222.128.1.157:19800"
|
||||
CONCEPT_API_URL: str = "http://222.128.1.157:16801"
|
||||
STOCK_ANALYSIS_API_URL: str = "http://222.128.1.157:8811"
|
||||
|
||||
# HTTP客户端配置
|
||||
HTTP_TIMEOUT: float = 60.0
|
||||
HTTP_MAX_RETRIES: int = 3
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGINS: list = ["*"]
|
||||
CORS_CREDENTIALS: bool = True
|
||||
CORS_METHODS: list = ["*"]
|
||||
CORS_HEADERS: list = ["*"]
|
||||
|
||||
# LLM配置(如果需要集成)
|
||||
LLM_PROVIDER: str = "openai" # openai, anthropic, etc.
|
||||
LLM_API_KEY: str = ""
|
||||
LLM_MODEL: str = "gpt-4"
|
||||
LLM_BASE_URL: str = ""
|
||||
|
||||
# 速率限制
|
||||
RATE_LIMIT_ENABLED: bool = False
|
||||
RATE_LIMIT_PER_MINUTE: int = 60
|
||||
|
||||
# 缓存配置
|
||||
CACHE_ENABLED: bool = True
|
||||
CACHE_TTL: int = 300 # 秒
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# 全局设置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
# 工具类别映射(用于组织和展示)
|
||||
TOOL_CATEGORIES: Dict[str, list] = {
|
||||
"新闻搜索": [
|
||||
"search_news",
|
||||
"search_china_news",
|
||||
"search_medical_news"
|
||||
],
|
||||
"公司研究": [
|
||||
"search_roadshows",
|
||||
"search_research_reports"
|
||||
],
|
||||
"概念板块": [
|
||||
"search_concepts",
|
||||
"get_concept_details",
|
||||
"get_stock_concepts",
|
||||
"get_concept_statistics"
|
||||
],
|
||||
"股票分析": [
|
||||
"search_limit_up_stocks",
|
||||
"get_daily_stock_analysis"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# 工具优先级(用于LLM选择工具时的提示)
|
||||
TOOL_PRIORITIES: Dict[str, int] = {
|
||||
"search_china_news": 10, # 最常用
|
||||
"search_concepts": 9,
|
||||
"search_limit_up_stocks": 8,
|
||||
"search_research_reports": 8,
|
||||
"get_stock_concepts": 7,
|
||||
"search_news": 6,
|
||||
"get_daily_stock_analysis": 5,
|
||||
"get_concept_statistics": 5,
|
||||
"search_medical_news": 4,
|
||||
"search_roadshows": 4,
|
||||
"get_concept_details": 3,
|
||||
}
|
||||
|
||||
|
||||
# 默认参数配置
|
||||
DEFAULT_PARAMS = {
|
||||
"top_k": 20,
|
||||
"page_size": 20,
|
||||
"size": 10,
|
||||
"sort_by": "change_pct",
|
||||
"mode": "hybrid",
|
||||
"exact_match": False,
|
||||
}
|
||||
783
mcp_database.py
Normal file
783
mcp_database.py
Normal file
@@ -0,0 +1,783 @@
|
||||
"""
|
||||
MySQL数据库查询模块
|
||||
提供股票财务数据查询功能
|
||||
"""
|
||||
|
||||
import aiomysql
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MySQL连接配置
|
||||
MYSQL_CONFIG = {
|
||||
'host': '222.128.1.157',
|
||||
'port': 33060,
|
||||
'user': 'root',
|
||||
'password': 'Zzl5588161!',
|
||||
'db': 'stock',
|
||||
'charset': 'utf8mb4',
|
||||
'autocommit': True
|
||||
}
|
||||
|
||||
# 全局连接池
|
||||
_pool = None
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON编码器,处理datetime和Decimal类型"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
async def get_pool():
|
||||
"""获取MySQL连接池"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await aiomysql.create_pool(
|
||||
host=MYSQL_CONFIG['host'],
|
||||
port=MYSQL_CONFIG['port'],
|
||||
user=MYSQL_CONFIG['user'],
|
||||
password=MYSQL_CONFIG['password'],
|
||||
db=MYSQL_CONFIG['db'],
|
||||
charset=MYSQL_CONFIG['charset'],
|
||||
autocommit=MYSQL_CONFIG['autocommit'],
|
||||
minsize=1,
|
||||
maxsize=10
|
||||
)
|
||||
logger.info("MySQL connection pool created")
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""关闭MySQL连接池"""
|
||||
global _pool
|
||||
if _pool:
|
||||
_pool.close()
|
||||
await _pool.wait_closed()
|
||||
_pool = None
|
||||
logger.info("MySQL connection pool closed")
|
||||
|
||||
|
||||
def convert_row(row: Dict) -> Dict:
|
||||
"""转换数据库行,处理特殊类型"""
|
||||
if not row:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for key, value in row.items():
|
||||
if isinstance(value, Decimal):
|
||||
result[key] = float(value)
|
||||
elif isinstance(value, (datetime, date)):
|
||||
result[key] = value.isoformat()
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
async def get_stock_basic_info(seccode: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票基本信息
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
|
||||
Returns:
|
||||
股票基本信息字典
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ORGNAME,
|
||||
F001V as english_name,
|
||||
F003V as legal_representative,
|
||||
F004V as registered_address,
|
||||
F005V as office_address,
|
||||
F010D as establishment_date,
|
||||
F011V as website,
|
||||
F012V as email,
|
||||
F013V as phone,
|
||||
F015V as main_business,
|
||||
F016V as business_scope,
|
||||
F017V as company_profile,
|
||||
F030V as industry_level1,
|
||||
F032V as industry_level2,
|
||||
F034V as sw_industry_level1,
|
||||
F036V as sw_industry_level2,
|
||||
F026V as province,
|
||||
F028V as city,
|
||||
F041V as chairman,
|
||||
F042V as general_manager,
|
||||
UPDATE_DATE as update_date
|
||||
FROM ea_baseinfo
|
||||
WHERE SECCODE = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
await cursor.execute(query, (seccode,))
|
||||
result = await cursor.fetchone()
|
||||
|
||||
if result:
|
||||
return convert_row(result)
|
||||
return None
|
||||
|
||||
|
||||
async def get_stock_financial_index(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票财务指标
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期 YYYY-MM-DD
|
||||
end_date: 结束日期 YYYY-MM-DD
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
财务指标列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 构建查询
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ENDDATE, STARTDATE,
|
||||
F069D as report_year,
|
||||
F003N as eps, -- 每股收益
|
||||
F004N as basic_eps,
|
||||
F008N as bps, -- 每股净资产
|
||||
F014N as roe, -- 净资产收益率
|
||||
F016N as roa, -- 总资产报酬率
|
||||
F017N as net_profit_margin, -- 净利润率
|
||||
F022N as receivable_turnover, -- 应收账款周转率
|
||||
F023N as inventory_turnover, -- 存货周转率
|
||||
F025N as total_asset_turnover, -- 总资产周转率
|
||||
F041N as debt_ratio, -- 资产负债率
|
||||
F042N as current_ratio, -- 流动比率
|
||||
F043N as quick_ratio, -- 速动比率
|
||||
F052N as revenue_growth, -- 营业收入增长率
|
||||
F053N as profit_growth, -- 净利润增长率
|
||||
F089N as revenue, -- 营业收入
|
||||
F090N as operating_cost, -- 营业成本
|
||||
F101N as net_profit, -- 净利润
|
||||
F102N as net_profit_parent -- 归母净利润
|
||||
FROM ea_financialindex
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND ENDDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND ENDDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_trade_data(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 30
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取股票交易数据
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期 YYYY-MM-DD
|
||||
end_date: 结束日期 YYYY-MM-DD
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
交易数据列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, TRADEDATE,
|
||||
F002N as prev_close, -- 昨日收盘价
|
||||
F003N as open_price, -- 开盘价
|
||||
F005N as high_price, -- 最高价
|
||||
F006N as low_price, -- 最低价
|
||||
F007N as close_price, -- 收盘价
|
||||
F004N as volume, -- 成交量
|
||||
F011N as turnover, -- 成交金额
|
||||
F009N as change_amount, -- 涨跌额
|
||||
F010N as change_pct, -- 涨跌幅
|
||||
F012N as turnover_rate, -- 换手率
|
||||
F013N as amplitude, -- 振幅
|
||||
F026N as pe_ratio, -- 市盈率
|
||||
F020N as total_shares, -- 总股本
|
||||
F021N as circulating_shares -- 流通股本
|
||||
FROM ea_trade
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND TRADEDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND TRADEDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY TRADEDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_balance_sheet(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 8
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取资产负债表数据
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
资产负债表数据列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ENDDATE,
|
||||
F001D as report_year,
|
||||
F006N as cash, -- 货币资金
|
||||
F009N as receivables, -- 应收账款
|
||||
F015N as inventory, -- 存货
|
||||
F019N as current_assets, -- 流动资产合计
|
||||
F023N as long_term_investment, -- 长期股权投资
|
||||
F025N as fixed_assets, -- 固定资产
|
||||
F037N as noncurrent_assets, -- 非流动资产合计
|
||||
F038N as total_assets, -- 资产总计
|
||||
F039N as short_term_loan, -- 短期借款
|
||||
F042N as payables, -- 应付账款
|
||||
F052N as current_liabilities, -- 流动负债合计
|
||||
F053N as long_term_loan, -- 长期借款
|
||||
F060N as noncurrent_liabilities, -- 非流动负债合计
|
||||
F061N as total_liabilities, -- 负债合计
|
||||
F062N as share_capital, -- 股本
|
||||
F063N as capital_reserve, -- 资本公积
|
||||
F065N as retained_earnings, -- 未分配利润
|
||||
F070N as total_equity -- 所有者权益合计
|
||||
FROM ea_asset
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND ENDDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND ENDDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_cashflow(
|
||||
seccode: str,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 8
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取现金流量表数据
|
||||
|
||||
Args:
|
||||
seccode: 股票代码
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
现金流量表数据列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT
|
||||
SECCODE, SECNAME, ENDDATE, STARTDATE,
|
||||
F001D as report_year,
|
||||
F009N as operating_cash_inflow, -- 经营活动现金流入
|
||||
F014N as operating_cash_outflow, -- 经营活动现金流出
|
||||
F015N as net_operating_cashflow, -- 经营活动现金流量净额
|
||||
F021N as investing_cash_inflow, -- 投资活动现金流入
|
||||
F026N as investing_cash_outflow, -- 投资活动现金流出
|
||||
F027N as net_investing_cashflow, -- 投资活动现金流量净额
|
||||
F031N as financing_cash_inflow, -- 筹资活动现金流入
|
||||
F035N as financing_cash_outflow, -- 筹资活动现金流出
|
||||
F036N as net_financing_cashflow, -- 筹资活动现金流量净额
|
||||
F039N as net_cash_increase, -- 现金及现金等价物净增加额
|
||||
F044N as net_profit, -- 净利润
|
||||
F046N as depreciation, -- 固定资产折旧
|
||||
F060N as net_operating_cashflow_adjusted -- 经营活动现金流量净额(补充)
|
||||
FROM ea_cashflow
|
||||
WHERE SECCODE = %s
|
||||
"""
|
||||
|
||||
params = [seccode]
|
||||
|
||||
if start_date:
|
||||
query += " AND ENDDATE >= %s"
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
query += " AND ENDDATE <= %s"
|
||||
params.append(end_date)
|
||||
|
||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def search_stocks_by_criteria(
|
||||
industry: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
min_market_cap: Optional[float] = None,
|
||||
max_market_cap: Optional[float] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
按条件搜索股票
|
||||
|
||||
Args:
|
||||
industry: 行业名称
|
||||
province: 省份
|
||||
min_market_cap: 最小市值(亿元)
|
||||
max_market_cap: 最大市值(亿元)
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
股票列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
SELECT DISTINCT
|
||||
b.SECCODE,
|
||||
b.SECNAME,
|
||||
b.F030V as industry_level1,
|
||||
b.F032V as industry_level2,
|
||||
b.F034V as sw_industry_level1,
|
||||
b.F026V as province,
|
||||
b.F028V as city,
|
||||
b.F015V as main_business,
|
||||
t.F007N as latest_price,
|
||||
t.F010N as change_pct,
|
||||
t.F026N as pe_ratio,
|
||||
t.TRADEDATE as latest_trade_date
|
||||
FROM ea_baseinfo b
|
||||
LEFT JOIN (
|
||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
||||
FROM ea_trade
|
||||
GROUP BY SECCODE
|
||||
) latest ON b.SECCODE = latest.SECCODE
|
||||
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
|
||||
AND t.TRADEDATE = latest.max_date
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
if industry:
|
||||
query += " AND (b.F030V LIKE %s OR b.F032V LIKE %s OR b.F034V LIKE %s)"
|
||||
pattern = f"%{industry}%"
|
||||
params.extend([pattern, pattern, pattern])
|
||||
|
||||
if province:
|
||||
query += " AND b.F026V = %s"
|
||||
params.append(province)
|
||||
|
||||
if min_market_cap or max_market_cap:
|
||||
# 市值 = 最新价 * 总股本 / 100000000(转换为亿元)
|
||||
if min_market_cap:
|
||||
query += " AND (t.F007N * t.F020N / 100000000) >= %s"
|
||||
params.append(min_market_cap)
|
||||
|
||||
if max_market_cap:
|
||||
query += " AND (t.F007N * t.F020N / 100000000) <= %s"
|
||||
params.append(max_market_cap)
|
||||
|
||||
query += " ORDER BY t.TRADEDATE DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
await cursor.execute(query, params)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_stock_comparison(
|
||||
seccodes: List[str],
|
||||
metric: str = "financial"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
股票对比分析
|
||||
|
||||
Args:
|
||||
seccodes: 股票代码列表
|
||||
metric: 对比指标类型 (financial/trade)
|
||||
|
||||
Returns:
|
||||
对比数据
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
if not seccodes or len(seccodes) < 2:
|
||||
return {"error": "至少需要2个股票代码进行对比"}
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
placeholders = ','.join(['%s'] * len(seccodes))
|
||||
|
||||
if metric == "financial":
|
||||
# 对比最新财务指标
|
||||
query = f"""
|
||||
SELECT
|
||||
f.SECCODE, f.SECNAME, f.ENDDATE,
|
||||
f.F003N as eps,
|
||||
f.F008N as bps,
|
||||
f.F014N as roe,
|
||||
f.F017N as net_profit_margin,
|
||||
f.F041N as debt_ratio,
|
||||
f.F052N as revenue_growth,
|
||||
f.F053N as profit_growth,
|
||||
f.F089N as revenue,
|
||||
f.F101N as net_profit
|
||||
FROM ea_financialindex f
|
||||
INNER JOIN (
|
||||
SELECT SECCODE, MAX(ENDDATE) as max_date
|
||||
FROM ea_financialindex
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
GROUP BY SECCODE
|
||||
) latest ON f.SECCODE = latest.SECCODE
|
||||
AND f.ENDDATE = latest.max_date
|
||||
"""
|
||||
else: # trade
|
||||
# 对比最新交易数据
|
||||
query = f"""
|
||||
SELECT
|
||||
t.SECCODE, t.SECNAME, t.TRADEDATE,
|
||||
t.F007N as close_price,
|
||||
t.F010N as change_pct,
|
||||
t.F012N as turnover_rate,
|
||||
t.F026N as pe_ratio,
|
||||
t.F020N as total_shares,
|
||||
t.F021N as circulating_shares
|
||||
FROM ea_trade t
|
||||
INNER JOIN (
|
||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
GROUP BY SECCODE
|
||||
) latest ON t.SECCODE = latest.SECCODE
|
||||
AND t.TRADEDATE = latest.max_date
|
||||
"""
|
||||
|
||||
await cursor.execute(query, seccodes)
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return {
|
||||
"comparison_type": metric,
|
||||
"stocks": [convert_row(row) for row in results]
|
||||
}
|
||||
|
||||
|
||||
async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户自选股列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
自选股列表(包含最新行情数据)
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 查询用户自选股(假设有 user_favorites 表)
|
||||
# 如果没有此表,可以根据实际情况调整
|
||||
query = """
|
||||
SELECT
|
||||
f.user_id,
|
||||
f.stock_code,
|
||||
b.SECNAME as stock_name,
|
||||
b.F030V as industry,
|
||||
t.F007N as current_price,
|
||||
t.F010N as change_pct,
|
||||
t.F012N as turnover_rate,
|
||||
t.F026N as pe_ratio,
|
||||
t.TRADEDATE as latest_trade_date,
|
||||
f.created_at as favorite_time
|
||||
FROM user_favorites f
|
||||
INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE
|
||||
LEFT JOIN (
|
||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
||||
FROM ea_trade
|
||||
GROUP BY SECCODE
|
||||
) latest ON b.SECCODE = latest.SECCODE
|
||||
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
|
||||
AND t.TRADEDATE = latest.max_date
|
||||
WHERE f.user_id = %s AND f.is_deleted = 0
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
await cursor.execute(query, [user_id, limit])
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户自选事件列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
自选事件列表
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 查询用户自选事件(假设有 user_event_favorites 表)
|
||||
query = """
|
||||
SELECT
|
||||
f.user_id,
|
||||
f.event_id,
|
||||
e.title,
|
||||
e.description,
|
||||
e.event_date,
|
||||
e.importance,
|
||||
e.related_stocks,
|
||||
e.category,
|
||||
f.created_at as favorite_time
|
||||
FROM user_event_favorites f
|
||||
INNER JOIN events e ON f.event_id = e.id
|
||||
WHERE f.user_id = %s AND f.is_deleted = 0
|
||||
ORDER BY e.event_date DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
await cursor.execute(query, [user_id, limit])
|
||||
results = await cursor.fetchall()
|
||||
|
||||
return [convert_row(row) for row in results]
|
||||
|
||||
|
||||
async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
添加自选股
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 检查是否已存在
|
||||
check_query = """
|
||||
SELECT id, is_deleted
|
||||
FROM user_favorites
|
||||
WHERE user_id = %s AND stock_code = %s
|
||||
"""
|
||||
await cursor.execute(check_query, [user_id, stock_code])
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing['is_deleted'] == 1:
|
||||
# 恢复已删除的记录
|
||||
update_query = """
|
||||
UPDATE user_favorites
|
||||
SET is_deleted = 0, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
await cursor.execute(update_query, [existing['id']])
|
||||
return {"success": True, "message": "已恢复自选股"}
|
||||
else:
|
||||
return {"success": False, "message": "该股票已在自选中"}
|
||||
|
||||
# 插入新记录
|
||||
insert_query = """
|
||||
INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted)
|
||||
VALUES (%s, %s, NOW(), NOW(), 0)
|
||||
"""
|
||||
await cursor.execute(insert_query, [user_id, stock_code])
|
||||
return {"success": True, "message": "添加自选股成功"}
|
||||
|
||||
|
||||
async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
删除自选股
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
stock_code: 股票代码
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
UPDATE user_favorites
|
||||
SET is_deleted = 1, updated_at = NOW()
|
||||
WHERE user_id = %s AND stock_code = %s AND is_deleted = 0
|
||||
"""
|
||||
result = await cursor.execute(query, [user_id, stock_code])
|
||||
|
||||
if result > 0:
|
||||
return {"success": True, "message": "删除自选股成功"}
|
||||
else:
|
||||
return {"success": False, "message": "未找到该自选股"}
|
||||
|
||||
|
||||
async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
添加自选事件
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
event_id: 事件ID
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
# 检查是否已存在
|
||||
check_query = """
|
||||
SELECT id, is_deleted
|
||||
FROM user_event_favorites
|
||||
WHERE user_id = %s AND event_id = %s
|
||||
"""
|
||||
await cursor.execute(check_query, [user_id, event_id])
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing['is_deleted'] == 1:
|
||||
# 恢复已删除的记录
|
||||
update_query = """
|
||||
UPDATE user_event_favorites
|
||||
SET is_deleted = 0, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
await cursor.execute(update_query, [existing['id']])
|
||||
return {"success": True, "message": "已恢复自选事件"}
|
||||
else:
|
||||
return {"success": False, "message": "该事件已在自选中"}
|
||||
|
||||
# 插入新记录
|
||||
insert_query = """
|
||||
INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted)
|
||||
VALUES (%s, %s, NOW(), NOW(), 0)
|
||||
"""
|
||||
await cursor.execute(insert_query, [user_id, event_id])
|
||||
return {"success": True, "message": "添加自选事件成功"}
|
||||
|
||||
|
||||
async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
删除自选事件
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
event_id: 事件ID
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
query = """
|
||||
UPDATE user_event_favorites
|
||||
SET is_deleted = 1, updated_at = NOW()
|
||||
WHERE user_id = %s AND event_id = %s AND is_deleted = 0
|
||||
"""
|
||||
result = await cursor.execute(query, [user_id, event_id])
|
||||
|
||||
if result > 0:
|
||||
return {"success": True, "message": "删除自选事件成功"}
|
||||
else:
|
||||
return {"success": False, "message": "未找到该自选事件"}
|
||||
320
mcp_elasticsearch.py
Normal file
320
mcp_elasticsearch.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Elasticsearch 连接和工具模块
|
||||
用于聊天记录存储和向量搜索
|
||||
"""
|
||||
|
||||
from elasticsearch import Elasticsearch, helpers
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
import json
|
||||
import openai
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
# ES 配置
|
||||
ES_CONFIG = {
|
||||
"host": "http://222.128.1.157:19200",
|
||||
"index_chat_history": "agent_chat_history", # 聊天记录索引
|
||||
}
|
||||
|
||||
# Embedding 配置
|
||||
EMBEDDING_CONFIG = {
|
||||
"api_key": "dummy",
|
||||
"base_url": "http://222.128.1.157:18008/v1",
|
||||
"model": "qwen3-embedding-8b",
|
||||
"dims": 4096, # 向量维度
|
||||
}
|
||||
|
||||
# ==================== ES 客户端 ====================
|
||||
|
||||
class ESClient:
|
||||
"""Elasticsearch 客户端封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60)
|
||||
self.chat_index = ES_CONFIG["index_chat_history"]
|
||||
|
||||
# 初始化 OpenAI 客户端用于 embedding
|
||||
self.embedding_client = openai.OpenAI(
|
||||
api_key=EMBEDDING_CONFIG["api_key"],
|
||||
base_url=EMBEDDING_CONFIG["base_url"],
|
||||
)
|
||||
self.embedding_model = EMBEDDING_CONFIG["model"]
|
||||
|
||||
# 初始化索引
|
||||
self.create_chat_history_index()
|
||||
|
||||
def create_chat_history_index(self):
|
||||
"""创建聊天记录索引"""
|
||||
if self.es.indices.exists(index=self.chat_index):
|
||||
logger.info(f"索引 {self.chat_index} 已存在")
|
||||
return
|
||||
|
||||
mappings = {
|
||||
"properties": {
|
||||
"session_id": {"type": "keyword"}, # 会话ID
|
||||
"user_id": {"type": "keyword"}, # 用户ID
|
||||
"user_nickname": {"type": "text"}, # 用户昵称
|
||||
"user_avatar": {"type": "keyword"}, # 用户头像URL
|
||||
"message_type": {"type": "keyword"}, # user / assistant
|
||||
"message": {"type": "text"}, # 消息内容
|
||||
"message_embedding": { # 消息向量
|
||||
"type": "dense_vector",
|
||||
"dims": EMBEDDING_CONFIG["dims"],
|
||||
"index": True,
|
||||
"similarity": "cosine"
|
||||
},
|
||||
"plan": {"type": "text"}, # 执行计划(仅 assistant)
|
||||
"steps": {"type": "text"}, # 执行步骤(仅 assistant)
|
||||
"timestamp": {"type": "date"}, # 时间戳
|
||||
"created_at": {"type": "date"}, # 创建时间
|
||||
}
|
||||
}
|
||||
|
||||
self.es.indices.create(index=self.chat_index, body={"mappings": mappings})
|
||||
logger.info(f"创建索引: {self.chat_index}")
|
||||
|
||||
def generate_embedding(self, text: str) -> List[float]:
|
||||
"""生成文本向量"""
|
||||
try:
|
||||
if not text or len(text.strip()) == 0:
|
||||
return []
|
||||
|
||||
# 截断过长文本
|
||||
text = text[:16000] if len(text) > 16000 else text
|
||||
|
||||
response = self.embedding_client.embeddings.create(
|
||||
model=self.embedding_model,
|
||||
input=[text]
|
||||
)
|
||||
return response.data[0].embedding
|
||||
except Exception as e:
|
||||
logger.error(f"Embedding 生成失败: {e}")
|
||||
return []
|
||||
|
||||
def save_chat_message(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
user_nickname: str,
|
||||
user_avatar: str,
|
||||
message_type: str, # "user" or "assistant"
|
||||
message: str,
|
||||
plan: Optional[str] = None,
|
||||
steps: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
保存聊天消息
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
user_id: 用户ID
|
||||
user_nickname: 用户昵称
|
||||
user_avatar: 用户头像URL
|
||||
message_type: 消息类型 (user/assistant)
|
||||
message: 消息内容
|
||||
plan: 执行计划(可选)
|
||||
steps: 执行步骤(可选)
|
||||
|
||||
Returns:
|
||||
文档ID
|
||||
"""
|
||||
try:
|
||||
# 生成向量
|
||||
embedding = self.generate_embedding(message)
|
||||
|
||||
doc = {
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"user_nickname": user_nickname,
|
||||
"user_avatar": user_avatar,
|
||||
"message_type": message_type,
|
||||
"message": message,
|
||||
"message_embedding": embedding if embedding else None,
|
||||
"plan": plan,
|
||||
"steps": steps,
|
||||
"timestamp": datetime.now(),
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
|
||||
result = self.es.index(index=self.chat_index, body=doc)
|
||||
logger.info(f"保存聊天记录: {result['_id']}")
|
||||
return result["_id"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存聊天记录失败: {e}")
|
||||
raise
|
||||
|
||||
def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户的聊天会话列表
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
会话列表,每个会话包含:session_id, last_message, last_timestamp
|
||||
"""
|
||||
try:
|
||||
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
|
||||
query = {
|
||||
"query": {
|
||||
"term": {"user_id": user_id}
|
||||
},
|
||||
"aggs": {
|
||||
"sessions": {
|
||||
"terms": {
|
||||
"field": "session_id",
|
||||
"size": limit,
|
||||
"order": {"last_message": "desc"}
|
||||
},
|
||||
"aggs": {
|
||||
"last_message": {
|
||||
"max": {"field": "timestamp"}
|
||||
},
|
||||
"last_message_content": {
|
||||
"top_hits": {
|
||||
"size": 1,
|
||||
"sort": [{"timestamp": {"order": "desc"}}],
|
||||
"_source": ["message", "timestamp", "message_type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": 0
|
||||
}
|
||||
|
||||
result = self.es.search(index=self.chat_index, body=query)
|
||||
|
||||
sessions = []
|
||||
for bucket in result["aggregations"]["sessions"]["buckets"]:
|
||||
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
|
||||
sessions.append({
|
||||
"session_id": bucket["key"],
|
||||
"last_message": session_data["message"],
|
||||
"last_timestamp": session_data["timestamp"],
|
||||
"message_count": bucket["doc_count"],
|
||||
})
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取会话列表失败: {e}")
|
||||
return []
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定会话的聊天历史
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
limit: 返回数量
|
||||
|
||||
Returns:
|
||||
聊天记录列表
|
||||
"""
|
||||
try:
|
||||
query = {
|
||||
"query": {
|
||||
"term": {"session_id": session_id}
|
||||
},
|
||||
"sort": [{"timestamp": {"order": "asc"}}],
|
||||
"size": limit
|
||||
}
|
||||
|
||||
result = self.es.search(index=self.chat_index, body=query)
|
||||
|
||||
messages = []
|
||||
for hit in result["hits"]["hits"]:
|
||||
doc = hit["_source"]
|
||||
messages.append({
|
||||
"message_type": doc["message_type"],
|
||||
"message": doc["message"],
|
||||
"plan": doc.get("plan"),
|
||||
"steps": doc.get("steps"),
|
||||
"timestamp": doc["timestamp"],
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取聊天历史失败: {e}")
|
||||
return []
|
||||
|
||||
def search_chat_history(
|
||||
self,
|
||||
user_id: str,
|
||||
query_text: str,
|
||||
top_k: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
向量搜索聊天历史
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
query_text: 查询文本
|
||||
top_k: 返回数量
|
||||
|
||||
Returns:
|
||||
相关聊天记录列表
|
||||
"""
|
||||
try:
|
||||
# 生成查询向量
|
||||
query_embedding = self.generate_embedding(query_text)
|
||||
if not query_embedding:
|
||||
return []
|
||||
|
||||
# 向量搜索
|
||||
query = {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{"term": {"user_id": user_id}},
|
||||
{
|
||||
"script_score": {
|
||||
"query": {"match_all": {}},
|
||||
"script": {
|
||||
"source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0",
|
||||
"params": {"query_vector": query_embedding}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"size": top_k
|
||||
}
|
||||
|
||||
result = self.es.search(index=self.chat_index, body=query)
|
||||
|
||||
messages = []
|
||||
for hit in result["hits"]["hits"]:
|
||||
doc = hit["_source"]
|
||||
messages.append({
|
||||
"session_id": doc["session_id"],
|
||||
"message_type": doc["message_type"],
|
||||
"message": doc["message"],
|
||||
"timestamp": doc["timestamp"],
|
||||
"score": hit["_score"],
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"向量搜索失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ==================== 全局实例 ====================
|
||||
|
||||
# 创建全局 ES 客户端
|
||||
es_client = ESClient()
|
||||
2383
mcp_server.py
Normal file
2383
mcp_server.py
Normal file
File diff suppressed because it is too large
Load Diff
134
migrations/add_promo_code_tables.sql
Normal file
134
migrations/add_promo_code_tables.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
-- 数据库迁移脚本:添加优惠码和订阅升级相关表
|
||||
-- 执行时间:2025-xx-xx
|
||||
-- 作者:Claude Code
|
||||
-- 说明:此脚本添加了优惠码、优惠码使用记录和订阅升级记录三张新表,并扩展了 payment_orders 表
|
||||
|
||||
-- ============================================
|
||||
-- 1. 创建优惠码表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `promo_codes` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`code` VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码(唯一)',
|
||||
`description` VARCHAR(200) DEFAULT NULL COMMENT '优惠码描述',
|
||||
|
||||
-- 折扣类型和值
|
||||
`discount_type` VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比) 或 fixed_amount(固定金额)',
|
||||
`discount_value` DECIMAL(10, 2) NOT NULL COMMENT '折扣值',
|
||||
|
||||
-- 适用范围
|
||||
`applicable_plans` VARCHAR(200) DEFAULT NULL COMMENT '适用套餐(JSON格式),如 ["pro", "max"],null表示全部适用',
|
||||
`applicable_cycles` VARCHAR(50) DEFAULT NULL COMMENT '适用周期(JSON格式),如 ["monthly", "yearly"],null表示全部适用',
|
||||
`min_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '最低消费金额',
|
||||
|
||||
-- 使用限制
|
||||
`max_uses` INT DEFAULT NULL COMMENT '最大使用次数,null表示无限制',
|
||||
`max_uses_per_user` INT DEFAULT 1 COMMENT '每个用户最多使用次数',
|
||||
`current_uses` INT DEFAULT 0 COMMENT '当前已使用次数',
|
||||
|
||||
-- 有效期
|
||||
`valid_from` DATETIME NOT NULL COMMENT '生效时间',
|
||||
`valid_until` DATETIME NOT NULL COMMENT '失效时间',
|
||||
|
||||
-- 状态
|
||||
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
`created_by` INT DEFAULT NULL COMMENT '创建人(管理员ID)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_code (`code`),
|
||||
INDEX idx_valid_dates (`valid_from`, `valid_until`),
|
||||
INDEX idx_is_active (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 2. 创建优惠码使用记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `promo_code_usage` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`promo_code_id` INT NOT NULL COMMENT '优惠码ID',
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`order_id` INT NOT NULL COMMENT '订单ID',
|
||||
|
||||
`original_amount` DECIMAL(10, 2) NOT NULL COMMENT '原价',
|
||||
`discount_amount` DECIMAL(10, 2) NOT NULL COMMENT '优惠金额',
|
||||
`final_amount` DECIMAL(10, 2) NOT NULL COMMENT '实付金额',
|
||||
|
||||
`used_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '使用时间',
|
||||
|
||||
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_user_id (`user_id`),
|
||||
INDEX idx_promo_code_id (`promo_code_id`),
|
||||
INDEX idx_order_id (`order_id`),
|
||||
INDEX idx_used_at (`used_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 3. 创建订阅升级记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `subscription_upgrades` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`order_id` INT NOT NULL COMMENT '订单ID',
|
||||
|
||||
-- 原订阅信息
|
||||
`from_plan` VARCHAR(20) NOT NULL COMMENT '原套餐',
|
||||
`from_cycle` VARCHAR(10) NOT NULL COMMENT '原周期',
|
||||
`from_end_date` DATETIME DEFAULT NULL COMMENT '原到期日',
|
||||
|
||||
-- 新订阅信息
|
||||
`to_plan` VARCHAR(20) NOT NULL COMMENT '新套餐',
|
||||
`to_cycle` VARCHAR(10) NOT NULL COMMENT '新周期',
|
||||
`to_end_date` DATETIME NOT NULL COMMENT '新到期日',
|
||||
|
||||
-- 价格计算
|
||||
`remaining_value` DECIMAL(10, 2) NOT NULL COMMENT '剩余价值',
|
||||
`upgrade_amount` DECIMAL(10, 2) NOT NULL COMMENT '升级应付金额',
|
||||
`actual_amount` DECIMAL(10, 2) NOT NULL COMMENT '实际支付金额',
|
||||
|
||||
`upgrade_type` VARCHAR(20) NOT NULL COMMENT '升级类型: plan_upgrade(套餐升级), cycle_change(周期变更), both(都变更)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_user_id (`user_id`),
|
||||
INDEX idx_order_id (`order_id`),
|
||||
INDEX idx_created_at (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅升级/降级记录表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 4. 扩展 payment_orders 表(添加新字段)
|
||||
-- ============================================
|
||||
-- 注意:这些字段是可选的扩展,用于记录优惠码和升级信息
|
||||
-- 如果字段已存在会报错,可以忽略
|
||||
|
||||
ALTER TABLE `payment_orders`
|
||||
ADD COLUMN `promo_code_id` INT DEFAULT NULL COMMENT '使用的优惠码ID' AFTER `remark`,
|
||||
ADD COLUMN `original_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '原价(使用优惠码前)' AFTER `promo_code_id`,
|
||||
ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0 COMMENT '优惠金额' AFTER `original_amount`,
|
||||
ADD COLUMN `is_upgrade` BOOLEAN DEFAULT FALSE COMMENT '是否为升级订单' AFTER `discount_amount`,
|
||||
ADD COLUMN `upgrade_from_plan` VARCHAR(20) DEFAULT NULL COMMENT '从哪个套餐升级' AFTER `is_upgrade`;
|
||||
|
||||
-- 添加外键约束
|
||||
ALTER TABLE `payment_orders`
|
||||
ADD CONSTRAINT `fk_payment_orders_promo_code`
|
||||
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE SET NULL;
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 5. 插入示例优惠码(供测试使用)
|
||||
-- ============================================
|
||||
-- 10% 折扣优惠码,适用所有套餐和周期
|
||||
INSERT INTO `promo_codes`
|
||||
(`code`, `description`, `discount_type`, `discount_value`, `applicable_plans`, `applicable_cycles`, `min_amount`, `max_uses`, `max_uses_per_user`, `valid_from`, `valid_until`, `is_active`)
|
||||
VALUES
|
||||
('WELCOME10', '新用户欢迎优惠 - 10%折扣', 'percentage', 10.00, NULL, NULL, NULL, NULL, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
|
||||
('ANNUAL20', '年付专享 - 20%折扣', 'percentage', 20.00, NULL, '["yearly"]', NULL, 100, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
|
||||
('SUMMER50', '夏季促销 - 减免50元', 'fixed_amount', 50.00, '["max"]', NULL, 100.00, 50, 1, NOW(), DATE_ADD(NOW(), INTERVAL 3 MONTH), TRUE);
|
||||
|
||||
-- 完成
|
||||
SELECT 'Migration completed successfully!' AS status;
|
||||
12
package.json
12
package.json
@@ -18,10 +18,9 @@
|
||||
"@fullcalendar/daygrid": "^5.9.0",
|
||||
"@fullcalendar/interaction": "^5.9.0",
|
||||
"@fullcalendar/react": "^5.9.0",
|
||||
"@react-three/drei": "^9.11.3",
|
||||
"@react-three/fiber": "^8.0.27",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
@@ -38,7 +37,6 @@
|
||||
"fullcalendar": "^5.9.0",
|
||||
"globalize": "^1.7.0",
|
||||
"history": "^5.3.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
@@ -58,7 +56,6 @@
|
||||
"react-input-pin-code": "^1.1.5",
|
||||
"react-just-parallax": "^3.1.16",
|
||||
"react-jvectormap": "0.0.16",
|
||||
"react-leaflet": "^3.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-quill": "^2.0.0-beta.4",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -81,7 +78,6 @@
|
||||
"styled-components": "^5.3.11",
|
||||
"stylis": "^4.0.10",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"three": "^0.142.0",
|
||||
"tsparticles-slim": "^2.12.0"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -95,12 +91,14 @@
|
||||
"scripts": {
|
||||
"prestart": "kill-port 3000",
|
||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||
"prestart:real": "kill-port 3000",
|
||||
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
|
||||
"prestart:dev": "kill-port 3000",
|
||||
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
|
||||
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"start:test": "concurrently \"python app.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
|
||||
"dev": "npm start",
|
||||
"backend": "python app_2.py",
|
||||
"backend": "python app.py",
|
||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
|
||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||
"test": "craco test --env=jsdom",
|
||||
|
||||
BIN
public/badge.png
Normal file
BIN
public/badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -65,6 +65,9 @@
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<!-- ============================================
|
||||
Dify 机器人配置 - 只在 /home 页面显示
|
||||
============================================ -->
|
||||
<script>
|
||||
window.difyChatbotConfig = {
|
||||
token: 'DwN8qAKtYFQtWskM',
|
||||
@@ -85,6 +88,44 @@
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Dify 机器人显示控制脚本 -->
|
||||
<script>
|
||||
// 控制 Dify 机器人只在 /home 页面显示
|
||||
function controlDifyVisibility() {
|
||||
const currentPath = window.location.pathname;
|
||||
const difyChatButton = document.getElementById('dify-chatbot-bubble-button');
|
||||
|
||||
if (difyChatButton) {
|
||||
// 只在 /home 页面显示
|
||||
if (currentPath === '/home') {
|
||||
difyChatButton.style.display = 'none';
|
||||
console.log('[Dify] 显示机器人(当前路径: /home)');
|
||||
} else {
|
||||
difyChatButton.style.display = 'none';
|
||||
console.log('[Dify] 隐藏机器人(当前路径:', currentPath, ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后执行
|
||||
window.addEventListener('load', function() {
|
||||
console.log('[Dify] 初始化显示控制');
|
||||
|
||||
// 初始检查(延迟执行,等待 Dify 按钮渲染)
|
||||
setTimeout(controlDifyVisibility, 500);
|
||||
setTimeout(controlDifyVisibility, 1500);
|
||||
|
||||
// 监听路由变化(React Router 使用 pushState)
|
||||
const observer = setInterval(controlDifyVisibility, 1000);
|
||||
|
||||
// 清理函数(可选)
|
||||
window.addEventListener('beforeunload', function() {
|
||||
clearInterval(observer);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script
|
||||
src="https://app.valuefrontier.cn/embed.min.js"
|
||||
id="DwN8qAKtYFQtWskM"
|
||||
@@ -166,7 +207,7 @@
|
||||
bottom: 80px !important;
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
|
||||
#dify-chatbot-bubble-button {
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
|
||||
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -4,8 +4,24 @@
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "badge.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "badge"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "apple-icon.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.11.6'
|
||||
const PACKAGE_VERSION = '2.12.0'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
92
public/service-worker.js
Normal file
92
public/service-worker.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// public/service-worker.js
|
||||
/**
|
||||
* Service Worker for Browser Notifications
|
||||
* 主要功能:支持浏览器通知的稳定运行
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'valuefrontier-v1';
|
||||
|
||||
// Service Worker 安装事件
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Installing...');
|
||||
// 跳过等待,立即激活
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Service Worker 激活事件
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[Service Worker] Activating...');
|
||||
// 立即接管所有页面
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// 通知点击事件
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[Service Worker] Notification clicked:', event.notification.tag);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
// 获取通知数据中的链接
|
||||
const urlToOpen = event.notification.data?.link;
|
||||
|
||||
if (urlToOpen) {
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((windowClients) => {
|
||||
// 查找是否已有打开的窗口
|
||||
for (let client of windowClients) {
|
||||
if (client.url.includes(window.location.origin) && 'focus' in client) {
|
||||
// 聚焦现有窗口并导航到目标页面
|
||||
return client.focus().then(client => {
|
||||
return client.navigate(urlToOpen);
|
||||
});
|
||||
}
|
||||
}
|
||||
// 如果没有打开的窗口,打开新窗口
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 通知关闭事件
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[Service Worker] Notification closed:', event.notification.tag);
|
||||
});
|
||||
|
||||
// Fetch 事件 - 基础的网络优先策略
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// 对于通知相关的资源,使用网络优先策略
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => {
|
||||
// 网络失败时,尝试从缓存获取
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 推送消息事件(预留,用于未来的 Push API 集成)
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] Push message received:', event);
|
||||
|
||||
if (event.data) {
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body || '您有新消息',
|
||||
icon: data.icon || '/favicon.png',
|
||||
badge: '/favicon.png',
|
||||
data: data.data || {},
|
||||
requireInteraction: data.requireInteraction || false,
|
||||
tag: data.tag || `notification_${Date.now()}`,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || '价值前沿', options)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Service Worker] Loaded successfully');
|
||||
156
src/bytedesk-integration/.env.bytedesk.example
Normal file
156
src/bytedesk-integration/.env.bytedesk.example
Normal file
@@ -0,0 +1,156 @@
|
||||
################################################################################
|
||||
# Bytedesk客服系统环境变量配置示例
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 复制本文件到vf_react项目根目录(与package.json同级)
|
||||
# cp bytedesk-integration/.env.bytedesk.example .env.local
|
||||
#
|
||||
# 2. 根据实际部署环境修改配置值
|
||||
#
|
||||
# 3. 重启开发服务器使配置生效
|
||||
# npm start
|
||||
#
|
||||
# 注意事项:
|
||||
# - .env.local文件不应提交到Git(已在.gitignore中)
|
||||
# - 开发环境和生产环境应使用不同的配置文件
|
||||
# - 所有以REACT_APP_开头的变量会被打包到前端代码中
|
||||
################################################################################
|
||||
|
||||
# ============================================================================
|
||||
# Bytedesk服务器配置(必需)
|
||||
# ============================================================================
|
||||
|
||||
# Bytedesk后端服务地址(生产环境)
|
||||
# 格式: http://IP地址 或 https://域名
|
||||
# 示例: http://43.143.189.195 或 https://kefu.yourdomain.com
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# ============================================================================
|
||||
# Bytedesk组织和工作组配置(必需)
|
||||
# ============================================================================
|
||||
|
||||
# 组织ID(Organization UID)
|
||||
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 复制UID
|
||||
# 示例: df_org_uid
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID(Workgroup SID)
|
||||
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 复制工作组ID
|
||||
# 示例: df_wg_aftersales (售后服务组)
|
||||
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
|
||||
|
||||
# 是否自动弹出客服窗口(不推荐)
|
||||
# true = 页面加载后自动弹出
|
||||
# false = 需用户点击图标弹出(默认)
|
||||
# REACT_APP_BYTEDESK_AUTO_POPUP=false
|
||||
|
||||
# ============================================================================
|
||||
# 开发环境专用配置
|
||||
# ============================================================================
|
||||
|
||||
# 开发环境可以使用不同的服务器地址
|
||||
# 取消注释以下行使用本地或测试服务器
|
||||
# REACT_APP_BYTEDESK_API_URL_DEV=http://localhost:9003
|
||||
|
||||
# ============================================================================
|
||||
# 配置示例 - 不同部署场景
|
||||
# ============================================================================
|
||||
|
||||
# ---------- 示例1: 生产环境(域名访问) ----------
|
||||
# REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
|
||||
# REACT_APP_BYTEDESK_ORG=prod_org_12345
|
||||
# REACT_APP_BYTEDESK_SID=prod_wg_sales
|
||||
|
||||
# ---------- 示例2: 测试环境(IP访问) ----------
|
||||
# REACT_APP_BYTEDESK_API_URL=http://192.168.1.100
|
||||
# REACT_APP_BYTEDESK_ORG=test_org_abc
|
||||
# REACT_APP_BYTEDESK_SID=test_wg_support
|
||||
|
||||
# ---------- 示例3: 本地开发环境 ----------
|
||||
# REACT_APP_BYTEDESK_API_URL=http://localhost:9003
|
||||
# REACT_APP_BYTEDESK_ORG=dev_org_local
|
||||
# REACT_APP_BYTEDESK_SID=dev_wg_test
|
||||
|
||||
# ============================================================================
|
||||
# 故障排查
|
||||
# ============================================================================
|
||||
|
||||
# 问题1: 客服图标不显示
|
||||
# 解决方案:
|
||||
# - 检查REACT_APP_BYTEDESK_API_URL是否可访问
|
||||
# - 确认.env文件在项目根目录
|
||||
# - 重启开发服务器(npm start)
|
||||
# - 查看浏览器控制台是否有错误
|
||||
|
||||
# 问题2: 连接不上后端服务
|
||||
# 解决方案:
|
||||
# - 确认后端服务已启动(docker ps查看bytedesk-prod容器)
|
||||
# - 检查CORS配置(后端.env.production中的BYTEDESK_CORS_ALLOWED_ORIGINS)
|
||||
# - 确认防火墙未阻止80/443端口
|
||||
|
||||
# 问题3: ORG或SID配置错误
|
||||
# 解决方案:
|
||||
# - 登录管理后台http://43.143.189.195/admin/
|
||||
# - 导航到"设置" -> "组织信息"获取ORG
|
||||
# - 导航到"客服管理" -> "工作组"获取SID
|
||||
# - 确保复制的ID没有多余空格
|
||||
|
||||
# 问题4: 多工作组场景
|
||||
# 解决方案:
|
||||
# - 可以为不同页面配置不同的SID
|
||||
# - 在bytedesk.config.js中使用条件判断
|
||||
# - 示例: 售后页面用售后组SID,销售页面用销售组SID
|
||||
|
||||
# ============================================================================
|
||||
# 安全提示
|
||||
# ============================================================================
|
||||
|
||||
# 1. 不要在代码中硬编码API地址和ID
|
||||
# 2. .env.local文件不应提交到Git仓库
|
||||
# 3. 生产环境建议使用HTTPS
|
||||
# 4. 定期更新后端服务器的安全补丁
|
||||
# 5. 不要在公开的代码库中暴露组织ID和工作组ID
|
||||
|
||||
# ============================================================================
|
||||
# 更多信息
|
||||
# ============================================================================
|
||||
|
||||
# Bytedesk官方文档: https://docs.bytedesk.com
|
||||
# 技术支持: 访问http://43.143.189.195/chat/联系在线客服
|
||||
# GitHub: https://github.com/Bytedesk/bytedesk
|
||||
237
src/bytedesk-integration/App.jsx.example
Normal file
237
src/bytedesk-integration/App.jsx.example
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
140
src/bytedesk-integration/components/BytedeskWidget.jsx
Normal file
140
src/bytedesk-integration/components/BytedeskWidget.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Bytedesk客服Widget组件
|
||||
* 用于vf_react项目集成
|
||||
*
|
||||
* 使用方法:
|
||||
* import BytedeskWidget from './components/BytedeskWidget';
|
||||
* import { getBytedeskConfig } from './config/bytedesk.config';
|
||||
*
|
||||
* <BytedeskWidget
|
||||
* config={getBytedeskConfig()}
|
||||
* autoLoad={true}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const BytedeskWidget = ({
|
||||
config,
|
||||
autoLoad = true,
|
||||
onLoad,
|
||||
onError
|
||||
}) => {
|
||||
const scriptRef = useRef(null);
|
||||
const widgetRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果不自动加载或配置未设置,跳过
|
||||
if (!autoLoad || !config) {
|
||||
if (!config) {
|
||||
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Bytedesk] 开始加载客服Widget...', config);
|
||||
|
||||
// 加载Bytedesk Widget脚本
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
|
||||
script.async = true;
|
||||
script.id = 'bytedesk-web-script';
|
||||
|
||||
script.onload = () => {
|
||||
console.log('[Bytedesk] Widget脚本加载成功');
|
||||
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
console.log('[Bytedesk] 初始化Widget');
|
||||
const bytedesk = new window.BytedeskWeb(config);
|
||||
bytedesk.init();
|
||||
|
||||
widgetRef.current = bytedesk;
|
||||
console.log('[Bytedesk] Widget初始化成功');
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(bytedesk);
|
||||
}
|
||||
} else {
|
||||
throw new Error('BytedeskWeb对象未定义');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Bytedesk] Widget初始化失败:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('[Bytedesk] Widget脚本加载失败:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加脚本到页面
|
||||
document.body.appendChild(script);
|
||||
scriptRef.current = script;
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
console.log('[Bytedesk] 清理Widget');
|
||||
|
||||
// 移除脚本
|
||||
if (scriptRef.current && document.body.contains(scriptRef.current)) {
|
||||
document.body.removeChild(scriptRef.current);
|
||||
}
|
||||
|
||||
// 移除Widget DOM元素
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
if (el && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理全局对象
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
};
|
||||
}, [config, autoLoad, onLoad, onError]);
|
||||
|
||||
// 不渲染任何可见元素(Widget会自动插入到body)
|
||||
return <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
|
||||
};
|
||||
|
||||
BytedeskWidget.propTypes = {
|
||||
config: PropTypes.shape({
|
||||
apiUrl: PropTypes.string.isRequired,
|
||||
htmlUrl: PropTypes.string.isRequired,
|
||||
placement: PropTypes.oneOf(['bottom-right', 'bottom-left', 'top-right', 'top-left']),
|
||||
marginBottom: PropTypes.number,
|
||||
marginSide: PropTypes.number,
|
||||
autoPopup: PropTypes.bool,
|
||||
locale: PropTypes.string,
|
||||
bubbleConfig: PropTypes.shape({
|
||||
show: PropTypes.bool,
|
||||
icon: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
subtitle: PropTypes.string,
|
||||
}),
|
||||
theme: PropTypes.shape({
|
||||
mode: PropTypes.oneOf(['light', 'dark', 'system']),
|
||||
backgroundColor: PropTypes.string,
|
||||
textColor: PropTypes.string,
|
||||
}),
|
||||
chatConfig: PropTypes.shape({
|
||||
org: PropTypes.string.isRequired,
|
||||
t: PropTypes.string.isRequired,
|
||||
sid: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
autoLoad: PropTypes.bool,
|
||||
onLoad: PropTypes.func,
|
||||
onError: PropTypes.func,
|
||||
};
|
||||
|
||||
export default BytedeskWidget;
|
||||
148
src/bytedesk-integration/config/bytedesk.config.js
Normal file
148
src/bytedesk-integration/config/bytedesk.config.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Bytedesk客服配置文件
|
||||
* 指向43.143.189.195服务器
|
||||
*
|
||||
* 环境变量配置(.env文件):
|
||||
* REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
* REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
* REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
*/
|
||||
|
||||
// 从环境变量读取配置
|
||||
const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || 'http://43.143.189.195';
|
||||
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
|
||||
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_aftersales';
|
||||
|
||||
/**
|
||||
* Bytedesk客服基础配置
|
||||
*/
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址
|
||||
apiUrl: BYTEDESK_API_URL,
|
||||
// 聊天页面地址
|
||||
htmlUrl: `${BYTEDESK_API_URL}/chat/`,
|
||||
// SDK 资源基础路径(用于加载内部模块 sdk.js, index.js 等)
|
||||
baseUrl: 'https://www.weiyuai.cn',
|
||||
|
||||
// 客服图标位置
|
||||
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
|
||||
|
||||
// 边距设置(像素)
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
|
||||
// 自动弹出(不推荐)
|
||||
autoPopup: false,
|
||||
|
||||
// 语言设置
|
||||
locale: 'zh-cn', // zh-cn | en | ja | ko
|
||||
|
||||
// 客服图标配置
|
||||
bubbleConfig: {
|
||||
show: true, // 是否显示客服图标
|
||||
icon: '💬', // 图标(emoji或图片URL)
|
||||
title: '在线客服', // 鼠标悬停标题
|
||||
subtitle: '点击咨询', // 副标题
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
backgroundColor: '#0066FF', // 主题色
|
||||
textColor: '#ffffff', // 文字颜色
|
||||
},
|
||||
|
||||
// 聊天配置(必需)
|
||||
chatConfig: {
|
||||
org: BYTEDESK_ORG, // 组织ID
|
||||
t: '2', // 类型: 2=客服, 1=机器人
|
||||
sid: BYTEDESK_SID, // 工作组ID
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取Bytedesk配置(根据环境自动切换)
|
||||
*
|
||||
* @returns {Object} Bytedesk配置对象
|
||||
*/
|
||||
export const getBytedeskConfig = () => {
|
||||
// 所有环境都使用公网地址(不使用代理)
|
||||
return bytedeskConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取带用户信息的配置
|
||||
* 用于已登录用户,自动传递用户信息到客服端
|
||||
*
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {string} user.id - 用户ID
|
||||
* @param {string} user.name - 用户名
|
||||
* @param {string} user.email - 用户邮箱
|
||||
* @param {string} user.mobile - 用户手机号
|
||||
* @returns {Object} 带用户信息的Bytedesk配置
|
||||
*/
|
||||
export const getBytedeskConfigWithUser = (user) => {
|
||||
const config = getBytedeskConfig();
|
||||
|
||||
if (user && user.id) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
// 传递用户信息(可选)
|
||||
customParams: {
|
||||
userId: user.id,
|
||||
userName: user.name || 'Guest',
|
||||
userEmail: user.email || '',
|
||||
userMobile: user.mobile || '',
|
||||
source: 'web', // 来源标识
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -508,19 +508,19 @@ export default function WechatRegister() {
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||||
scrolling="no"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
|
||||
allow="clipboard-write"
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transform: 'scale(0.77) translateY(-35px)',
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
pointerEvents: 'auto',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
376
src/components/ChatBot/ChatInterface.js
Normal file
376
src/components/ChatBot/ChatInterface.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// src/components/ChatBot/ChatInterface.js
|
||||
// 聊天界面主组件
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
IconButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Divider,
|
||||
Badge,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSend, FiRefreshCw, FiSettings, FiDownload } from 'react-icons/fi';
|
||||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { mcpService } from '../../services/mcpService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* 聊天界面组件
|
||||
*/
|
||||
export const ChatInterface = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 1,
|
||||
content: '你好!我是AI投资助手,我可以帮你查询股票信息、新闻资讯、概念板块、涨停分析等。请问有什么可以帮到你的?',
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [availableTools, setAvailableTools] = useState([]);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const inputBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 加载可用工具列表
|
||||
useEffect(() => {
|
||||
const loadTools = async () => {
|
||||
const result = await mcpService.listTools();
|
||||
if (result.success) {
|
||||
setAvailableTools(result.data);
|
||||
logger.info('ChatInterface', '已加载MCP工具', { count: result.data.length });
|
||||
}
|
||||
};
|
||||
loadTools();
|
||||
}, []);
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim() || isLoading) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: inputValue,
|
||||
isUser: true,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 调用MCP服务
|
||||
const response = await mcpService.chat(inputValue, messages);
|
||||
|
||||
let botMessage;
|
||||
if (response.success) {
|
||||
// 根据返回的数据类型构造消息
|
||||
const data = response.data;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: data,
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else if (Array.isArray(data)) {
|
||||
// 数据列表
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `找到 ${data.length} 条结果:`,
|
||||
isUser: false,
|
||||
type: 'data',
|
||||
data: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else if (typeof data === 'object') {
|
||||
// 对象数据
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: JSON.stringify(data, null, 2),
|
||||
isUser: false,
|
||||
type: 'markdown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: '抱歉,我无法理解这个查询结果。',
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
botMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `抱歉,查询失败:${response.error}`,
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} catch (error) {
|
||||
logger.error('ChatInterface', 'handleSendMessage', error);
|
||||
const errorMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `抱歉,发生了错误:${error.message}`,
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
setMessages([
|
||||
{
|
||||
id: 1,
|
||||
content: '对话已清空。有什么可以帮到你的?',
|
||||
isUser: false,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 复制消息
|
||||
const handleCopyMessage = () => {
|
||||
toast({
|
||||
title: '已复制',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 反馈
|
||||
const handleFeedback = (type) => {
|
||||
logger.info('ChatInterface', 'Feedback', { type });
|
||||
toast({
|
||||
title: type === 'positive' ? '感谢反馈!' : '我们会改进',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
'查询贵州茅台的股票信息',
|
||||
'搜索人工智能相关新闻',
|
||||
'今日涨停股票有哪些',
|
||||
'新能源概念板块分析',
|
||||
];
|
||||
|
||||
const handleQuickQuestion = (question) => {
|
||||
setInputValue(question);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// 导出对话
|
||||
const handleExportChat = () => {
|
||||
const chatText = messages
|
||||
.map((msg) => `[${msg.isUser ? '用户' : 'AI'}] ${msg.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
const blob = new Blob([chatText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="100%" bg={bgColor}>
|
||||
{/* 头部工具栏 */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">AI投资助手</Text>
|
||||
<Badge colorScheme="green">在线</Badge>
|
||||
{availableTools.length > 0 && (
|
||||
<Badge colorScheme="blue">{availableTools.length} 个工具</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiRefreshCw />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="清空对话"
|
||||
onClick={handleClearChat}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiDownload />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="导出对话"
|
||||
onClick={handleExportChat}
|
||||
/>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<FiSettings />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="设置"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem>模型设置</MenuItem>
|
||||
<MenuItem>快捷指令</MenuItem>
|
||||
<MenuItem>历史记录</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<Box
|
||||
flex="1"
|
||||
overflowY="auto"
|
||||
px={4}
|
||||
py={4}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#CBD5E0',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack spacing={0} align="stretch">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isUser={message.isUser}
|
||||
onCopy={handleCopyMessage}
|
||||
onFeedback={handleFeedback}
|
||||
/>
|
||||
))}
|
||||
{isLoading && (
|
||||
<Flex justify="flex-start" mb={4}>
|
||||
<Flex align="center" bg={inputBg} px={4} py={3} borderRadius="lg">
|
||||
<Spinner size="sm" mr={2} />
|
||||
<Text fontSize="sm">AI正在思考...</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 快捷问题(仅在消息较少时显示) */}
|
||||
{messages.length <= 2 && (
|
||||
<Box px={4} py={2}>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>快捷问题:</Text>
|
||||
<Flex wrap="wrap" gap={2}>
|
||||
{quickQuestions.map((question, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => handleQuickQuestion(question)}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 输入框 */}
|
||||
<Box px={4} py={3} borderTop="1px" borderColor={borderColor}>
|
||||
<Flex>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入消息... (Shift+Enter换行,Enter发送)"
|
||||
bg={inputBg}
|
||||
border="none"
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
mr={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiSend />}
|
||||
colorScheme="blue"
|
||||
aria-label="发送"
|
||||
onClick={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
disabled={!inputValue.trim()}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInterface;
|
||||
681
src/components/ChatBot/ChatInterfaceV2.js
Normal file
681
src/components/ChatBot/ChatInterfaceV2.js
Normal file
@@ -0,0 +1,681 @@
|
||||
// src/components/ChatBot/ChatInterfaceV2.js
|
||||
// 重新设计的聊天界面 - 更漂亮、支持Agent模式
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Input,
|
||||
IconButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Divider,
|
||||
Badge,
|
||||
Button,
|
||||
Avatar,
|
||||
Heading,
|
||||
Progress,
|
||||
Fade,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiSend, FiRefreshCw, FiDownload, FiCpu, FiUser, FiZap } from 'react-icons/fi';
|
||||
import { PlanCard } from './PlanCard';
|
||||
import { StepResultCard } from './StepResultCard';
|
||||
import { mcpService } from '../../services/mcpService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Agent消息类型
|
||||
*/
|
||||
const MessageTypes = {
|
||||
USER: 'user',
|
||||
AGENT_THINKING: 'agent_thinking',
|
||||
AGENT_PLAN: 'agent_plan',
|
||||
AGENT_EXECUTING: 'agent_executing',
|
||||
AGENT_RESPONSE: 'agent_response',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* 聊天界面V2组件 - Agent模式
|
||||
*/
|
||||
export const ChatInterfaceV2 = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 1,
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: '你好!我是AI投资研究助手。我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const chatBg = useColorModeValue('white', 'gray.800');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const agentBubbleBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 添加消息
|
||||
const addMessage = (message) => {
|
||||
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
|
||||
};
|
||||
|
||||
// 更新最后一条消息
|
||||
const updateLastMessage = (updates) => {
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
if (newMessages.length > 0) {
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...newMessages[newMessages.length - 1],
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
};
|
||||
|
||||
// 发送消息(Agent模式 - 流式)
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim() || isProcessing) return;
|
||||
|
||||
const userMessage = {
|
||||
type: MessageTypes.USER,
|
||||
content: inputValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addMessage(userMessage);
|
||||
const userInput = inputValue; // 保存输入值
|
||||
setInputValue('');
|
||||
setIsProcessing(true);
|
||||
setCurrentProgress(0);
|
||||
|
||||
// 用于存储步骤结果
|
||||
let currentPlan = null;
|
||||
let stepResults = [];
|
||||
let executingMessageId = null;
|
||||
|
||||
try {
|
||||
// 1. 显示思考状态
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_THINKING,
|
||||
content: '正在分析你的问题...',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setCurrentProgress(10);
|
||||
|
||||
// 使用 EventSource 接收流式数据
|
||||
const eventSource = new EventSource(
|
||||
`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userInput,
|
||||
conversation_history: messages
|
||||
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||
.map(m => ({
|
||||
isUser: m.type === MessageTypes.USER,
|
||||
content: m.content,
|
||||
})),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// 由于 EventSource 不支持 POST,我们使用 fetch + ReadableStream
|
||||
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userInput,
|
||||
conversation_history: messages
|
||||
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||
.map(m => ({
|
||||
isUser: m.type === MessageTypes.USER,
|
||||
content: m.content,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Agent请求失败');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// 读取流式数据
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop(); // 保留不完整的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// 解析 SSE 消息
|
||||
const eventMatch = line.match(/^event: (.+)$/m);
|
||||
const dataMatch = line.match(/^data: (.+)$/m);
|
||||
|
||||
if (!eventMatch || !dataMatch) continue;
|
||||
|
||||
const event = eventMatch[1];
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
|
||||
logger.info(`SSE Event: ${event}`, data);
|
||||
|
||||
// 处理不同类型的事件
|
||||
switch (event) {
|
||||
case 'status':
|
||||
if (data.stage === 'planning') {
|
||||
// 移除思考消息,显示规划中
|
||||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_THINKING,
|
||||
content: data.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(20);
|
||||
} else if (data.stage === 'executing') {
|
||||
setCurrentProgress(30);
|
||||
} else if (data.stage === 'summarizing') {
|
||||
setCurrentProgress(90);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan':
|
||||
// 移除思考消息
|
||||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
|
||||
|
||||
// 显示执行计划
|
||||
currentPlan = data;
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_PLAN,
|
||||
content: '已制定执行计划',
|
||||
plan: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(30);
|
||||
break;
|
||||
|
||||
case 'step_start':
|
||||
// 如果还没有执行中消息,创建一个
|
||||
if (!executingMessageId) {
|
||||
const executingMsg = {
|
||||
type: MessageTypes.AGENT_EXECUTING,
|
||||
content: '正在执行步骤...',
|
||||
plan: currentPlan,
|
||||
stepResults: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addMessage(executingMsg);
|
||||
executingMessageId = Date.now();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'step_complete':
|
||||
// 添加步骤结果
|
||||
stepResults.push({
|
||||
step_index: data.step_index,
|
||||
tool: data.tool,
|
||||
status: data.status,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
execution_time: data.execution_time,
|
||||
arguments: data.arguments,
|
||||
});
|
||||
|
||||
// 更新执行中消息
|
||||
setMessages(prev =>
|
||||
prev.map(msg =>
|
||||
msg.type === MessageTypes.AGENT_EXECUTING
|
||||
? { ...msg, stepResults: [...stepResults] }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
|
||||
// 更新进度
|
||||
if (currentPlan) {
|
||||
const progress = 30 + ((data.step_index + 1) / currentPlan.steps.length) * 60;
|
||||
setCurrentProgress(progress);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'summary':
|
||||
// 移除执行中消息
|
||||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
|
||||
|
||||
// 显示最终结果
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: data.content,
|
||||
plan: currentPlan,
|
||||
stepResults: stepResults,
|
||||
metadata: data.metadata,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(100);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
throw new Error(data.message);
|
||||
|
||||
case 'done':
|
||||
logger.info('Stream完成');
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('未知事件类型:', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Agent chat error', error);
|
||||
|
||||
// 移除思考/执行中消息
|
||||
setMessages(prev => prev.filter(
|
||||
m => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
|
||||
));
|
||||
|
||||
addMessage({
|
||||
type: MessageTypes.ERROR,
|
||||
content: `处理失败:${error.message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '处理失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setCurrentProgress(0);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
setMessages([
|
||||
{
|
||||
id: 1,
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: '对话已清空。有什么可以帮到你的?',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 导出对话
|
||||
const handleExportChat = () => {
|
||||
const chatText = messages
|
||||
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : 'AI助手'}] ${msg.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
const blob = new Blob([chatText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
'全面分析贵州茅台这只股票',
|
||||
'今日涨停股票有哪些亮点',
|
||||
'新能源概念板块的投资机会',
|
||||
'半导体行业最新动态',
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex direction="column" h="100%" bg={bgColor}>
|
||||
{/* 头部 */}
|
||||
<Box
|
||||
bg={chatBg}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
px={6}
|
||||
py={4}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={4}>
|
||||
<Avatar
|
||||
size="md"
|
||||
bg="blue.500"
|
||||
icon={<FiCpu fontSize="1.5rem" />}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Heading size="md">AI投资研究助手</Heading>
|
||||
<HStack>
|
||||
<Badge colorScheme="green" fontSize="xs">
|
||||
<HStack spacing={1}>
|
||||
<FiZap size={10} />
|
||||
<span>智能分析</span>
|
||||
</HStack>
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
多步骤深度研究
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiRefreshCw />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="清空对话"
|
||||
onClick={handleClearChat}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiDownload />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="导出对话"
|
||||
onClick={handleExportChat}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isProcessing && (
|
||||
<Progress
|
||||
value={currentProgress}
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
mt={3}
|
||||
borderRadius="full"
|
||||
isAnimated
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<Box
|
||||
flex="1"
|
||||
overflowY="auto"
|
||||
px={6}
|
||||
py={4}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#CBD5E0',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{messages.map((message) => (
|
||||
<Fade in key={message.id}>
|
||||
<MessageRenderer message={message} />
|
||||
</Fade>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 快捷问题 */}
|
||||
{messages.length <= 2 && !isProcessing && (
|
||||
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>💡 试试这些问题:</Text>
|
||||
<Flex wrap="wrap" gap={2}>
|
||||
{quickQuestions.map((question, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
fontSize="xs"
|
||||
onClick={() => {
|
||||
setInputValue(question);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
|
||||
<Flex>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入你的问题,我会进行深度分析..."
|
||||
bg={inputBg}
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
|
||||
mr={2}
|
||||
disabled={isProcessing}
|
||||
size="lg"
|
||||
/>
|
||||
<IconButton
|
||||
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
|
||||
colorScheme="blue"
|
||||
aria-label="发送"
|
||||
onClick={handleSendMessage}
|
||||
isLoading={isProcessing}
|
||||
disabled={!inputValue.trim() || isProcessing}
|
||||
size="lg"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息渲染器
|
||||
*/
|
||||
const MessageRenderer = ({ message }) => {
|
||||
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const agentBubbleBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
switch (message.type) {
|
||||
case MessageTypes.USER:
|
||||
return (
|
||||
<Flex justify="flex-end">
|
||||
<HStack align="flex-start" maxW="75%">
|
||||
<Box
|
||||
bg={userBubbleBg}
|
||||
color="white"
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
>
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
</Box>
|
||||
<Avatar size="sm" bg="blue.500" icon={<FiUser fontSize="1rem" />} />
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_THINKING:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="75%">
|
||||
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<Box
|
||||
bg={agentBubbleBg}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<HStack>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="purple.600">
|
||||
{message.content}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_PLAN:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="85%">
|
||||
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<VStack align="stretch" flex={1}>
|
||||
<PlanCard plan={message.plan} stepResults={[]} />
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_EXECUTING:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="85%">
|
||||
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<VStack align="stretch" flex={1} spacing={3}>
|
||||
<PlanCard plan={message.plan} stepResults={message.stepResults} />
|
||||
{message.stepResults?.map((result, idx) => (
|
||||
<StepResultCard key={idx} stepResult={result} />
|
||||
))}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_RESPONSE:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="85%">
|
||||
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<VStack align="stretch" flex={1} spacing={3}>
|
||||
{/* 最终总结 */}
|
||||
<Box
|
||||
bg={agentBubbleBg}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="md"
|
||||
>
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
{/* 元数据 */}
|
||||
{message.metadata && (
|
||||
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
|
||||
<Text>总步骤: {message.metadata.total_steps}</Text>
|
||||
<Text>✓ {message.metadata.successful_steps}</Text>
|
||||
{message.metadata.failed_steps > 0 && (
|
||||
<Text>✗ {message.metadata.failed_steps}</Text>
|
||||
)}
|
||||
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 执行详情(可选) */}
|
||||
{message.plan && message.stepResults && message.stepResults.length > 0 && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Divider />
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.500">
|
||||
📊 执行详情(点击展开查看)
|
||||
</Text>
|
||||
{message.stepResults.map((result, idx) => (
|
||||
<StepResultCard key={idx} stepResult={result} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.ERROR:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="75%">
|
||||
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<Box
|
||||
bg="red.50"
|
||||
color="red.700"
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="red.200"
|
||||
>
|
||||
<Text fontSize="sm">{message.content}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ChatInterfaceV2;
|
||||
72
src/components/ChatBot/EChartsRenderer.js
Normal file
72
src/components/ChatBot/EChartsRenderer.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/components/ChatBot/EChartsRenderer.js
|
||||
// ECharts 图表渲染组件
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
/**
|
||||
* ECharts 图表渲染组件
|
||||
* @param {Object} option - ECharts 配置对象
|
||||
* @param {number} height - 图表高度(默认 400px)
|
||||
*/
|
||||
export const EChartsRenderer = ({ option, height = 400 }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !option) return;
|
||||
|
||||
// 初始化图表
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
// 设置默认主题配置
|
||||
const defaultOption = {
|
||||
backgroundColor: 'transparent',
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
...option,
|
||||
};
|
||||
|
||||
// 设置图表配置
|
||||
chartInstance.current.setOption(defaultOption, true);
|
||||
|
||||
// 响应式调整大小
|
||||
const handleResize = () => {
|
||||
chartInstance.current?.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// chartInstance.current?.dispose(); // 不要销毁,避免重新渲染时闪烁
|
||||
};
|
||||
}, [option]);
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
chartInstance.current?.dispose();
|
||||
chartInstance.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={chartRef}
|
||||
width="100%"
|
||||
height={`${height}px`}
|
||||
bg={bgColor}
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
189
src/components/ChatBot/MarkdownWithCharts.js
Normal file
189
src/components/ChatBot/MarkdownWithCharts.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// src/components/ChatBot/MarkdownWithCharts.js
|
||||
// 支持 ECharts 图表的 Markdown 渲染组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Alert, AlertIcon, Text, VStack, Code } from '@chakra-ui/react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { EChartsRenderer } from './EChartsRenderer';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 解析 Markdown 内容,提取 ECharts 代码块
|
||||
* @param {string} markdown - Markdown 文本
|
||||
* @returns {Array} - 包含文本和图表的数组
|
||||
*/
|
||||
const parseMarkdownWithCharts = (markdown) => {
|
||||
if (!markdown) return [];
|
||||
|
||||
const parts = [];
|
||||
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = echartsRegex.exec(markdown)) !== null) {
|
||||
// 添加代码块前的文本
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = markdown.substring(lastIndex, match.index).trim();
|
||||
if (textBefore) {
|
||||
parts.push({ type: 'text', content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 ECharts 配置
|
||||
const chartConfig = match[1].trim();
|
||||
parts.push({ type: 'chart', content: chartConfig });
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < markdown.length) {
|
||||
const textAfter = markdown.substring(lastIndex).trim();
|
||||
if (textAfter) {
|
||||
parts.push({ type: 'text', content: textAfter });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到图表,返回整个 markdown 作为文本
|
||||
if (parts.length === 0) {
|
||||
parts.push({ type: 'text', content: markdown });
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* 支持 ECharts 图表的 Markdown 渲染组件
|
||||
* @param {string} content - Markdown 文本
|
||||
*/
|
||||
export const MarkdownWithCharts = ({ content }) => {
|
||||
const parts = parseMarkdownWithCharts(content);
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{parts.map((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
// 渲染普通 Markdown
|
||||
return (
|
||||
<Box key={index}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// 自定义渲染样式
|
||||
p: ({ children }) => (
|
||||
<Text mb={2} fontSize="sm">
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<Text fontSize="xl" fontWeight="bold" mb={3}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<Text fontSize="lg" fontWeight="bold" mb={2}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<Text fontSize="md" fontWeight="bold" mb={2}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<Box as="ul" pl={4} mb={2}>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<Box as="ol" pl={4} mb={2}>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<Box as="li" fontSize="sm" mb={1}>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
code: ({ inline, children }) =>
|
||||
inline ? (
|
||||
<Code fontSize="sm" px={1}>
|
||||
{children}
|
||||
</Code>
|
||||
) : (
|
||||
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap">
|
||||
{children}
|
||||
</Code>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<Box
|
||||
borderLeftWidth="4px"
|
||||
borderLeftColor="blue.500"
|
||||
pl={4}
|
||||
py={2}
|
||||
fontStyle="italic"
|
||||
color="gray.600"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{part.content}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
);
|
||||
} else if (part.type === 'chart') {
|
||||
// 渲染 ECharts 图表
|
||||
try {
|
||||
// 清理可能的 Markdown 残留符号
|
||||
let cleanContent = part.content.trim();
|
||||
|
||||
// 移除可能的前后空白和不可见字符
|
||||
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
|
||||
|
||||
// 尝试解析 JSON
|
||||
const chartOption = JSON.parse(cleanContent);
|
||||
|
||||
// 验证是否是有效的 ECharts 配置
|
||||
if (!chartOption || typeof chartOption !== 'object') {
|
||||
throw new Error('Invalid chart configuration: not an object');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<EChartsRenderer option={chartOption} height={350} />
|
||||
</Box>
|
||||
);
|
||||
} catch (error) {
|
||||
// 记录详细的错误信息
|
||||
logger.error('解析 ECharts 配置失败', {
|
||||
error: error.message,
|
||||
contentLength: part.content.length,
|
||||
contentPreview: part.content.substring(0, 200),
|
||||
errorStack: error.stack
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert status="warning" key={index} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="flex-start" spacing={1} flex="1">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
图表配置解析失败
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
错误: {error.message}
|
||||
</Text>
|
||||
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
|
||||
{part.content.substring(0, 300)}
|
||||
{part.content.length > 300 ? '...' : ''}
|
||||
</Code>
|
||||
</VStack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
149
src/components/ChatBot/MessageBubble.js
Normal file
149
src/components/ChatBot/MessageBubble.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// src/components/ChatBot/MessageBubble.js
|
||||
// 聊天消息气泡组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
useColorModeValue,
|
||||
IconButton,
|
||||
HStack,
|
||||
Code,
|
||||
Badge,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiCopy, FiThumbsUp, FiThumbsDown } from 'react-icons/fi';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
/**
|
||||
* 消息气泡组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.message - 消息对象
|
||||
* @param {boolean} props.isUser - 是否是用户消息
|
||||
* @param {Function} props.onCopy - 复制消息回调
|
||||
* @param {Function} props.onFeedback - 反馈回调
|
||||
*/
|
||||
export const MessageBubble = ({ message, isUser, onCopy, onFeedback }) => {
|
||||
const userBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const botBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const userColor = 'white';
|
||||
const botColor = useColorModeValue('gray.800', 'white');
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
w="100%"
|
||||
justify={isUser ? 'flex-end' : 'flex-start'}
|
||||
mb={4}
|
||||
>
|
||||
<Flex
|
||||
maxW="75%"
|
||||
flexDirection={isUser ? 'row-reverse' : 'row'}
|
||||
align="flex-start"
|
||||
>
|
||||
{/* 头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={isUser ? '用户' : 'AI助手'}
|
||||
bg={isUser ? 'blue.500' : 'green.500'}
|
||||
color="white"
|
||||
mx={3}
|
||||
/>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Box>
|
||||
<Box
|
||||
bg={isUser ? userBg : botBg}
|
||||
color={isUser ? userColor : botColor}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
>
|
||||
{message.type === 'text' ? (
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
) : message.type === 'markdown' ? (
|
||||
<Box fontSize="sm" className="markdown-content">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</Box>
|
||||
) : message.type === 'data' ? (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={3}
|
||||
bg={useColorModeValue('white', 'gray.600')}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{Object.entries(item).map(([key, value]) => (
|
||||
<Flex key={key} justify="space-between" mb={1}>
|
||||
<Text fontWeight="bold" mr={2}>{key}:</Text>
|
||||
<Text>{String(value)}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{message.data && message.data.length > 5 && (
|
||||
<Badge colorScheme="blue" alignSelf="center">
|
||||
+{message.data.length - 5} 更多结果
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* 消息操作按钮(仅AI消息) */}
|
||||
{!isUser && (
|
||||
<HStack mt={2} spacing={2}>
|
||||
<IconButton
|
||||
icon={<FiCopy />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="复制"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiThumbsUp />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="赞"
|
||||
onClick={() => onFeedback?.('positive')}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiThumbsDown />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="踩"
|
||||
onClick={() => onFeedback?.('negative')}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={1}
|
||||
textAlign={isUser ? 'right' : 'left'}
|
||||
>
|
||||
{message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
145
src/components/ChatBot/PlanCard.js
Normal file
145
src/components/ChatBot/PlanCard.js
Normal file
@@ -0,0 +1,145 @@
|
||||
// src/components/ChatBot/PlanCard.js
|
||||
// 执行计划展示卡片
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiTarget, FiCheckCircle, FiXCircle, FiClock, FiTool } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 执行计划卡片组件
|
||||
*/
|
||||
export const PlanCard = ({ plan, stepResults }) => {
|
||||
const cardBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const borderColor = useColorModeValue('blue.200', 'blue.700');
|
||||
const successColor = useColorModeValue('green.500', 'green.300');
|
||||
const errorColor = useColorModeValue('red.500', 'red.300');
|
||||
const pendingColor = useColorModeValue('gray.400', 'gray.500');
|
||||
|
||||
const getStepStatus = (stepIndex) => {
|
||||
if (!stepResults || stepResults.length === 0) return 'pending';
|
||||
const result = stepResults.find(r => r.step_index === stepIndex);
|
||||
return result ? result.status : 'pending';
|
||||
};
|
||||
|
||||
const getStepIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return FiCheckCircle;
|
||||
case 'failed':
|
||||
return FiXCircle;
|
||||
default:
|
||||
return FiClock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepColor = (status) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return successColor;
|
||||
case 'failed':
|
||||
return errorColor;
|
||||
default:
|
||||
return pendingColor;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="lg"
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
p={4}
|
||||
mb={4}
|
||||
boxShadow="md"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 目标 */}
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="blue.500" boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">执行目标</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600" pl={7}>
|
||||
{plan.goal}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 规划思路 */}
|
||||
{plan.reasoning && (
|
||||
<>
|
||||
<Text fontSize="sm" fontWeight="bold">规划思路:</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{plan.reasoning}
|
||||
</Text>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 执行步骤 */}
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" fontWeight="bold">执行步骤</Text>
|
||||
<Badge colorScheme="blue">{plan.steps.length} 步</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{plan.steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const StepIcon = getStepIcon(status);
|
||||
const stepColor = getStepColor(status);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
key={index}
|
||||
p={2}
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={stepColor}
|
||||
align="flex-start"
|
||||
>
|
||||
<Icon as={StepIcon} color={stepColor} boxSize={4} mt={1} />
|
||||
<VStack align="stretch" flex={1} spacing={1}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
步骤 {index + 1}: {step.tool}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
status === 'success' ? 'green' :
|
||||
status === 'failed' ? 'red' : 'gray'
|
||||
}
|
||||
fontSize="xs"
|
||||
>
|
||||
{status === 'success' ? '✓ 完成' :
|
||||
status === 'failed' ? '✗ 失败' : '⏳ 等待'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{step.reason}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
186
src/components/ChatBot/StepResultCard.js
Normal file
186
src/components/ChatBot/StepResultCard.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// src/components/ChatBot/StepResultCard.js
|
||||
// 步骤结果展示卡片(可折叠)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Collapse,
|
||||
Icon,
|
||||
IconButton,
|
||||
Code,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiChevronDown, FiChevronUp, FiCheckCircle, FiXCircle, FiClock, FiDatabase } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* 步骤结果卡片组件
|
||||
*/
|
||||
export const StepResultCard = ({ stepResult }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const successColor = useColorModeValue('green.500', 'green.300');
|
||||
const errorColor = useColorModeValue('red.500', 'red.300');
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (stepResult.status) {
|
||||
case 'success':
|
||||
return FiCheckCircle;
|
||||
case 'failed':
|
||||
return FiXCircle;
|
||||
default:
|
||||
return FiClock;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (stepResult.status) {
|
||||
case 'success':
|
||||
return 'green';
|
||||
case 'failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const StatusIcon = getStatusIcon();
|
||||
const statusColorScheme = getStatusColor();
|
||||
|
||||
// 格式化数据以便展示
|
||||
const formatResult = (data) => {
|
||||
if (typeof data === 'string') return data;
|
||||
if (Array.isArray(data)) {
|
||||
return `找到 ${data.length} 条记录`;
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
return String(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
>
|
||||
{/* 头部 - 始终可见 */}
|
||||
<HStack
|
||||
p={3}
|
||||
justify="space-between"
|
||||
cursor="pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}
|
||||
>
|
||||
<HStack flex={1}>
|
||||
<Icon as={StatusIcon} color={`${statusColorScheme}.500`} boxSize={5} />
|
||||
<VStack align="stretch" spacing={0} flex={1}>
|
||||
<HStack>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
步骤 {stepResult.step_index + 1}: {stepResult.tool}
|
||||
</Text>
|
||||
<Badge colorScheme={statusColorScheme} fontSize="xs">
|
||||
{stepResult.status === 'success' ? '成功' :
|
||||
stepResult.status === 'failed' ? '失败' : '执行中'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
耗时: {stepResult.execution_time?.toFixed(2)}s
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<IconButton
|
||||
icon={<Icon as={isExpanded ? FiChevronUp : FiChevronDown} />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isExpanded ? "收起" : "展开"}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 内容 - 可折叠 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box p={3} pt={0}>
|
||||
<Divider mb={3} />
|
||||
|
||||
{/* 参数 */}
|
||||
{stepResult.arguments && Object.keys(stepResult.arguments).length > 0 && (
|
||||
<VStack align="stretch" spacing={2} mb={3}>
|
||||
<HStack>
|
||||
<Icon as={FiDatabase} color="blue.500" boxSize={4} />
|
||||
<Text fontSize="xs" fontWeight="bold">请求参数:</Text>
|
||||
</HStack>
|
||||
<Code
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
whiteSpace="pre-wrap"
|
||||
wordBreak="break-word"
|
||||
>
|
||||
{JSON.stringify(stepResult.arguments, null, 2)}
|
||||
</Code>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 结果或错误 */}
|
||||
{stepResult.status === 'success' && stepResult.result && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" fontWeight="bold">执行结果:</Text>
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
p={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.800')}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{typeof stepResult.result === 'string' ? (
|
||||
<Text whiteSpace="pre-wrap">{stepResult.result}</Text>
|
||||
) : Array.isArray(stepResult.result) ? (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontWeight="bold">找到 {stepResult.result.length} 条记录:</Text>
|
||||
{stepResult.result.slice(0, 3).map((item, idx) => (
|
||||
<Code key={idx} p={2} borderRadius="md" fontSize="xs">
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</Code>
|
||||
))}
|
||||
{stepResult.result.length > 3 && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
...还有 {stepResult.result.length - 3} 条记录
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Code whiteSpace="pre-wrap" wordBreak="break-word">
|
||||
{JSON.stringify(stepResult.result, null, 2)}
|
||||
</Code>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{stepResult.status === 'failed' && stepResult.error && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" fontWeight="bold" color="red.500">错误信息:</Text>
|
||||
<Text fontSize="xs" color="red.600" p={2} bg="red.50" borderRadius="md">
|
||||
{stepResult.error}
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepResultCard;
|
||||
11
src/components/ChatBot/index.js
Normal file
11
src/components/ChatBot/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/components/ChatBot/index.js
|
||||
// 聊天机器人组件统一导出
|
||||
|
||||
export { ChatInterface } from './ChatInterface';
|
||||
export { ChatInterfaceV2 } from './ChatInterfaceV2';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { PlanCard } from './PlanCard';
|
||||
export { StepResultCard } from './StepResultCard';
|
||||
|
||||
// 默认导出新版本
|
||||
export { ChatInterfaceV2 as default } from './ChatInterfaceV2';
|
||||
@@ -2,6 +2,7 @@
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
@@ -12,6 +13,10 @@ 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';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以在这里包装
|
||||
@@ -67,8 +72,12 @@ function ConnectionStatusBarWrapper() {
|
||||
* - AuthModalManager: 认证弹窗管理器
|
||||
* - NotificationContainer: 通知容器
|
||||
* - NotificationTestTool: 通知测试工具 (仅开发环境)
|
||||
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Socket 连接状态条 */}
|
||||
@@ -85,6 +94,14 @@ export function GlobalComponents() {
|
||||
|
||||
{/* 通知测试工具 (仅开发环境) */}
|
||||
<NotificationTestTool />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.leaflet-container {
|
||||
height: 300px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MapContainer, TileLayer, } from 'react-leaflet';
|
||||
import "./Map.css";
|
||||
|
||||
function MapPlaceholder() {
|
||||
return (
|
||||
<p>
|
||||
Map of London.{' '}
|
||||
<noscript>You need to enable JavaScript to see this map.</noscript>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Map() {
|
||||
return (
|
||||
<MapContainer
|
||||
center={[51.505, -0.09]}
|
||||
zoom={13}
|
||||
scrollWheelZoom={true}
|
||||
placeholder={<MapPlaceholder />}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Map;
|
||||
@@ -106,7 +106,29 @@ const FollowingEventsMenu = memo(() => {
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
<HStack flexShrink={0}>
|
||||
<HStack flexShrink={0} spacing={1}>
|
||||
{/* 热度 */}
|
||||
{typeof ev.hot_score === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
ev.hot_score >= 80 ? 'red' :
|
||||
(ev.hot_score >= 60 ? 'orange' : 'gray')
|
||||
}
|
||||
fontSize="xs"
|
||||
>
|
||||
🔥 {ev.hot_score}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 关注数 */}
|
||||
{typeof ev.follower_count === 'number' && ev.follower_count > 0 && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
fontSize="xs"
|
||||
>
|
||||
👥 {ev.follower_count}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 日均涨跌幅 */}
|
||||
{typeof ev.related_avg_chg === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
@@ -119,6 +141,7 @@ const FollowingEventsMenu = memo(() => {
|
||||
{ev.related_avg_chg.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{/* 周涨跌幅 */}
|
||||
{typeof ev.related_week_chg === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
@@ -131,6 +154,7 @@ const FollowingEventsMenu = memo(() => {
|
||||
{ev.related_week_chg.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{/* 取消关注按钮 */}
|
||||
<Box
|
||||
as="span"
|
||||
fontSize="xs"
|
||||
|
||||
@@ -243,6 +243,26 @@ const MobileDrawer = memo(({
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Link
|
||||
onClick={() => handleNavigate('/agent-chat')}
|
||||
py={1}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="xs" colorScheme="green">AI</Badge>
|
||||
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
py={1}
|
||||
px={3}
|
||||
|
||||
@@ -199,6 +199,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
onClick={agentCommunityMenu.handleClick}
|
||||
@@ -207,10 +213,31 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
minW="300px"
|
||||
p={4}
|
||||
p={2}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('AI聊天助手', 'dropdown', '/agent-chat');
|
||||
navigate('/agent-chat');
|
||||
agentCommunityMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">AI</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
|
||||
@@ -139,6 +139,22 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
|
||||
{/* AGENT社群组 */}
|
||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/agent-chat');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">AI</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>
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Text,
|
||||
Box,
|
||||
VStack,
|
||||
Divider,
|
||||
useColorModeValue
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
const PrivacyPolicyModal = ({ isOpen, onClose }) => {
|
||||
const modalBg = useColorModeValue("white", "gray.800");
|
||||
const headingColor = useColorModeValue("gray.800", "white");
|
||||
const textColor = useColorModeValue("gray.600", "gray.300");
|
||||
|
||||
// Conditional rendering: only render Modal when open
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.600" />
|
||||
<ModalContent
|
||||
maxW="95vw"
|
||||
maxH="95vh"
|
||||
bg={modalBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="2xl"
|
||||
mx={4}
|
||||
>
|
||||
<ModalHeader
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color={headingColor}
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.200"
|
||||
borderRadius="xl xl 0 0"
|
||||
py={6}
|
||||
>
|
||||
隐私政策
|
||||
</ModalHeader>
|
||||
<ModalCloseButton
|
||||
size="lg"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
/>
|
||||
<ModalBody py={8} px={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box bg="blue.50" p={4} borderRadius="md" border="1px solid" borderColor="blue.100">
|
||||
<Text fontSize="md" color="blue.600" mb={2} fontWeight="semibold">
|
||||
生效日期:2025年1月20日
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
【北京价值前沿科技有限公司】(以下简称"我们")深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。同时,我们承诺,我们将按业界成熟的安全标准,采取相应的安全保护措施来保护您的个人信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" fontWeight="medium">
|
||||
请在使用我们的产品(或服务)前,仔细阅读并了解本《隐私政策》。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
一、我们如何收集和使用您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
根据《信息安全技术个人信息安全规范》(GB/T 35273—2020),个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。本隐私政策中涉及的个人信息包括:基本信息(包括性别、地址、地区、个人电话号码、电子邮箱);个人身份信息(包括身份证、护照、相关身份证明等);网络身份标识信息(包括系统账号、IP地址、口令);个人上网记录(包括登录记录、浏览记录);个人常用设备信息(包括硬件型号、操作系统类型、应用安装列表、运行中进程信息、设备MAC地址、软件列表设备识别码如IMEI/android ID/IDFA/IMSI 在内的描述个人常用设备基本情况的信息);个人位置信息(包括精准定位信息、经纬度等);
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
个人敏感信息是指一旦泄露、非法提供或滥用可能危害人身和财产安全,极易导致个人名誉、身心健康受到损害或歧视性待遇等的个人信息,本隐私政策中涉及的个人敏感信息包括:个人身份信息(包括身份证、护照、相关身份证明等);网络身份识别信息(包括账户名、账户昵称、用户头像、与前述有关的密码);其他信息(包括个人电话号码、浏览记录、精准定位信息)。对于个人敏感信息,我们将在本政策中进行显著标识,请您仔细阅读。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="green.300" bg="green.50" p={4} borderRadius="md">
|
||||
<Text fontSize="xl" fontWeight="semibold" color="green.700" mb={2}>
|
||||
(一)手机号注册/登录
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用手机号注册/登录服务时,我们会收集您的手机号码、验证码匹配结果、手机系统平台等信息,用于保存您的登录信息,使您在使用不同设备登录时能够同步您的数据。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="purple.300" bg="purple.50" p={4} borderRadius="md">
|
||||
<Text fontSize="xl" fontWeight="semibold" color="purple.700" mb={2}>
|
||||
(二)第三方登录
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用微信/QQ等第三方登录时,我们会收集您第三方的唯一标识、头像、昵称,用于保存您的登录信息,使您在使用不同设备登录时能够同步您的数据。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用微信,微博,QQ 进行三方分享的时候,我们的产品可能会集成第三方的SDK或其他类似的应用程序,用于三方登录以及分享内容到三方平台。您可以登陆以下网址了解相关隐私政策:
|
||||
</Text>
|
||||
<VStack align="start" spacing={2} pl={4}>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【新浪微博】微博个人信息保护政策:https://m.weibo.cn/c/privacy
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【微信】微信开放平台开发者服务协议:https://open.weixin.qq.com/cgi-bin/frame?t=news/protocol_developer_tmpl
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【QQ】QQ互联SDK隐私保护声明:https://wiki.connect.qq.com/qq互联sdk隐私保护声明
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="orange.300" bg="orange.50" p={4} borderRadius="md">
|
||||
<Text fontSize="xl" fontWeight="semibold" color="orange.700" mb={2}>
|
||||
(三)第三方支付
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用 微信 支付宝 华为 进行三方支付的时候,我们的产品可能会集成第三方的SDK或其他类似的应用程序,帮助用户在应用内使用三方支付:
|
||||
</Text>
|
||||
<VStack align="start" spacing={2} pl={4}>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【支付宝】客户端 SDK 隐私说明:https://opendocs.alipay.com/open/01g6qm
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【微信支付】微信支付服务协议:https://pay.weixin.qq.com/index.php/public/apply_sign/protocol_v2
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【华为支付】SDK数据安全说明:https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/sdk-data-security-0000001050044906
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
二、我们如何使用 Cookie 和同类技术
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
为确保网站正常运转,我们会在您的计算机或移动设备上存储名为 Cookie 的小数据文件。Cookie 通常包含标识符、站点名称以及一些号码和字符。借助于 Cookie,网站能够存储您的偏好或购物篮内的商品等数据。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
三、我们如何共享、转让、公开披露您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们不会向其他任何公司、组织和个人分享您的个人信息,但以下情况除外:
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
1、在获取明确同意的情况下共享:获得您的明确同意后,我们会与其他方共享您的个人信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
2、我们可能会根据法律法规规定,或按政府主管部门的强制性要求,对外共享您的个人信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
3、与我们的关联公司共享:您的个人信息可能会与我们关联公司共享。我们只会共享必要的个人信息,且受本隐私政策中所声明目的的约束。关联公司如要改变个人信息的处理目的,将再次征求您的授权同意。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们的关联公司包括:北京价值经纬咨询有限责任公司等。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
四、我们如何保护您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们已使用符合业界标准的安全防护措施保护您提供的个人信息,防止数据遭到未经授权访问、公开披露、使用、修改、损坏或丢失。我们会采取一切合理可行的措施,保护您的个人信息。例如,在您的浏览器与"服务"之间交换数据(如信用卡信息)时受 SSL 加密保护;我们同时对我们网站提供 https 安全浏览方式;我们会使用加密技术确保数据的保密性;我们会使用受信赖的保护机制防止数据遭到恶意攻击;我们会部署访问控制机制,确保只有授权人员才可访问个人信息;以及我们会举办安全和隐私保护培训课程,加强员工对于保护个人信息重要性的认识。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
五、您的权利
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
按照中国相关的法律、法规、标准,以及其他国家、地区的通行做法,我们保障您对自己的个人信息行使以下权利:
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
(一)访问您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
(二)更正您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
(三)删除您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
(四)约束信息系统自动决策
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
如果您无法通过上述链接更正这些个人信息,您可以随时使用我们的 Web 表单联系,或发送电子邮件至admin@valuefrontier.cn。我们将在30天内回复您的更正请求。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
六、我们如何处理儿童的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们的产品、网站和服务主要面向成人。如果没有父母或监护人的同意,儿童不得创建自己的用户账户。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
对于经父母同意而收集儿童个人信息的情况,我们只会在受到法律允许、父母或监护人明确同意或者保护儿童所必要的情况下使用或公开披露此信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
尽管当地法律和习俗对儿童的定义不同,但我们将不满 14 周岁的任何人均视为儿童。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
如果我们发现自己在未事先获得可证实的父母同意的情况下收集了儿童的个人信息,则会设法尽快删除相关数据。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
七、本隐私政策如何更新
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们可能适时会对本隐私政策进行调整或变更,本隐私政策的任何更新将在用户启动应用时以弹窗形式提醒用户更新内容并提示查看最新的隐私政策,提醒用户重新确认是否同意隐私政策条款,除法律法规或监管规定另有强制性规定外,经调整或变更的内容一经用户确认后将即时生效。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
八、如何联系我们
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
如果您对本隐私政策有任何疑问、意见或建议,通过以下方式与我们联系:
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
邮箱:admin@valuefrontier.cn
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
九、未成年人保护方面
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
1、若您是未满18周岁的未成年人,您应在您的监护人监护、指导下并获得监护人同意的情况下,认真阅读并同意本协议后,方可使用价值前沿app及相关服务。若您未取得监护人的同意,监护人可以通过联系价值前沿官方公布的客服联系方式通知价值前沿处理相关账号,价值前沿有权对相关账号的功能、使用进行限制,包括但不限于浏览、发布信息、互动交流等功能。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
2、价值前沿重视对未成年人个人信息的保护,未成年用户在填写个人信息时,请加强个人保护意识并谨慎对待,并应在取得监护人的同意以及在监护人指导下正确使用价值前沿app及相关服务。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
3、未成年人用户及其监护人理解并确认,如您违反法律法规、本协议内容,则您及您的监护人应依照法律规定承担因此而可能导致的全部法律责任。
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicyModal;
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
|
||||
import { getChangeColor } from '../utils/colorUtils';
|
||||
|
||||
/**
|
||||
* 股票涨跌幅指标组件(3分天下布局)
|
||||
@@ -10,41 +12,26 @@ import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
* @param {number} props.avgChange - 平均涨跌幅
|
||||
* @param {number} props.maxChange - 最大涨跌幅
|
||||
* @param {number} props.weekChange - 周涨跌幅
|
||||
* @param {'default'|'comfortable'|'large'} props.size - 尺寸模式:default=紧凑,comfortable=舒适(事件列表),large=大卡片(详情面板)
|
||||
*/
|
||||
const StockChangeIndicators = ({
|
||||
avgChange,
|
||||
maxChange,
|
||||
weekChange,
|
||||
size = 'default',
|
||||
}) => {
|
||||
// 根据涨跌幅获取数字颜色(多颜色梯度:5级分级)
|
||||
const isLarge = size === 'large';
|
||||
const isComfortable = size === 'comfortable';
|
||||
const isDefault = size === 'default';
|
||||
|
||||
// 根据涨跌幅获取数字颜色(动态深浅)
|
||||
const getNumberColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.700', 'gray.400');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色
|
||||
if (value === 0) {
|
||||
return 'gray.700';
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨:红色系 → 橙色系
|
||||
if (absValue >= 10) return 'red.900'; // 10%以上:最深红
|
||||
if (absValue >= 5) return 'red.700'; // 5-10%:深红
|
||||
if (absValue >= 3) return 'red.500'; // 3-5%:中红
|
||||
if (absValue >= 1) return 'orange.600'; // 1-3%:橙色
|
||||
return 'orange.400'; // 0-1%:浅橙
|
||||
} else {
|
||||
// 下跌:绿色系 → 青色系
|
||||
if (absValue >= 10) return 'green.900'; // -10%以下:最深绿
|
||||
if (absValue >= 5) return 'green.700'; // -10% ~ -5%:深绿
|
||||
if (absValue >= 3) return 'green.500'; // -5% ~ -3%:中绿
|
||||
if (absValue >= 1) return 'teal.600'; // -3% ~ -1%:青色
|
||||
return 'teal.400'; // -1% ~ 0%:浅青
|
||||
}
|
||||
// 使用动态颜色函数
|
||||
return getChangeColor(value);
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取背景色(永远比文字色浅)
|
||||
@@ -58,24 +45,10 @@ const StockChangeIndicators = ({
|
||||
return useColorModeValue('gray.50', 'gray.800');
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨背景:红色系 → 橙色系(统一使用 50 最浅色)
|
||||
if (absValue >= 10) return useColorModeValue('red.50', 'red.900');
|
||||
if (absValue >= 5) return useColorModeValue('red.50', 'red.900');
|
||||
if (absValue >= 3) return useColorModeValue('red.50', 'red.900');
|
||||
if (absValue >= 1) return useColorModeValue('orange.50', 'orange.900');
|
||||
return useColorModeValue('orange.50', 'orange.900');
|
||||
} else {
|
||||
// 下跌背景:绿色系 → 青色系(统一使用 50 最浅色)
|
||||
if (absValue >= 10) return useColorModeValue('green.50', 'green.900');
|
||||
if (absValue >= 5) return useColorModeValue('green.50', 'green.900');
|
||||
if (absValue >= 3) return useColorModeValue('green.50', 'green.900');
|
||||
if (absValue >= 1) return useColorModeValue('teal.50', 'teal.900');
|
||||
return useColorModeValue('teal.50', 'teal.900');
|
||||
}
|
||||
// 统一背景色:上涨红色系,下跌绿色系
|
||||
return value > 0
|
||||
? useColorModeValue('red.50', 'red.900')
|
||||
: useColorModeValue('green.50', 'green.900');
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取边框色(比背景深,比文字浅)
|
||||
@@ -89,64 +62,86 @@ const StockChangeIndicators = ({
|
||||
return useColorModeValue('gray.200', 'gray.700');
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨边框:红色系 → 橙色系(跟随文字深浅)
|
||||
if (absValue >= 10) return useColorModeValue('red.200', 'red.800'); // 文字 red.900
|
||||
if (absValue >= 5) return useColorModeValue('red.200', 'red.700'); // 文字 red.700
|
||||
if (absValue >= 3) return useColorModeValue('red.100', 'red.600'); // 文字 red.500
|
||||
if (absValue >= 1) return useColorModeValue('orange.200', 'orange.700'); // 文字 orange.600
|
||||
return useColorModeValue('orange.100', 'orange.600'); // 文字 orange.400
|
||||
} else {
|
||||
// 下跌边框:绿色系 → 青色系(跟随文字深浅)
|
||||
if (absValue >= 10) return useColorModeValue('green.200', 'green.800'); // 文字 green.900
|
||||
if (absValue >= 5) return useColorModeValue('green.200', 'green.700'); // 文字 green.700
|
||||
if (absValue >= 3) return useColorModeValue('green.100', 'green.600'); // 文字 green.500
|
||||
if (absValue >= 1) return useColorModeValue('teal.200', 'teal.700'); // 文字 teal.600
|
||||
return useColorModeValue('teal.100', 'teal.600'); // 文字 teal.400
|
||||
}
|
||||
// 统一边框色:上涨红色系,下跌绿色系
|
||||
return value > 0
|
||||
? useColorModeValue('red.200', 'red.700')
|
||||
: useColorModeValue('green.200', 'green.700');
|
||||
};
|
||||
|
||||
// 渲染单个指标
|
||||
const renderIndicator = (label, value) => {
|
||||
if (value == null) return null;
|
||||
|
||||
const sign = value > 0 ? '+' : '';
|
||||
// 0值显示为 "0",其他值显示一位小数
|
||||
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(1);
|
||||
const sign = value > 0 ? '+' : '-';
|
||||
// 0值显示为 "0",其他值显示两位小数
|
||||
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(2);
|
||||
const numberColor = getNumberColor(value);
|
||||
const bgColor = getBgColor(value);
|
||||
const borderColor = getBorderColor(value);
|
||||
const labelColor = useColorModeValue('gray.700', 'gray.400');
|
||||
const labelColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderWidth="2px"
|
||||
borderWidth={isLarge ? "2px" : "1px"}
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
px={isLarge ? 4 : (isDefault ? 1.5 : (isComfortable ? 3 : 2))}
|
||||
py={isLarge ? 3 : (isDefault ? 1.5 : (isComfortable ? 2 : 1))}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexDirection={(isLarge || isDefault) ? "column" : "row"}
|
||||
alignItems={(isLarge || isDefault) ? "flex-start" : "center"}
|
||||
gap={(isLarge || isDefault) ? (isLarge ? 2 : 1) : 1}
|
||||
maxW={isLarge ? "200px" : "none"}
|
||||
flex="0 1 auto"
|
||||
minW="0"
|
||||
>
|
||||
<Text fontSize="xs" lineHeight="1.2">
|
||||
<Text as="span" color={labelColor}>
|
||||
{label}
|
||||
{/* Large 和 Default 模式:标签单独一行 */}
|
||||
{(isLarge || isDefault) && (
|
||||
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
|
||||
{label.trim()}
|
||||
</Text>
|
||||
<Text as="span" color={labelColor}>
|
||||
{sign}
|
||||
)}
|
||||
|
||||
{/* 数值 + 图标 */}
|
||||
<Flex align="center" gap={isLarge ? 2 : (isDefault ? 1 : 1)}>
|
||||
{/* 三角形图标 */}
|
||||
{value !== 0 && (
|
||||
value > 0 ? (
|
||||
<TriangleUpIcon
|
||||
w={2}
|
||||
h={2}
|
||||
color={numberColor}
|
||||
/>
|
||||
) : (
|
||||
<TriangleDownIcon
|
||||
w={2}
|
||||
h={2}
|
||||
color={numberColor}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 数字 */}
|
||||
<Text
|
||||
fontSize={isLarge ? "2xl" : (isDefault ? "md" : "lg")}
|
||||
fontWeight="bold"
|
||||
color={numberColor}
|
||||
lineHeight="1.2"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
{/* Comfortable 模式:标签和数字在同一行 */}
|
||||
{!isLarge && !isDefault && (
|
||||
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
{sign}{numStr}
|
||||
<Text as="span" fontWeight="medium" fontSize="sm">%</Text>
|
||||
</Text>
|
||||
<Text as="span" fontWeight="bold" color={numberColor} fontSize="sm">
|
||||
{value < 0 ? '-' : ''}{numStr}
|
||||
</Text>
|
||||
<Text as="span" color={labelColor}>
|
||||
%
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -157,10 +152,10 @@ const StockChangeIndicators = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex width="100%" justify="space-between" align="center" gap={1}>
|
||||
{renderIndicator('平均 ', avgChange)}
|
||||
{renderIndicator('最大 ', maxChange)}
|
||||
{renderIndicator('周涨 ', weekChange)}
|
||||
<Flex width="100%" justify="flex-start" align="center" gap={isLarge ? 4 : (isDefault ? 2 : 1)}>
|
||||
{renderIndicator('平均涨幅', avgChange)}
|
||||
{renderIndicator('最大涨幅', maxChange)}
|
||||
{renderIndicator('周涨幅', weekChange)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
Td,
|
||||
Heading,
|
||||
Collapse,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
} from '@chakra-ui/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -76,7 +79,8 @@ export default function SubscriptionContent() {
|
||||
// State
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [selectedCycle, setSelectedCycle] = useState('monthly');
|
||||
const [selectedCycle, setSelectedCycle] = useState('monthly'); // 保持向后兼容,默认月付
|
||||
const [selectedCycleOption, setSelectedCycleOption] = useState(null); // 当前选中的pricing_option对象
|
||||
const [paymentOrder, setPaymentOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paymentCountdown, setPaymentCountdown] = useState(0);
|
||||
@@ -85,6 +89,13 @@ export default function SubscriptionContent() {
|
||||
const [forceUpdating, setForceUpdating] = useState(false);
|
||||
const [openFaqIndex, setOpenFaqIndex] = useState(null);
|
||||
|
||||
// 优惠码相关state
|
||||
const [promoCode, setPromoCode] = useState('');
|
||||
const [promoCodeApplied, setPromoCodeApplied] = useState(false);
|
||||
const [promoCodeError, setPromoCodeError] = useState('');
|
||||
const [validatingPromo, setValidatingPromo] = useState(false);
|
||||
const [priceInfo, setPriceInfo] = useState(null); // 价格信息(包含升级计算)
|
||||
|
||||
// 加载订阅套餐数据
|
||||
useEffect(() => {
|
||||
fetchSubscriptionPlans();
|
||||
@@ -149,7 +160,102 @@ export default function SubscriptionContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = (plan) => {
|
||||
// 计算价格(包含升级和优惠码)
|
||||
const calculatePrice = async (plan, cycle, promoCodeValue = null) => {
|
||||
try {
|
||||
// 确保优惠码值正确:只接受非空字符串,其他情况传null
|
||||
const validPromoCode = promoCodeValue && typeof promoCodeValue === 'string' && promoCodeValue.trim()
|
||||
? promoCodeValue.trim()
|
||||
: null;
|
||||
|
||||
logger.debug('SubscriptionContent', '计算价格', {
|
||||
plan: plan.name,
|
||||
cycle,
|
||||
promoCodeValue,
|
||||
validPromoCode
|
||||
});
|
||||
|
||||
const response = await fetch('/api/subscription/calculate-price', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
to_plan: plan.name,
|
||||
to_cycle: cycle,
|
||||
promo_code: validPromoCode
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setPriceInfo(data.data);
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'calculatePrice', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证优惠码
|
||||
const handleValidatePromoCode = async () => {
|
||||
const trimmedCode = promoCode.trim();
|
||||
|
||||
if (!trimmedCode) {
|
||||
setPromoCodeError('请输入优惠码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPlan) {
|
||||
setPromoCodeError('请先选择套餐');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingPromo(true);
|
||||
setPromoCodeError('');
|
||||
|
||||
try {
|
||||
// 重新计算价格,包含优惠码(使用去除空格后的值)
|
||||
const result = await calculatePrice(selectedPlan, selectedCycle, trimmedCode);
|
||||
|
||||
if (result && !result.promo_error) {
|
||||
setPromoCodeApplied(true);
|
||||
toast({
|
||||
title: '优惠码已应用',
|
||||
description: `节省 ¥${result.discount_amount.toFixed(2)}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
setPromoCodeError(result?.promo_error || '优惠码无效');
|
||||
setPromoCodeApplied(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setPromoCodeError('验证失败,请重试');
|
||||
setPromoCodeApplied(false);
|
||||
} finally {
|
||||
setValidatingPromo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除优惠码
|
||||
const handleRemovePromoCode = async () => {
|
||||
setPromoCode('');
|
||||
setPromoCodeApplied(false);
|
||||
setPromoCodeError('');
|
||||
// 重新计算价格(不含优惠码)
|
||||
if (selectedPlan) {
|
||||
await calculatePrice(selectedPlan, selectedCycle, null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (plan) => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: '请先登录',
|
||||
@@ -178,6 +284,10 @@ export default function SubscriptionContent() {
|
||||
);
|
||||
|
||||
setSelectedPlan(plan);
|
||||
|
||||
// 计算价格(包含升级判断)
|
||||
await calculatePrice(plan, selectedCycle, promoCodeApplied ? promoCode : null);
|
||||
|
||||
onPaymentModalOpen();
|
||||
};
|
||||
|
||||
@@ -186,7 +296,7 @@ export default function SubscriptionContent() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price;
|
||||
const price = priceInfo?.final_amount || (selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price);
|
||||
|
||||
// 🎯 追踪支付发起
|
||||
subscriptionEvents.trackPaymentInitiated({
|
||||
@@ -205,7 +315,8 @@ export default function SubscriptionContent() {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
plan_name: selectedPlan.name,
|
||||
billing_cycle: selectedCycle
|
||||
billing_cycle: selectedCycle,
|
||||
promo_code: promoCodeApplied ? promoCode : null
|
||||
})
|
||||
});
|
||||
|
||||
@@ -477,15 +588,83 @@ export default function SubscriptionContent() {
|
||||
|
||||
const getCurrentPrice = (plan) => {
|
||||
if (!plan) return 0;
|
||||
|
||||
// 如果有pricing_options,使用它
|
||||
if (plan.pricing_options && plan.pricing_options.length > 0) {
|
||||
// 查找当前选中的周期选项
|
||||
const option = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
return option ? option.price : plan.monthly_price;
|
||||
}
|
||||
|
||||
// 向后兼容:回退到monthly_price/yearly_price
|
||||
return selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price;
|
||||
};
|
||||
|
||||
const getSavingsText = (plan) => {
|
||||
if (!plan || selectedCycle !== 'yearly') return null;
|
||||
const yearlyTotal = plan.monthly_price * 12;
|
||||
const savings = yearlyTotal - plan.yearly_price;
|
||||
const percentage = Math.round((savings / yearlyTotal) * 100);
|
||||
return `年付节省 ${percentage}%`;
|
||||
if (!plan) return null;
|
||||
|
||||
// 如果有pricing_options,从中查找discount_percent
|
||||
if (plan.pricing_options && plan.pricing_options.length > 0) {
|
||||
const currentOption = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
|
||||
if (currentOption && currentOption.discount_percent) {
|
||||
return `立减 ${currentOption.discount_percent}%`;
|
||||
}
|
||||
|
||||
// 如果没有discount_percent,尝试计算节省金额
|
||||
if (currentOption && currentOption.months > 1) {
|
||||
const monthlyOption = plan.pricing_options.find(opt => opt.months === 1);
|
||||
if (monthlyOption) {
|
||||
const expectedTotal = monthlyOption.price * currentOption.months;
|
||||
const savings = expectedTotal - currentOption.price;
|
||||
if (savings > 0) {
|
||||
const percentage = Math.round((savings / expectedTotal) * 100);
|
||||
return `${currentOption.label || `${currentOption.months}个月`}节省 ${percentage}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容:计算年付节省
|
||||
if (selectedCycle === 'yearly') {
|
||||
const yearlyTotal = plan.monthly_price * 12;
|
||||
const savings = yearlyTotal - plan.yearly_price;
|
||||
if (savings > 0) {
|
||||
const percentage = Math.round((savings / yearlyTotal) * 100);
|
||||
return `年付节省 ${percentage}%`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取按钮文字(根据用户当前订阅判断是升级还是新订阅)
|
||||
const getButtonText = (plan, user) => {
|
||||
if (!user || user.subscription_type === 'free') {
|
||||
return `选择 ${plan.display_name}`;
|
||||
}
|
||||
|
||||
// 判断是否为升级
|
||||
const planLevels = { 'free': 0, 'pro': 1, 'max': 2 };
|
||||
const currentLevel = planLevels[user.subscription_type] || 0;
|
||||
const targetLevel = planLevels[plan.name] || 0;
|
||||
|
||||
if (targetLevel > currentLevel) {
|
||||
return `升级至 ${plan.display_name}`;
|
||||
} else if (targetLevel < currentLevel) {
|
||||
return `切换至 ${plan.display_name}`;
|
||||
} else {
|
||||
// 同级别,可能是切换周期
|
||||
return `切换至 ${plan.display_name}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 统一的功能列表定义 - 基于商业定价(10月15日)文档
|
||||
@@ -578,36 +757,107 @@ export default function SubscriptionContent() {
|
||||
)}
|
||||
|
||||
{/* 计费周期选择 */}
|
||||
<Flex justify="center">
|
||||
<HStack
|
||||
spacing={0}
|
||||
bg={bgAccent}
|
||||
borderRadius="lg"
|
||||
p={1}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Button
|
||||
variant={selectedCycle === 'monthly' ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedCycle === 'monthly' ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle('monthly')}
|
||||
borderRadius="md"
|
||||
<Box>
|
||||
<Text textAlign="center" fontSize="sm" color={secondaryText} mb={3}>
|
||||
选择计费周期 · 时长越长优惠越大
|
||||
</Text>
|
||||
<Flex justify="center" mb={2}>
|
||||
<HStack
|
||||
spacing={2}
|
||||
bg={bgAccent}
|
||||
borderRadius="xl"
|
||||
p={2}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
flexWrap="wrap"
|
||||
justify="center"
|
||||
>
|
||||
按月付费
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCycle === 'yearly' ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedCycle === 'yearly' ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle('yearly')}
|
||||
borderRadius="md"
|
||||
>
|
||||
按年付费
|
||||
<Badge ml={2} colorScheme="red" fontSize="xs">省20%</Badge>
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
{(() => {
|
||||
// 获取第一个套餐的pricing_options作为周期选项(假设所有套餐都有相同的周期)
|
||||
const firstPlan = subscriptionPlans.find(plan => plan.pricing_options);
|
||||
const cycleOptions = firstPlan?.pricing_options || [
|
||||
{ cycle_key: 'monthly', label: '月付', months: 1 },
|
||||
{ cycle_key: 'yearly', label: '年付', months: 12, discount_percent: 20 }
|
||||
];
|
||||
|
||||
return cycleOptions.map((option, index) => {
|
||||
const cycleKey = option.cycle_key || (option.months === 1 ? 'monthly' : option.months === 12 ? 'yearly' : `${option.months}months`);
|
||||
const isSelected = selectedCycle === cycleKey;
|
||||
const hasDiscount = option.discount_percent && option.discount_percent > 0;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
key={index}
|
||||
spacing={0}
|
||||
position="relative"
|
||||
>
|
||||
{/* 折扣标签 */}
|
||||
{hasDiscount && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
colorScheme="red"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
>
|
||||
省{option.discount_percent}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={isSelected ? 'solid' : 'outline'}
|
||||
colorScheme={isSelected ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle(cycleKey)}
|
||||
borderRadius="lg"
|
||||
minW="80px"
|
||||
h="50px"
|
||||
position="relative"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'md'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack spacing={0}>
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
{option.label || `${option.months}个月`}
|
||||
</Text>
|
||||
{hasDiscount && (
|
||||
<Text fontSize="xs" color={isSelected ? 'white' : 'gray.500'}>
|
||||
更优惠
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</HStack>
|
||||
</Flex>
|
||||
{/* 提示文字 */}
|
||||
{(() => {
|
||||
const firstPlan = subscriptionPlans.find(plan => plan.pricing_options);
|
||||
const cycleOptions = firstPlan?.pricing_options || [];
|
||||
const currentOption = cycleOptions.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
|
||||
if (currentOption && currentOption.discount_percent > 0) {
|
||||
return (
|
||||
<Text textAlign="center" fontSize="sm" color="green.600" fontWeight="medium">
|
||||
🎉 当前选择可节省 {currentOption.discount_percent}% 的费用
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
{/* 订阅套餐 */}
|
||||
<Grid
|
||||
@@ -780,19 +1030,69 @@ export default function SubscriptionContent() {
|
||||
{getCurrentPrice(plan).toFixed(0)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
/{selectedCycle === 'monthly' ? '月' : '年'}
|
||||
{(() => {
|
||||
if (plan.pricing_options) {
|
||||
const option = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
if (option) {
|
||||
// 如果months是1,显示"/月";如果是12,显示"/年";否则显示周期label
|
||||
if (option.months === 1) return '/月';
|
||||
if (option.months === 12) return '/年';
|
||||
return `/${option.months}个月`;
|
||||
}
|
||||
}
|
||||
return selectedCycle === 'monthly' ? '/月' : '/年';
|
||||
})()}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="xs" color={secondaryText} pl={11}>
|
||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
{getSavingsText(plan) && (
|
||||
<Badge colorScheme="green" fontSize="xs" px={2} py={1}>
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
)}
|
||||
{(() => {
|
||||
// 获取当前选中的周期信息
|
||||
if (plan.pricing_options) {
|
||||
const currentOption = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
|
||||
if (currentOption && currentOption.discount_percent > 0) {
|
||||
// 计算原价和节省金额
|
||||
const monthlyOption = plan.pricing_options.find(opt => opt.months === 1);
|
||||
if (monthlyOption) {
|
||||
const originalPrice = monthlyOption.price * currentOption.months;
|
||||
const savedAmount = originalPrice - currentOption.price;
|
||||
|
||||
return (
|
||||
<VStack spacing={0} align="flex-end">
|
||||
<Badge colorScheme="red" fontSize="xs" px={3} py={1} borderRadius="full">
|
||||
立省 {currentOption.discount_percent}%
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.500" textDecoration="line-through">
|
||||
原价¥{originalPrice.toFixed(0)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="green.600" fontWeight="bold">
|
||||
省¥{savedAmount.toFixed(0)}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge colorScheme="green" fontSize="xs" px={3} py={1} borderRadius="full">
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Flex>
|
||||
</VStack>
|
||||
|
||||
@@ -838,7 +1138,8 @@ export default function SubscriptionContent() {
|
||||
onClick={() => handleSubscribe(plan)}
|
||||
isDisabled={
|
||||
user?.subscription_type === plan.name &&
|
||||
user?.subscription_status === 'active'
|
||||
user?.subscription_status === 'active' &&
|
||||
user?.billing_cycle === selectedCycle
|
||||
}
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
@@ -846,9 +1147,10 @@ export default function SubscriptionContent() {
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{user?.subscription_type === plan.name &&
|
||||
user?.subscription_status === 'active'
|
||||
? '✓ 已订阅'
|
||||
: `选择 ${plan.display_name}`
|
||||
user?.subscription_status === 'active' &&
|
||||
user?.billing_cycle === selectedCycle
|
||||
? '✓ 当前套餐'
|
||||
: getButtonText(plan, user)
|
||||
}
|
||||
</Button>
|
||||
</VStack>
|
||||
@@ -958,7 +1260,7 @@ export default function SubscriptionContent() {
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
可以在月付和年付之间切换吗?
|
||||
升级或切换套餐时,原套餐的费用怎么办?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 2 ? FaChevronUp : FaChevronDown}
|
||||
@@ -967,14 +1269,28 @@ export default function SubscriptionContent() {
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 2}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
可以。您可以随时更改计费周期。如果从月付切换到年付,系统会计算剩余价值并应用到新的订阅中。年付用户可享受20%的折扣优惠。
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text>
|
||||
当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。
|
||||
</Text>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
计算方式:
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• <strong>剩余价值</strong> = 原套餐价格 × (剩余天数 / 总天数)
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• <strong>实付金额</strong> = 新套餐价格 - 剩余价值 - 优惠码折扣
|
||||
</Text>
|
||||
<Text fontSize="sm" color="blue.600" mt={2}>
|
||||
例如:您购买了年付Pro版(¥999),使用了180天后升级到Max版(¥1999/年),剩余价值约¥500将自动抵扣,实付约¥1499。
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 4 */}
|
||||
{/* FAQ 4 - 原FAQ 3 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
@@ -992,7 +1308,7 @@ export default function SubscriptionContent() {
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
是否提供退款?
|
||||
可以在月付和年付之间切换吗?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 3 ? FaChevronUp : FaChevronDown}
|
||||
@@ -1002,13 +1318,13 @@ export default function SubscriptionContent() {
|
||||
<Collapse in={openFaqIndex === 3}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
我们提供7天无理由退款保证。如果您在订阅后7天内对服务不满意,可以申请全额退款。超过7天后,我们将根据实际使用情况进行评估。
|
||||
可以。您可以随时更改计费周期。如果从月付切换到年付,系统会计算剩余价值并应用到新的订阅中。年付用户可享受20%的折扣优惠。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 5 */}
|
||||
{/* FAQ 5 - 原FAQ 4 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
@@ -1026,7 +1342,7 @@ export default function SubscriptionContent() {
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
Pro版和Max版有什么区别?
|
||||
是否支持退款?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 4 ? FaChevronUp : FaChevronDown}
|
||||
@@ -1034,6 +1350,60 @@ export default function SubscriptionContent() {
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 4}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text>
|
||||
为了保障服务质量和维护公平的商业环境,我们<strong>不支持退款</strong>。
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
建议您在订阅前:
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 充分了解各套餐的功能差异
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 使用免费版体验基础功能
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 根据实际需求选择合适的计费周期
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 如有疑问可联系客服咨询
|
||||
</Text>
|
||||
<Text fontSize="sm" color="blue.600" mt={2}>
|
||||
提示:选择长期套餐(如半年付、年付)可享受更大折扣,性价比更高。
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 6 - 原FAQ 5 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 5 ? null : 5)}
|
||||
bg={openFaqIndex === 5 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
Pro版和Max版有什么区别?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 5 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 5}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
Pro版适合个人专业用户,提供高级图表、历史数据分析等功能。Max版则是为团队和企业设计,额外提供实时数据推送、API访问、无限制的数据存储和团队协作功能,并享有优先技术支持。
|
||||
@@ -1052,6 +1422,11 @@ export default function SubscriptionContent() {
|
||||
stopAutoPaymentCheck();
|
||||
setPaymentOrder(null);
|
||||
setPaymentCountdown(0);
|
||||
// 清空优惠码状态
|
||||
setPromoCode('');
|
||||
setPromoCodeApplied(false);
|
||||
setPromoCodeError('');
|
||||
setPriceInfo(null);
|
||||
onPaymentModalClose();
|
||||
}}
|
||||
size="lg"
|
||||
@@ -1082,16 +1457,76 @@ export default function SubscriptionContent() {
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text color={secondaryText}>计费周期:</Text>
|
||||
<Text>{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}</Text>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex justify="space-between" align="baseline">
|
||||
<Text color={secondaryText}>应付金额:</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
|
||||
¥{getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
<Text>
|
||||
{(() => {
|
||||
if (selectedPlan?.pricing_options) {
|
||||
const option = selectedPlan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
return option?.label || (selectedCycle === 'monthly' ? '按月付费' : '按年付费');
|
||||
}
|
||||
return selectedCycle === 'monthly' ? '按月付费' : '按年付费';
|
||||
})()}
|
||||
</Text>
|
||||
</Flex>
|
||||
{getSavingsText(selectedPlan) && (
|
||||
|
||||
{/* 价格明细 */}
|
||||
<Divider my={2} />
|
||||
|
||||
{priceInfo && priceInfo.is_upgrade && (
|
||||
<Box bg="blue.50" p={3} borderRadius="md" mb={2}>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FaCheck} color="blue.500" boxSize={4} />
|
||||
<Text fontSize="sm" fontWeight="bold" color="blue.700">
|
||||
{priceInfo.upgrade_type === 'plan_upgrade' ? '套餐升级' :
|
||||
priceInfo.upgrade_type === 'cycle_change' ? '周期变更' : '套餐和周期调整'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack spacing={1} align="stretch" fontSize="xs">
|
||||
<Flex justify="space-between" color="gray.600">
|
||||
<Text>当前订阅: {priceInfo.current_plan === 'pro' ? 'Pro版' : 'Max版'} ({priceInfo.current_cycle === 'monthly' ? '月付' : '年付'})</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" color="gray.600">
|
||||
<Text>剩余价值:</Text>
|
||||
<Text>¥{priceInfo.remaining_value.toFixed(2)}</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Flex justify="space-between">
|
||||
<Text color={secondaryText}>
|
||||
{priceInfo && priceInfo.is_upgrade ? '新套餐价格:' : '套餐价格:'}
|
||||
</Text>
|
||||
<Text fontWeight="medium">
|
||||
¥{priceInfo ? priceInfo.new_plan_price.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{priceInfo && priceInfo.is_upgrade && priceInfo.remaining_value > 0 && (
|
||||
<Flex justify="space-between" color="blue.600">
|
||||
<Text>已付剩余抵扣:</Text>
|
||||
<Text>-¥{priceInfo.remaining_value.toFixed(2)}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{priceInfo && priceInfo.discount_amount > 0 && (
|
||||
<Flex justify="space-between" color="green.600">
|
||||
<Text>优惠码折扣:</Text>
|
||||
<Text>-¥{priceInfo.discount_amount.toFixed(2)}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Flex justify="space-between" align="baseline">
|
||||
<Text fontSize="lg" fontWeight="bold">实付金额:</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
|
||||
¥{priceInfo ? priceInfo.final_amount.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
{getSavingsText(selectedPlan) && !priceInfo?.is_upgrade && (
|
||||
<Badge colorScheme="green" alignSelf="flex-end" fontSize="xs">
|
||||
{getSavingsText(selectedPlan)}
|
||||
</Badge>
|
||||
@@ -1104,6 +1539,53 @@ export default function SubscriptionContent() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 优惠码输入 */}
|
||||
{selectedPlan && (
|
||||
<Box>
|
||||
<HStack spacing={2}>
|
||||
<Input
|
||||
placeholder="输入优惠码(可选)"
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
setPromoCodeError('');
|
||||
}}
|
||||
size="md"
|
||||
isDisabled={promoCodeApplied}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleValidatePromoCode}
|
||||
isLoading={validatingPromo}
|
||||
isDisabled={!promoCode || promoCodeApplied}
|
||||
minW="80px"
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</HStack>
|
||||
{promoCodeError && (
|
||||
<Text color="red.500" fontSize="sm" mt={2}>
|
||||
{promoCodeError}
|
||||
</Text>
|
||||
)}
|
||||
{promoCodeApplied && priceInfo && (
|
||||
<HStack mt={2} p={2} bg="green.50" borderRadius="md">
|
||||
<Icon as={FaCheck} color="green.500" />
|
||||
<Text color="green.700" fontSize="sm" fontWeight="medium" flex={1}>
|
||||
优惠码已应用!节省 ¥{priceInfo.discount_amount.toFixed(2)}
|
||||
</Text>
|
||||
<Icon
|
||||
as={FaTimes}
|
||||
color="gray.500"
|
||||
cursor="pointer"
|
||||
onClick={handleRemovePromoCode}
|
||||
_hover={{ color: 'red.500' }}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="green"
|
||||
size="lg"
|
||||
|
||||
83
src/components/SubscriptionBadge.js
Normal file
83
src/components/SubscriptionBadge.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/SubscriptionBadge.js
|
||||
// 会员专享标签组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Badge,
|
||||
HStack,
|
||||
Icon,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaStar, FaCrown } from 'react-icons/fa';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 会员专享标签组件
|
||||
* @param {Object} props
|
||||
* @param {'pro' | 'max'} props.tier - 会员等级:pro 或 max
|
||||
* @param {'sm' | 'md'} props.size - 标签尺寸
|
||||
*/
|
||||
const SubscriptionBadge = ({ tier = 'pro', size = 'sm' }) => {
|
||||
// PRO 和 MAX 配置
|
||||
const config = {
|
||||
pro: {
|
||||
label: 'PRO专享',
|
||||
icon: FaStar,
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500)',
|
||||
color: 'white',
|
||||
},
|
||||
max: {
|
||||
label: 'MAX专享',
|
||||
icon: FaCrown,
|
||||
bgGradient: 'linear(to-r, pink.400, red.500)',
|
||||
color: 'white',
|
||||
},
|
||||
};
|
||||
|
||||
const tierConfig = config[tier] || config.pro;
|
||||
|
||||
// 尺寸配置
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
fontSize: 'xs',
|
||||
iconSize: 2.5,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
},
|
||||
md: {
|
||||
fontSize: 'sm',
|
||||
iconSize: 3,
|
||||
px: 3,
|
||||
py: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size] || sizeConfig.sm;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
bgGradient={tierConfig.bgGradient}
|
||||
color={tierConfig.color}
|
||||
borderRadius="full"
|
||||
px={currentSize.px}
|
||||
py={currentSize.py}
|
||||
fontSize={currentSize.fontSize}
|
||||
fontWeight="bold"
|
||||
boxShadow="sm"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Icon as={tierConfig.icon} boxSize={currentSize.iconSize} />
|
||||
<Text>{tierConfig.label}</Text>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionBadge;
|
||||
@@ -1,275 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Text,
|
||||
Box,
|
||||
VStack,
|
||||
Divider,
|
||||
useColorModeValue
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
const UserAgreementModal = ({ isOpen, onClose }) => {
|
||||
const modalBg = useColorModeValue("white", "gray.800");
|
||||
const headingColor = useColorModeValue("gray.800", "white");
|
||||
const textColor = useColorModeValue("gray.600", "gray.300");
|
||||
|
||||
// Conditional rendering: only render Modal when open
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.600" />
|
||||
<ModalContent
|
||||
maxW="95vw"
|
||||
maxH="95vh"
|
||||
bg={modalBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="2xl"
|
||||
mx={4}
|
||||
>
|
||||
<ModalHeader
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color={headingColor}
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.200"
|
||||
borderRadius="xl xl 0 0"
|
||||
py={6}
|
||||
>
|
||||
价值前沿用户协议
|
||||
</ModalHeader>
|
||||
<ModalCloseButton
|
||||
size="lg"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
/>
|
||||
<ModalBody py={8} px={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box bg="orange.50" p={6} borderRadius="lg" border="1px solid" borderColor="orange.100">
|
||||
<Text fontSize="xl" fontWeight="bold" color="orange.700" mb={6}>
|
||||
欢迎你使用价值前沿及服务!
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={5}>
|
||||
为使用价值前沿(以下简称"本软件")及服务,你应当阅读并遵守《价值前沿用户协议》(以下简称"本协议")。请你务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款,以及开通或使用某项服务的单独协议,并选择接受或不接受。限制、免责条款可能以加粗形式提示你注意。
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={5}>
|
||||
除非你已阅读并接受本协议所有条款,否则你无权下载、安装或使用本软件及相关服务。你的下载、安装、使用、获取价值前沿帐号、登录等行为即视为你已阅读并同意上述协议的约束。
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" fontWeight="medium">
|
||||
如果你未满18周岁,请在法定监护人的陪同下阅读本协议及其他上述协议,并特别注意未成年人使用条款。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={6}>
|
||||
一、协议的范围
|
||||
</Text>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="blue.300" bg="blue.50" p={4} borderRadius="md" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="blue.700" mb={2}>
|
||||
1.1 协议适用主体范围
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
本协议是你与北京价值前沿科技有限公司之间关于你下载、安装、使用、复制本软件,以及使用价值前沿相关服务所订立的协议。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="green.300" bg="green.50" p={4} borderRadius="md" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="green.700" mb={2}>
|
||||
1.2 协议关系及冲突条款
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
本协议内容同时包括北京价值前沿科技有限公司可能不断发布的关于本服务的相关协议、业务规则等内容。上述内容一经正式发布,即为本协议不可分割的组成部分,你同样应当遵守。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="purple.300" bg="purple.50" p={4} borderRadius="md" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="purple.700" mb={2}>
|
||||
1.3 许可范围
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
明确标识免费产品的,用户可以进行自用的、非商业性、无限制数量地下载、安装及使用,但不得复制、分发。其他收费类产品或者信息,除遵守本协议规定之外,还须遵守专门协议的规定。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="red.300" bg="red.50" p={4} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="red.700" mb={2}>
|
||||
1.4 权利限制
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
禁止反向工程、反向编译和反向汇编:用户不得对价值前沿软件类产品进行反向工程、反向编译或反向汇编,同时不得改动编译程序文件内部的任何资源。除法律、法规明文规定允许上述活动外,用户必须遵守此协议限制。
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
二、关于本服务
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
本服务内容是指北京价值前沿科技有限公司向用户提供的跨平台的社交资讯工具(以下简称"价值前沿"),支持单人、多人参与,在发布图片和文字等内容服务的基础上,同时为用户提供包括但不限于社交关系拓展、便捷工具等功能或内容的软件服务(以下简称"本服务")。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
三、免责条款
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
北京价值前沿科技有限公司作为面向全球投资人提供信息和服务的商家,对以下情况不承担相关责任:
|
||||
</Text>
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
1)不可抗力
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
如因发生自然灾害、战争、第三方侵害等不可控因素而发生的信息、服务中断,价值前沿不承担相应责任。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
2)信息网络传播
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
如因信息网络传播中的拥塞、断续、病毒木马、黑客窃取侦听等网络通道上的因素,而造成信息缺失、丢失、延迟、被篡改等,价值前沿不对此承担相应责任。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
3)第三方信息的收集整理
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
价值前沿为了更好地服务投资者,便于用户分析研判投资环境,尽可能多地收集整理来自第三方的所有信息,分门别类地提供给用户参考,并明确标识为来自第三方的信息,而对内容的真实性、合理性、完整性、合法性等并不承担判断责任,也不承担用户因信息而造成的损失责任。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
4)证券信息汇总
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
价值前沿仅提供证券信息汇总及证券投资品种历史数据统计功能,不针对用户提供任何情况判断、投资参考、品种操作建议等等,不属于荐股软件。用户按照自身对于市场环境的分析研判而做出的评论参考,用户可以结合自身需求予以借鉴,并自行作出判断,风险和收益都由用户自行承担。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
5)信息储存
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
用户在使用价值前沿系统时,会因信息注册、产品购买、软件使用过程中的某些需求,而留存于系统中的账户、密码、真实身份、联系方式、用户网络信息等个人信息,价值前沿将按照国家相关规定进行必要的保护。
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
四、用户个人信息保护
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
保护用户个人信息是北京价值前沿科技有限公司的一项基本原则,北京价值前沿科技有限公司将会采取合理的措施保护用户的个人信息。除法律法规规定的情形外,未经用户许可北京价值前沿科技有限公司不会向第三方公开、透露用户个人信息。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
你在注册帐号或使用本服务的过程中,需要提供一些必要的信息,例如:为向你提供帐号注册服务或进行用户身份识别,需要你填写手机号码;手机通讯录匹配功能需要你授权访问手机通讯录等。若国家法律法规或政策有特殊规定的,你需要提供真实的身份信息。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
五、用户行为规范
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
你理解并同意,价值前沿一直致力于为用户提供文明健康、规范有序的网络环境,你不得利用价值前沿帐号或本软件及服务制作、复制、发布、传播干扰正常运营,以及侵犯其他用户或第三方合法权益的内容。
|
||||
</Text>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="red.400" bg="red.50" p={4} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="red.700" mb={2}>
|
||||
禁止内容包括但不限于:
|
||||
</Text>
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="md" color={textColor}>• 违反宪法确定的基本原则的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 危害国家安全,泄露国家秘密的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 损害国家荣誉和利益的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 散布谣言,扰乱社会秩序的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 散布淫秽、色情、赌博、暴力、恐怖的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 侮辱或者诽谤他人,侵害他人合法权益的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 其他违反法律法规的内容</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
六、知识产权声明
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
北京价值前沿科技有限公司是本软件的知识产权权利人。本软件的一切著作权、商标权、专利权、商业秘密等知识产权,以及与本软件相关的所有信息内容(包括但不限于文字、图片、音频、视频、图表、界面设计、版面框架、有关数据或电子文档等)均受中华人民共和国法律法规和相应的国际条约保护。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
未经北京价值前沿科技有限公司或相关权利人书面同意,你不得为任何商业或非商业目的自行或许可任何第三方实施、利用、转让上述知识产权。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
七、其他条款
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
你使用本软件即视为你已阅读并同意受本协议的约束。北京价值前沿科技有限公司有权在必要时修改本协议条款。你可以在本软件的最新版本中查阅相关协议条款。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
本协议签订地为中华人民共和国北京市海淀区。本协议的成立、生效、履行、解释及纠纷解决,适用中华人民共和国大陆地区法律(不包括冲突法)。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
若你和北京价值前沿科技有限公司之间发生任何纠纷或争议,首先应友好协商解决;协商不成的,你同意将纠纷或争议提交本协议签订地有管辖权的人民法院管辖。
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgreementModal;
|
||||
@@ -19,9 +19,12 @@ export const IMPORTANCE_LEVELS = {
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.800', // 角标边框和文字颜色 - 极深红色
|
||||
badgeBg: '#dc2626', // 圆形徽章背景色 - 红色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
stampText: '极', // 印章文字
|
||||
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
|
||||
dotBg: 'red.800',
|
||||
description: '重大事件,市场影响深远',
|
||||
antdColor: '#cf1322',
|
||||
@@ -32,9 +35,12 @@ export const IMPORTANCE_LEVELS = {
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.600', // 角标边框和文字颜色 - 深红色
|
||||
badgeBg: '#ea580c', // 圆形徽章背景色 - 橙色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
stampText: '高', // 印章文字
|
||||
stampFont: "'STXingkai', 'FangSong', 'STFangsong', cursive", // 行楷/草书
|
||||
dotBg: 'red.600',
|
||||
description: '重要事件,影响较大',
|
||||
antdColor: '#ff4d4f',
|
||||
@@ -45,9 +51,12 @@ export const IMPORTANCE_LEVELS = {
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.500', // 角标边框和文字颜色 - 中红色
|
||||
badgeBg: '#2563eb', // 圆形徽章背景色 - 蓝色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
stampText: '中', // 印章文字
|
||||
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
|
||||
dotBg: 'red.500',
|
||||
description: '普通事件,有一定影响',
|
||||
antdColor: '#ff7875',
|
||||
@@ -58,9 +67,12 @@ export const IMPORTANCE_LEVELS = {
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.400', // 角标边框和文字颜色 - 浅红色
|
||||
badgeBg: '#10b981', // 圆形徽章背景色 - 青绿色(替代灰色)
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
stampText: '低', // 印章文字
|
||||
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
|
||||
dotBg: 'red.400',
|
||||
description: '参考事件,影响有限',
|
||||
antdColor: '#ffa39e',
|
||||
|
||||
@@ -292,8 +292,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
* 发送浏览器通知
|
||||
*/
|
||||
const sendBrowserNotification = useCallback((notificationData) => {
|
||||
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
|
||||
console.log('[NotificationContext] 通知数据:', notificationData);
|
||||
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
|
||||
|
||||
if (browserPermission !== 'granted') {
|
||||
logger.warn('NotificationContext', 'Browser permission not granted');
|
||||
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,6 +310,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 判断是否需要用户交互(紧急通知不自动关闭)
|
||||
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
|
||||
|
||||
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
|
||||
title,
|
||||
body: content,
|
||||
tag,
|
||||
requireInteraction,
|
||||
link
|
||||
});
|
||||
|
||||
// 发送浏览器通知
|
||||
const notification = browserNotificationService.sendNotification({
|
||||
title: title || '新通知',
|
||||
@@ -315,17 +328,24 @@ export const NotificationProvider = ({ children }) => {
|
||||
autoClose: requireInteraction ? 0 : 8000,
|
||||
});
|
||||
|
||||
// 设置点击处理(聚焦窗口并跳转)
|
||||
if (notification && link) {
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
// 使用 window.location 跳转(不需要 React Router)
|
||||
window.location.hash = link;
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
if (notification) {
|
||||
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
|
||||
|
||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||
// 设置点击处理(聚焦窗口并跳转)
|
||||
if (link) {
|
||||
notification.onclick = () => {
|
||||
console.log('[NotificationContext] 通知被点击,跳转到:', link);
|
||||
window.focus();
|
||||
// 使用 window.location 跳转(不需要 React Router)
|
||||
window.location.hash = link;
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ 通知对象创建失败!');
|
||||
}
|
||||
}, [browserPermission]);
|
||||
|
||||
/**
|
||||
@@ -610,6 +630,24 @@ export const NotificationProvider = ({ children }) => {
|
||||
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
|
||||
socket.startMockPush(interval, maxBatch);
|
||||
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
|
||||
} else {
|
||||
// ✅ 真实模式下,订阅事件推送
|
||||
console.log('%c[NotificationContext] 🔔 订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
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);
|
||||
},
|
||||
// ⚠️ 不需要 onNewEvent 回调,因为 NotificationContext 已经通过 socket.on('new_event') 监听
|
||||
});
|
||||
} else {
|
||||
console.warn('[NotificationContext] ⚠️ socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -646,6 +684,15 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// 监听新事件推送(统一事件名)
|
||||
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);
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
@@ -653,11 +700,14 @@ 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;');
|
||||
return; // 重复事件,直接忽略
|
||||
}
|
||||
|
||||
// 记录已处理的事件ID
|
||||
processedEventIds.current.add(eventId);
|
||||
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
|
||||
|
||||
// 限制Set大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
@@ -670,8 +720,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// 使用适配器转换事件格式
|
||||
console.log('[NotificationContext] 正在转换事件格式...');
|
||||
const notification = adaptEventToNotification(data);
|
||||
console.log('[NotificationContext] 转换后的通知对象:', notification);
|
||||
|
||||
console.log('[NotificationContext] 准备添加通知到队列...');
|
||||
addNotification(notification);
|
||||
console.log('[NotificationContext] ✅ 通知已添加到队列');
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
});
|
||||
|
||||
// 保留系统通知监听(兼容性)
|
||||
|
||||
52
src/index.js
52
src/index.js
@@ -2,8 +2,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
// 本地引入 Leaflet 样式,替代不稳定的 CDN 外链
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
// 导入 Tailwind CSS 和 Brainwave 样式
|
||||
import './styles/brainwave.css';
|
||||
import './styles/brainwave-colors.css';
|
||||
@@ -11,6 +9,53 @@ import './styles/brainwave-colors.css';
|
||||
// Import the main App component
|
||||
import App from './App';
|
||||
|
||||
// 导入通知服务并挂载到 window(用于调试)
|
||||
import { browserNotificationService } from './services/browserNotificationService';
|
||||
window.browserNotificationService = browserNotificationService;
|
||||
|
||||
// 注册 Service Worker(用于支持浏览器通知)
|
||||
function registerServiceWorker() {
|
||||
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
console.log(
|
||||
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在支持 Service Worker 的浏览器中注册
|
||||
if ('serviceWorker' in navigator) {
|
||||
// 在页面加载完成后注册
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
console.log('[App] Service Worker registered successfully:', registration.scope);
|
||||
|
||||
// 监听更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
console.log('[App] Service Worker update found');
|
||||
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
console.log('[App] Service Worker activated');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[App] Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.warn('[App] Service Worker is not supported in this browser');
|
||||
}
|
||||
}
|
||||
|
||||
// 启动 Mock Service Worker(如果启用)
|
||||
async function startApp() {
|
||||
// 只在开发环境启动 MSW
|
||||
@@ -35,6 +80,9 @@ async function startApp() {
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// 注册 Service Worker
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
|
||||
@@ -8,14 +8,14 @@ import RiskDisclaimer from '../components/RiskDisclaimer';
|
||||
*/
|
||||
const AppFooter = () => {
|
||||
return (
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<VStack spacing={1}>
|
||||
<RiskDisclaimer />
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<HStack spacing={1} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
|
||||
@@ -92,7 +92,7 @@ export const initPostHog = () => {
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
posthogInstance.debug(); // Enable debug mode in development
|
||||
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -143,7 +143,7 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
console.log('👤 User identified:', userId);
|
||||
// console.log('👤 User identified:', userId); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
console.log('📝 User properties updated');
|
||||
// console.log('📝 User properties updated'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
@@ -177,9 +177,9 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📍 Event tracked:', eventName, properties);
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📍 Event tracked:', eventName, properties);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
@@ -201,9 +201,9 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
...properties,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📄 Page view tracked:', pagePath);
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📄 Page view tracked:', pagePath);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
console.log('🔄 User session reset');
|
||||
// console.log('🔄 User session reset'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export const resetUser = () => {
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
console.log('🚫 User opted out of tracking');
|
||||
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export const optOut = () => {
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
console.log('✅ User opted in to tracking');
|
||||
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ export async function startMockServiceWorker() {
|
||||
|
||||
try {
|
||||
await worker.start({
|
||||
// 🎯 智能穿透模式(关键配置)
|
||||
// 🎯 警告模式(关键配置)
|
||||
// 'bypass': 未定义 Mock 的请求自动转发到真实后端
|
||||
// 'warn': 未定义的请求会显示警告(调试用)
|
||||
// 'error': 未定义的请求会抛出错误(严格模式)
|
||||
onUnhandledRequest: 'bypass',
|
||||
// 'warn': 未定义的请求会显示警告(调试用)✅ 当前使用(允许 passthrough)
|
||||
// 'error': 未定义的请求会抛出错误(严格模式,不允许 passthrough)
|
||||
onUnhandledRequest: 'warn',
|
||||
|
||||
// 自定义 Service Worker URL(如果需要)
|
||||
serviceWorker: {
|
||||
@@ -48,12 +48,12 @@ export async function startMockServiceWorker() {
|
||||
|
||||
isStarted = true;
|
||||
console.log(
|
||||
'%c[MSW] Mock Service Worker 已启动 🎭',
|
||||
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(
|
||||
'%c智能穿透模式:已定义 Mock → 返回假数据 | 未定义 Mock → 转发到 ' + (process.env.REACT_APP_API_URL || '真实后端'),
|
||||
'color: #FF9800; font-size: 12px;'
|
||||
'%c警告模式:已定义 Mock → 返回假数据 | 未定义 Mock → 显示警告 ⚠️ | 允许 passthrough',
|
||||
'color: #FF9800; font-weight: bold; font-size: 12px;'
|
||||
);
|
||||
console.log(
|
||||
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
|
||||
|
||||
@@ -138,6 +138,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 489,
|
||||
heat_score: 95,
|
||||
exceed_expectation_score: 85,
|
||||
related_avg_chg: 1.25,
|
||||
related_max_chg: 3.15,
|
||||
related_week_chg: 2.80,
|
||||
creator: {
|
||||
id: 1001,
|
||||
username: '财经分析师',
|
||||
@@ -155,6 +158,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 567,
|
||||
heat_score: 88,
|
||||
exceed_expectation_score: 78,
|
||||
related_avg_chg: 5.60,
|
||||
related_max_chg: 12.50,
|
||||
related_week_chg: 8.90,
|
||||
creator: {
|
||||
id: 1002,
|
||||
username: '科技观察者',
|
||||
@@ -172,6 +178,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 345,
|
||||
heat_score: 72,
|
||||
exceed_expectation_score: 68,
|
||||
related_avg_chg: 2.35,
|
||||
related_max_chg: 6.80,
|
||||
related_week_chg: 4.20,
|
||||
creator: {
|
||||
id: 1003,
|
||||
username: '产业研究员',
|
||||
@@ -189,6 +198,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 432,
|
||||
heat_score: 80,
|
||||
exceed_expectation_score: 72,
|
||||
related_avg_chg: 3.80,
|
||||
related_max_chg: 9.20,
|
||||
related_week_chg: 6.50,
|
||||
creator: {
|
||||
id: 1004,
|
||||
username: '半导体观察',
|
||||
@@ -206,6 +218,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 234,
|
||||
heat_score: 65,
|
||||
exceed_expectation_score: null,
|
||||
related_avg_chg: -0.80,
|
||||
related_max_chg: 2.50,
|
||||
related_week_chg: 1.20,
|
||||
creator: {
|
||||
id: 1005,
|
||||
username: '医药行业专家',
|
||||
|
||||
@@ -556,7 +556,7 @@ const industries = ['半导体', '新能源', '人工智能', '医药', '消费'
|
||||
|
||||
// 事件标题模板
|
||||
const eventTitleTemplates = [
|
||||
'{industry}行业迎来重大政策利好',
|
||||
'{industry}行业迎来重大政策利好中国物流与采购联合会发布《国有企业采购业务监督指南》团体标准',
|
||||
'{company}发布{quarter}财报,业绩超预期',
|
||||
'{industry}板块集体大涨,{company}涨停',
|
||||
'央行宣布{policy},影响{industry}行业',
|
||||
@@ -928,13 +928,16 @@ export function generateMockEvents(params = {}) {
|
||||
const end = start + per_page;
|
||||
const paginatedEvents = filteredEvents.slice(start, end);
|
||||
|
||||
const totalPages = Math.ceil(filteredEvents.length / per_page);
|
||||
return {
|
||||
events: paginatedEvents,
|
||||
pagination: {
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
total: filteredEvents.length,
|
||||
total_pages: Math.ceil(filteredEvents.length / per_page),
|
||||
pages: totalPages, // ← 对齐后端字段名 (was: total_pages)
|
||||
has_prev: page > 1, // ← 对齐后端:是否有上一页
|
||||
has_next: page < totalPages // ← 对齐后端:是否有下一页
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,20 +112,20 @@ export function getCurrentUser() {
|
||||
const stored = localStorage.getItem('mock_current_user');
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored);
|
||||
console.log('[Mock State] 获取当前登录用户:', {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
subscription_type: user.subscription_type,
|
||||
subscription_status: user.subscription_status,
|
||||
subscription_days_left: user.subscription_days_left
|
||||
});
|
||||
// console.log('[Mock State] 获取当前登录用户:', { // 已关闭:减少日志
|
||||
// id: user.id,
|
||||
// phone: user.phone,
|
||||
// nickname: user.nickname,
|
||||
// subscription_type: user.subscription_type,
|
||||
// subscription_status: user.subscription_status,
|
||||
// subscription_days_left: user.subscription_days_left
|
||||
// });
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Mock State] 解析用户数据失败:', error);
|
||||
}
|
||||
console.log('[Mock State] 未找到当前登录用户');
|
||||
// console.log('[Mock State] 未找到当前登录用户'); // 已关闭:减少日志
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export const accountHandlers = [
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取自选股列表');
|
||||
// console.log('[Mock] 获取自选股列表'); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -24,8 +24,6 @@ export const authHandlers = [
|
||||
const body = await request.json();
|
||||
const { credential, type, purpose } = body;
|
||||
|
||||
console.log('[Mock] 发送验证码:', { credential, type, purpose });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(credential, {
|
||||
@@ -33,7 +31,20 @@ export const authHandlers = [
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
console.log(`[Mock] 验证码已生成: ${credential} -> ${code}`);
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 验证码: ${code.padEnd(22)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -43,6 +54,86 @@ export const authHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 1.1 发送手机验证码(前端实际调用的接口)
|
||||
http.post('/api/auth/send-sms-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { phone } = body;
|
||||
|
||||
console.log('[Mock] 发送手机验证码请求:', { phone });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(phone, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 📱 手机验证码: ${code.padEnd(19)}║\n` +
|
||||
`║ 📞 手机号: ${phone.padEnd(23)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 📱 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${phone}(Mock: ${code})`,
|
||||
// 开发环境下返回验证码,方便测试
|
||||
dev_code: code
|
||||
});
|
||||
}),
|
||||
|
||||
// 1.2 发送邮箱验证码(前端实际调用的接口)
|
||||
http.post('/api/auth/send-email-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
console.log('[Mock] 发送邮箱验证码请求:', { email });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(email, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 📧 邮箱验证码: ${code.padEnd(19)}║\n` +
|
||||
`║ 📮 邮箱: ${email.padEnd(27)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #2563eb; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 📧 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${email}(Mock: ${code})`,
|
||||
// 开发环境下返回验证码,方便测试
|
||||
dev_code: code
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 验证码登录
|
||||
http.post('/api/auth/login-with-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
@@ -13,7 +13,7 @@ export const eventHandlers = [
|
||||
// ==================== 事件列表相关 ====================
|
||||
|
||||
// 获取事件列表
|
||||
http.get('/api/events/', async ({ request }) => {
|
||||
http.get('/api/events', async ({ request }) => {
|
||||
await delay(500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -41,11 +41,26 @@ export const eventHandlers = [
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件列表失败:', error);
|
||||
console.error('[Mock] Error details:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
params: params
|
||||
});
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件列表失败',
|
||||
data: { events: [], pagination: {} }
|
||||
data: {
|
||||
events: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0, // ← 对齐后端字段名
|
||||
has_prev: false, // ← 对齐后端
|
||||
has_next: false // ← 对齐后端
|
||||
}
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -1065,14 +1080,65 @@ export const eventHandlers = [
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)];
|
||||
const title = eventTitles[i % eventTitles.length];
|
||||
|
||||
// 带引用来源的研报数据
|
||||
const researchReports = [
|
||||
{
|
||||
author: '中信证券',
|
||||
report_title: `${title}深度研究报告`,
|
||||
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
author: '国泰君安',
|
||||
report_title: `行业专题:${title}影响分析`,
|
||||
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
{
|
||||
author: '华泰证券',
|
||||
report_title: `${title}投资机会深度解析`,
|
||||
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
// 生成带引用标记的content(data结构)
|
||||
const contentWithCitations = {
|
||||
data: [
|
||||
{
|
||||
query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】,市场关注度高,相关概念股表现活跃。`,
|
||||
sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor(Math.random() * 2 + 1)}年内,相关企业营收增速有望达到${Math.floor(Math.random() * 30 + 20)}%以上【3】。该事件的影响范围广泛,涉及多个细分领域,投资机会显著。`,
|
||||
match_score: importance >= 4 ? '好' : (importance >= 2 ? '中' : '一般'),
|
||||
author: researchReports[0].author,
|
||||
declare_date: researchReports[0].declare_date,
|
||||
report_title: researchReports[0].report_title
|
||||
},
|
||||
{
|
||||
query_part: `市场分析师认为,该事件将带动产业链上下游企业协同发展【2】,形成良性循环。`,
|
||||
sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`,
|
||||
match_score: importance >= 3 ? '好' : '中',
|
||||
author: researchReports[1].author,
|
||||
declare_date: researchReports[1].declare_date,
|
||||
report_title: researchReports[1].report_title
|
||||
},
|
||||
{
|
||||
query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor(Math.random() * 40 + 30)}%【3】。`,
|
||||
sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`,
|
||||
match_score: '好',
|
||||
author: researchReports[2].author,
|
||||
declare_date: researchReports[2].declare_date,
|
||||
report_title: researchReports[2].report_title
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
events.push({
|
||||
id: `hist_event_${i + 1}`,
|
||||
title: eventTitles[i % eventTitles.length],
|
||||
description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`,
|
||||
title: title,
|
||||
content: contentWithCitations, // 升级版本:带引用来源的data结构
|
||||
description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容
|
||||
date: date.toISOString().split('T')[0],
|
||||
importance: importance,
|
||||
similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0
|
||||
similarity: Math.floor(Math.random() * 10) + 1, // 1-10
|
||||
impact_sectors: [
|
||||
['半导体', '芯片设计', 'EDA'],
|
||||
['新能源汽车', '锂电池', '充电桩'],
|
||||
|
||||
25
src/mocks/handlers/external.js
Normal file
25
src/mocks/handlers/external.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/mocks/handlers/external.js
|
||||
// 外部服务 Mock Handler(允许通过)
|
||||
|
||||
import { http, passthrough } from 'msw';
|
||||
|
||||
/**
|
||||
* 外部服务处理器
|
||||
* 对于外部服务(如头像、CDN等),使用 passthrough 让请求正常发送到真实服务器
|
||||
*/
|
||||
export const externalHandlers = [
|
||||
// Pravatar 头像服务 - 允许通过到真实服务
|
||||
http.get('https://i.pravatar.cc/*', async () => {
|
||||
return passthrough();
|
||||
}),
|
||||
|
||||
// 如果需要 mock 头像,也可以返回一个占位图片
|
||||
// http.get('https://i.pravatar.cc/*', async () => {
|
||||
// return HttpResponse.text(
|
||||
// '<svg width="150" height="150" xmlns="http://www.w3.org/2000/svg"><rect width="150" height="150" fill="#ddd"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#999">Avatar</text></svg>',
|
||||
// {
|
||||
// headers: { 'Content-Type': 'image/svg+xml' }
|
||||
// }
|
||||
// );
|
||||
// }),
|
||||
];
|
||||
@@ -13,6 +13,8 @@ import { companyHandlers } from './company';
|
||||
import { marketHandlers } from './market';
|
||||
import { financialHandlers } from './financial';
|
||||
import { limitAnalyseHandlers } from './limitAnalyse';
|
||||
import { posthogHandlers } from './posthog';
|
||||
import { externalHandlers } from './external';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -30,5 +32,7 @@ export const handlers = [
|
||||
...marketHandlers,
|
||||
...financialHandlers,
|
||||
...limitAnalyseHandlers,
|
||||
...posthogHandlers,
|
||||
...externalHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
132
src/mocks/handlers/posthog.js
Normal file
132
src/mocks/handlers/posthog.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// src/mocks/handlers/posthog.js
|
||||
// PostHog 埋点请求 Mock Handler
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
/**
|
||||
* PostHog 埋点 Mock Handler
|
||||
* 拦截所有发往 PostHog 的埋点请求,避免在 Mock 模式下产生 500 错误
|
||||
*/
|
||||
export const posthogHandlers = [
|
||||
// PostHog 事件追踪接口
|
||||
http.post('https://us.i.posthog.com/e/', async ({ request }) => {
|
||||
try {
|
||||
// 读取埋点数据(可选,用于调试)
|
||||
const body = await request.text();
|
||||
|
||||
// 开发环境输出埋点日志(可选,方便调试)
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog 埋点请求:', {
|
||||
url: request.url,
|
||||
bodyPreview: body.substring(0, 150) + (body.length > 150 ? '...' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
// 返回成功响应(模拟 PostHog 服务器响应)
|
||||
return HttpResponse.json(
|
||||
{ status: 1 },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ status: 0, error: 'Mock handler error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog batch 批量事件追踪接口(可选)
|
||||
http.post('https://us.i.posthog.com/batch/', async ({ request }) => {
|
||||
try {
|
||||
const body = await request.text();
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog 批量埋点请求:', {
|
||||
url: request.url,
|
||||
bodyPreview: body.substring(0, 150) + (body.length > 150 ? '...' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ status: 1 },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog batch handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ status: 0, error: 'Mock handler error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog decide 接口(功能开关、特性标志)
|
||||
http.post('https://us.i.posthog.com/decide/', async ({ request }) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
const body = await request.json();
|
||||
console.log('[Mock] PostHog decide 请求:', body);
|
||||
}
|
||||
|
||||
// 返回空的特性标志配置
|
||||
return HttpResponse.json({
|
||||
featureFlags: {},
|
||||
sessionRecording: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog decide handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ featureFlags: {}, sessionRecording: false },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog session recording 接口(会话录制)
|
||||
http.post('https://us.i.posthog.com/s/', async ({ request }) => {
|
||||
try {
|
||||
const body = await request.text();
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog session recording 请求:', {
|
||||
url: request.url,
|
||||
bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
return HttpResponse.json(
|
||||
{ status: 1 },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog session recording handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ status: 0, error: 'Mock handler error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog feature flags 接口(特性标志查询)
|
||||
http.post('https://us.i.posthog.com/flags/', async ({ request }) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog feature flags 请求:', request.url);
|
||||
}
|
||||
|
||||
// 返回空的特性标志
|
||||
return HttpResponse.json({
|
||||
featureFlags: {},
|
||||
featureFlagPayloads: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog flags handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ featureFlags: {}, featureFlagPayloads: {} },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -130,7 +130,7 @@ export const stockHandlers = [
|
||||
try {
|
||||
const stocks = generateStockList();
|
||||
|
||||
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
|
||||
// console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length }); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json(stocks);
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,6 +35,9 @@ export const lazyComponents = {
|
||||
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,4 +62,5 @@ export const {
|
||||
ForecastReport,
|
||||
FinancialPanorama,
|
||||
MarketDataView,
|
||||
AgentChat,
|
||||
} = lazyComponents;
|
||||
|
||||
@@ -149,6 +149,18 @@ export const routeConfig = [
|
||||
description: '实时市场数据'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Agent模块 ====================
|
||||
{
|
||||
path: 'agent-chat',
|
||||
component: lazyComponents.AgentChat,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '价小前投研',
|
||||
description: '北京价值前沿科技公司的AI投研聊天助手'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,14 @@ class BrowserNotificationService {
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有通知权限
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPermission() {
|
||||
return this.isSupported() && Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
* @returns {Promise<string>} 权限状态
|
||||
@@ -77,57 +85,99 @@ class BrowserNotificationService {
|
||||
data = {},
|
||||
autoClose = 0,
|
||||
}) {
|
||||
// 详细日志:检查支持状态
|
||||
if (!this.isSupported()) {
|
||||
logger.warn('browserNotificationService', 'Notifications not supported');
|
||||
console.warn('[browserNotificationService] ❌ 浏览器不支持通知 API');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.permission !== 'granted') {
|
||||
logger.warn('browserNotificationService', 'Permission not granted');
|
||||
// 详细日志:检查权限状态
|
||||
const currentPermission = Notification.permission;
|
||||
console.log('[browserNotificationService] 当前权限状态:', currentPermission);
|
||||
|
||||
if (currentPermission !== 'granted') {
|
||||
logger.warn('browserNotificationService', 'Permission not granted', { permission: currentPermission });
|
||||
console.warn(`[browserNotificationService] ❌ 权限未授予: ${currentPermission}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[browserNotificationService] ✅ 准备发送通知:', { title, body, tag, requireInteraction, autoClose });
|
||||
|
||||
try {
|
||||
// 关闭相同 tag 的旧通知
|
||||
if (tag && this.activeNotifications.has(tag)) {
|
||||
const oldNotification = this.activeNotifications.get(tag);
|
||||
oldNotification.close();
|
||||
console.log('[browserNotificationService] 关闭旧通知:', tag);
|
||||
}
|
||||
|
||||
// 创建通知
|
||||
const finalTag = tag || `notification_${Date.now()}`;
|
||||
console.log('[browserNotificationService] 创建通知对象...');
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon,
|
||||
badge: '/badge.png',
|
||||
tag: tag || `notification_${Date.now()}`,
|
||||
tag: finalTag,
|
||||
requireInteraction,
|
||||
data,
|
||||
silent: false, // 允许声音
|
||||
});
|
||||
|
||||
console.log('[browserNotificationService] ✅ 通知对象已创建:', notification);
|
||||
|
||||
// 存储通知引用
|
||||
if (tag) {
|
||||
this.activeNotifications.set(tag, notification);
|
||||
console.log('[browserNotificationService] 通知已存储到活跃列表');
|
||||
}
|
||||
|
||||
// 自动关闭
|
||||
if (autoClose > 0 && !requireInteraction) {
|
||||
console.log(`[browserNotificationService] 设置自动关闭: ${autoClose}ms`);
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
console.log('[browserNotificationService] 通知已自动关闭');
|
||||
}, autoClose);
|
||||
}
|
||||
|
||||
// 通知关闭时清理引用
|
||||
notification.onclose = () => {
|
||||
console.log('[browserNotificationService] 通知被关闭:', finalTag);
|
||||
if (tag) {
|
||||
this.activeNotifications.delete(tag);
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('browserNotificationService', 'Notification sent', { title, tag });
|
||||
// 通知点击事件
|
||||
notification.onclick = (event) => {
|
||||
console.log('[browserNotificationService] 通知被点击:', finalTag, data);
|
||||
};
|
||||
|
||||
// 通知显示事件
|
||||
notification.onshow = () => {
|
||||
console.log('[browserNotificationService] ✅ 通知已显示:', finalTag);
|
||||
};
|
||||
|
||||
// 通知错误事件
|
||||
notification.onerror = (error) => {
|
||||
console.error('[browserNotificationService] ❌ 通知显示错误:', error);
|
||||
};
|
||||
|
||||
logger.info('browserNotificationService', 'Notification sent', { title, tag: finalTag });
|
||||
console.log('[browserNotificationService] ✅ 通知发送成功!');
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
logger.error('browserNotificationService', 'sendNotification', error);
|
||||
console.error('[browserNotificationService] ❌ 发送通知时发生错误:', error);
|
||||
console.error('[browserNotificationService] 错误详情:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,12 @@ const apiRequest = async (url, options = {}) => {
|
||||
|
||||
export const eventService = {
|
||||
getEvents: (params = {}) => {
|
||||
// Filter out empty params
|
||||
const cleanParams = Object.fromEntries(Object.entries(params).filter(([_, v]) => v != null && v !== ''));
|
||||
// Filter out null, undefined, and empty strings (but keep 0 and false)
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params).filter(([_, v]) => v !== null && v !== undefined && v !== '')
|
||||
);
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
return apiRequest(`/api/events/?${query}`);
|
||||
return apiRequest(`/api/events?${query}`);
|
||||
},
|
||||
getHotEvents: (params = {}) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
278
src/services/llmService.js
Normal file
278
src/services/llmService.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// src/services/llmService.js
|
||||
// LLM服务层 - 集成AI模型进行对话和工具调用
|
||||
|
||||
import axios from 'axios';
|
||||
import { mcpService } from './mcpService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* LLM服务配置
|
||||
*/
|
||||
const LLM_CONFIG = {
|
||||
// 可以使用 OpenAI、Claude、通义千问等
|
||||
provider: 'openai', // 或 'claude', 'qwen'
|
||||
apiKey: process.env.REACT_APP_OPENAI_API_KEY || '',
|
||||
apiUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
model: 'gpt-4o-mini', // 更便宜的模型
|
||||
};
|
||||
|
||||
/**
|
||||
* LLM服务类
|
||||
*/
|
||||
class LLMService {
|
||||
constructor() {
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建系统提示词
|
||||
*/
|
||||
getSystemPrompt(availableTools) {
|
||||
return `你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
|
||||
|
||||
${availableTools.map(tool => `
|
||||
**${tool.name}**
|
||||
描述:${tool.description}
|
||||
参数:${JSON.stringify(tool.parameters, null, 2)}
|
||||
`).join('\n')}
|
||||
|
||||
用户提问时,请按照以下步骤:
|
||||
1. 理解用户的意图
|
||||
2. 选择合适的工具(可以多个)
|
||||
3. 提取工具需要的参数
|
||||
4. 调用工具后,用自然语言总结结果
|
||||
|
||||
回复格式:
|
||||
- 如果需要调用工具,返回JSON格式:{"tool": "工具名", "arguments": {...}}
|
||||
- 如果不需要工具,直接回复自然语言
|
||||
|
||||
注意:
|
||||
- 贵州茅台的股票代码是 600519
|
||||
- 涨停是指股票当日涨幅达到10%
|
||||
- 概念板块是指相同题材的股票分类`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能对话 - 使用LLM理解意图并调用工具
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
// 1. 获取可用工具列表
|
||||
const toolsResult = await mcpService.listTools();
|
||||
if (!toolsResult.success) {
|
||||
throw new Error('获取工具列表失败');
|
||||
}
|
||||
|
||||
const availableTools = toolsResult.data;
|
||||
|
||||
// 2. 构建对话历史
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.getSystemPrompt(availableTools),
|
||||
},
|
||||
...conversationHistory.map(msg => ({
|
||||
role: msg.isUser ? 'user' : 'assistant',
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
},
|
||||
];
|
||||
|
||||
// 3. 调用LLM
|
||||
logger.info('LLMService', '调用LLM', { messageCount: messages.length });
|
||||
|
||||
// 注意:这里需要配置API密钥
|
||||
if (!LLM_CONFIG.apiKey) {
|
||||
// 如果没有配置LLM,使用简单的关键词匹配
|
||||
logger.warn('LLMService', '未配置LLM API密钥,使用简单匹配');
|
||||
return await this.fallbackChat(userMessage);
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
LLM_CONFIG.apiUrl,
|
||||
{
|
||||
model: LLM_CONFIG.model,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const aiResponse = response.data.choices[0].message.content;
|
||||
logger.info('LLMService', 'LLM响应', { response: aiResponse });
|
||||
|
||||
// 4. 解析LLM响应
|
||||
// 如果LLM返回工具调用指令
|
||||
try {
|
||||
const toolCall = JSON.parse(aiResponse);
|
||||
if (toolCall.tool && toolCall.arguments) {
|
||||
// 调用MCP工具
|
||||
const toolResult = await mcpService.callTool(toolCall.tool, toolCall.arguments);
|
||||
|
||||
if (!toolResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: toolResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 让LLM总结工具结果
|
||||
const summaryMessages = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: aiResponse,
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content: `工具 ${toolCall.tool} 返回的数据:\n${JSON.stringify(toolResult.data, null, 2)}\n\n请用自然语言总结这些数据,给用户一个简洁清晰的回复。`,
|
||||
},
|
||||
];
|
||||
|
||||
const summaryResponse = await axios.post(
|
||||
LLM_CONFIG.apiUrl,
|
||||
{
|
||||
model: LLM_CONFIG.model,
|
||||
messages: summaryMessages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
const summary = summaryResponse.data.choices[0].message.content;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: summary,
|
||||
rawData: toolResult.data,
|
||||
toolUsed: toolCall.tool,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 不是JSON格式,说明是直接回复
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: aiResponse,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 默认返回LLM的直接回复
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: aiResponse,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('LLMService', 'chat error', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级方案:简单的关键词匹配(当没有配置LLM时)
|
||||
*/
|
||||
async fallbackChat(userMessage) {
|
||||
logger.info('LLMService', '使用降级方案', { message: userMessage });
|
||||
|
||||
// 使用原有的简单匹配逻辑
|
||||
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
|
||||
const result = await mcpService.callTool('search_china_news', {
|
||||
query: userMessage.replace(/新闻|资讯/g, '').trim(),
|
||||
top_k: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '新闻搜索');
|
||||
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
|
||||
const query = userMessage.replace(/概念|板块/g, '').trim();
|
||||
const result = await mcpService.callTool('search_concepts', {
|
||||
query,
|
||||
size: 5,
|
||||
sort_by: 'change_pct',
|
||||
});
|
||||
return this.formatFallbackResponse(result, '概念搜索');
|
||||
} else if (userMessage.includes('涨停')) {
|
||||
const query = userMessage.replace(/涨停/g, '').trim();
|
||||
const result = await mcpService.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
page_size: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '涨停分析');
|
||||
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
|
||||
// 6位数字 = 股票代码
|
||||
const result = await mcpService.callTool('get_stock_basic_info', {
|
||||
seccode: userMessage.trim(),
|
||||
});
|
||||
return this.formatFallbackResponse(result, '股票信息');
|
||||
} else if (userMessage.includes('茅台') || userMessage.includes('贵州茅台')) {
|
||||
// 特殊处理茅台
|
||||
const result = await mcpService.callTool('get_stock_basic_info', {
|
||||
seccode: '600519',
|
||||
});
|
||||
return this.formatFallbackResponse(result, '贵州茅台股票信息');
|
||||
} else {
|
||||
// 默认:搜索新闻
|
||||
const result = await mcpService.callTool('search_china_news', {
|
||||
query: userMessage,
|
||||
top_k: 5,
|
||||
});
|
||||
return this.formatFallbackResponse(result, '新闻搜索');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化降级响应
|
||||
*/
|
||||
formatFallbackResponse(result, action) {
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
message: `已为您完成${action},找到以下结果:`,
|
||||
rawData: result.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除对话历史
|
||||
*/
|
||||
clearHistory() {
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const llmService = new LLMService();
|
||||
|
||||
export default LLMService;
|
||||
248
src/services/mcpService.js
Normal file
248
src/services/mcpService.js
Normal file
@@ -0,0 +1,248 @@
|
||||
// src/services/mcpService.js
|
||||
// MCP (Model Context Protocol) 服务层
|
||||
// 用于与FastAPI后端的MCP工具进行交互
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* MCP API客户端
|
||||
*/
|
||||
class MCPService {
|
||||
constructor() {
|
||||
this.baseURL = `${getApiBase()}/mcp`;
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 60000, // 60秒超时(MCP工具可能需要较长时间)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
logger.debug('MCPService', 'Request', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
data: config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('MCPService', 'Request Error', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.debug('MCPService', 'Response', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('MCPService', 'Response Error', {
|
||||
url: error.config?.url,
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有可用的MCP工具
|
||||
* @returns {Promise<Object>} 工具列表
|
||||
*/
|
||||
async listTools() {
|
||||
try {
|
||||
const response = await this.client.get('/tools');
|
||||
return {
|
||||
success: true,
|
||||
data: response.tools || [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '获取工具列表失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定工具的定义
|
||||
* @param {string} toolName - 工具名称
|
||||
* @returns {Promise<Object>} 工具定义
|
||||
*/
|
||||
async getTool(toolName) {
|
||||
try {
|
||||
const response = await this.client.get(`/tools/${toolName}`);
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '获取工具定义失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用MCP工具
|
||||
* @param {string} toolName - 工具名称
|
||||
* @param {Object} arguments - 工具参数
|
||||
* @returns {Promise<Object>} 工具执行结果
|
||||
*/
|
||||
async callTool(toolName, toolArguments) {
|
||||
try {
|
||||
const response = await this.client.post('/tools/call', {
|
||||
tool: toolName,
|
||||
arguments: toolArguments,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || '工具调用失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能对话 - 根据用户输入自动选择合适的工具
|
||||
* @param {string} userMessage - 用户消息
|
||||
* @param {Array} conversationHistory - 对话历史(可选)
|
||||
* @returns {Promise<Object>} AI响应
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
// 这里可以实现智能路由逻辑
|
||||
// 根据用户输入判断应该调用哪个工具
|
||||
|
||||
// 示例:关键词匹配
|
||||
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
|
||||
return await this.callTool('search_china_news', {
|
||||
query: userMessage.replace(/新闻|资讯/g, '').trim(),
|
||||
top_k: 5,
|
||||
});
|
||||
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
|
||||
const query = userMessage.replace(/概念|板块/g, '').trim();
|
||||
return await this.callTool('search_concepts', {
|
||||
query,
|
||||
size: 5,
|
||||
sort_by: 'change_pct',
|
||||
});
|
||||
} else if (userMessage.includes('涨停')) {
|
||||
const query = userMessage.replace(/涨停/g, '').trim();
|
||||
return await this.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
page_size: 5,
|
||||
});
|
||||
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
|
||||
// 6位数字 = 股票代码
|
||||
return await this.callTool('get_stock_basic_info', {
|
||||
seccode: userMessage.trim(),
|
||||
});
|
||||
} else {
|
||||
// 默认:搜索新闻
|
||||
return await this.callTool('search_china_news', {
|
||||
query: userMessage,
|
||||
top_k: 5,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具类别枚举
|
||||
*/
|
||||
static TOOL_CATEGORIES = {
|
||||
NEWS: 'news', // 新闻搜索
|
||||
STOCK: 'stock', // 股票信息
|
||||
CONCEPT: 'concept', // 概念板块
|
||||
LIMIT_UP: 'limit_up', // 涨停分析
|
||||
RESEARCH: 'research', // 研报搜索
|
||||
ROADSHOW: 'roadshow', // 路演信息
|
||||
FINANCIAL: 'financial', // 财务数据
|
||||
TRADE: 'trade', // 交易数据
|
||||
};
|
||||
|
||||
/**
|
||||
* 常用工具快捷方式
|
||||
*/
|
||||
async searchNews(query, topK = 5, exactMatch = false) {
|
||||
return await this.callTool('search_china_news', {
|
||||
query,
|
||||
top_k: topK,
|
||||
exact_match: exactMatch,
|
||||
});
|
||||
}
|
||||
|
||||
async searchConcepts(query, size = 10, sortBy = 'change_pct') {
|
||||
return await this.callTool('search_concepts', {
|
||||
query,
|
||||
size,
|
||||
sort_by: sortBy,
|
||||
});
|
||||
}
|
||||
|
||||
async searchLimitUpStocks(query, mode = 'hybrid', pageSize = 10) {
|
||||
return await this.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode,
|
||||
page_size: pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async getStockInfo(seccode) {
|
||||
return await this.callTool('get_stock_basic_info', {
|
||||
seccode,
|
||||
});
|
||||
}
|
||||
|
||||
async getStockConcepts(stockCode, size = 10) {
|
||||
return await this.callTool('get_stock_concepts', {
|
||||
stock_code: stockCode,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
async searchResearchReports(query, mode = 'hybrid', size = 5) {
|
||||
return await this.callTool('search_research_reports', {
|
||||
query,
|
||||
mode,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
async getConceptStatistics(days = 7, minStockCount = 3) {
|
||||
return await this.callTool('get_concept_statistics', {
|
||||
days,
|
||||
min_stock_count: minStockCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const mcpService = new MCPService();
|
||||
|
||||
// 导出类(供测试使用)
|
||||
export default MCPService;
|
||||
@@ -310,6 +310,7 @@ class MockSocketService {
|
||||
this.reconnectAttempts = 0;
|
||||
this.customReconnectTimer = null;
|
||||
this.failConnection = false; // 是否模拟连接失败
|
||||
this.pushPaused = false; // 新增:暂停推送标志(保持连接)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,6 +415,7 @@ class MockSocketService {
|
||||
// 清除所有定时器
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
this.pushPaused = false; // 重置暂停状态
|
||||
|
||||
const wasConnected = this.connected;
|
||||
this.connected = false;
|
||||
@@ -613,6 +615,12 @@ class MockSocketService {
|
||||
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
|
||||
|
||||
const pushInterval = setInterval(() => {
|
||||
// 检查是否暂停推送
|
||||
if (this.pushPaused) {
|
||||
logger.info('mockSocketService', '⏸️ Mock push is paused, skipping this cycle...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机选择 1-burstCount 条消息
|
||||
const count = Math.floor(Math.random() * burstCount) + 1;
|
||||
|
||||
@@ -642,22 +650,56 @@ class MockSocketService {
|
||||
stopMockPush() {
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
this.pushPaused = false; // 重置暂停状态
|
||||
logger.info('mockSocketService', 'Mock push stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停自动推送(保持连接和定时器运行)
|
||||
*/
|
||||
pausePush() {
|
||||
this.pushPaused = true;
|
||||
logger.info('mockSocketService', '⏸️ Mock push paused (connection and intervals maintained)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复自动推送
|
||||
*/
|
||||
resumePush() {
|
||||
this.pushPaused = false;
|
||||
logger.info('mockSocketService', '▶️ Mock push resumed');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询推送暂停状态
|
||||
* @returns {boolean} 是否已暂停
|
||||
*/
|
||||
isPushPaused() {
|
||||
return this.pushPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一条测试消息
|
||||
* @param {object} customData - 自定义消息数据(可选)
|
||||
*/
|
||||
sendTestNotification(customData = null) {
|
||||
const notification = customData || {
|
||||
type: 'trade_alert',
|
||||
severity: 'info',
|
||||
title: '测试消息',
|
||||
message: '这是一条手动触发的测试消息',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 5000,
|
||||
// 如果传入自定义数据,直接使用(向后兼容)
|
||||
if (customData) {
|
||||
this.emit('new_event', customData);
|
||||
logger.info('mockSocketService', 'Custom test notification sent', customData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认发送新格式的测试通知(符合当前通知系统规范)
|
||||
const notification = {
|
||||
type: 'announcement', // 公告通知类型
|
||||
priority: 'important', // 重要优先级(30秒自动关闭)
|
||||
title: '🧪 测试通知',
|
||||
content: '这是一条手动触发的测试消息,用于验证通知系统是否正常工作',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
id: `test_${Date.now()}`,
|
||||
clickable: false,
|
||||
};
|
||||
|
||||
this.emit('new_event', notification);
|
||||
@@ -836,6 +878,27 @@ if (process.env.NODE_ENV === 'development') {
|
||||
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
|
||||
return attempts;
|
||||
},
|
||||
|
||||
// 暂停自动推送(保持连接)
|
||||
pausePush: () => {
|
||||
mockSocketService.pausePush();
|
||||
logger.info('mockSocketService', '⏸️ Auto push paused');
|
||||
return true;
|
||||
},
|
||||
|
||||
// 恢复自动推送
|
||||
resumePush: () => {
|
||||
mockSocketService.resumePush();
|
||||
logger.info('mockSocketService', '▶️ Auto push resumed');
|
||||
return true;
|
||||
},
|
||||
|
||||
// 查看推送暂停状态
|
||||
isPushPaused: () => {
|
||||
const paused = mockSocketService.isPushPaused();
|
||||
logger.info('mockSocketService', `Push status: ${paused ? '⏸️ Paused' : '▶️ Active'}`);
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
|
||||
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
|
||||
@@ -845,6 +908,9 @@ if (process.env.NODE_ENV === 'development') {
|
||||
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
|
||||
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
|
||||
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
|
||||
logger.info('mockSocketService', ' __mockSocket.pausePush() - ⏸️ 暂停自动推送(保持连接)');
|
||||
logger.info('mockSocketService', ' __mockSocket.resumePush() - ▶️ 恢复自动推送');
|
||||
logger.info('mockSocketService', ' __mockSocket.isPushPaused() - 查看推送状态');
|
||||
}
|
||||
|
||||
export default mockSocketService;
|
||||
|
||||
@@ -78,10 +78,93 @@ if (typeof window !== 'undefined') {
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
|
||||
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
|
||||
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
|
||||
REACT_APP_ENV: process.env.REACT_APP_ENV,
|
||||
},
|
||||
};
|
||||
console.log('[Socket Diagnostics]', diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
},
|
||||
|
||||
// 手动订阅事件
|
||||
subscribe: (options = {}) => {
|
||||
const { eventType = 'all', importance = 'all' } = options;
|
||||
console.log(`[Socket Debug] Subscribing to events: type=${eventType}, importance=${importance}`);
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: (event) => {
|
||||
console.log('[Socket Debug] ✅ New event received:', event);
|
||||
},
|
||||
onSubscribed: (data) => {
|
||||
console.log('[Socket Debug] ✅ Subscription confirmed:', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error('[Socket Debug] ❌ subscribeToEvents method not available');
|
||||
}
|
||||
},
|
||||
|
||||
// 测试连接质量
|
||||
testConnection: () => {
|
||||
console.log('[Socket Debug] Testing connection...');
|
||||
const start = Date.now();
|
||||
|
||||
if (socket.emit) {
|
||||
socket.emit('ping', { timestamp: start }, (response) => {
|
||||
const latency = Date.now() - start;
|
||||
console.log(`[Socket Debug] ✅ Connection OK - Latency: ${latency}ms`, response);
|
||||
});
|
||||
} else {
|
||||
console.error('[Socket Debug] ❌ Cannot test connection - socket.emit not available');
|
||||
}
|
||||
},
|
||||
|
||||
// 检查配置是否正确
|
||||
checkConfig: () => {
|
||||
const config = {
|
||||
socketType: SOCKET_TYPE,
|
||||
useMock,
|
||||
envVars: {
|
||||
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
|
||||
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
|
||||
},
|
||||
socketMethods: {
|
||||
connect: typeof socket.connect,
|
||||
disconnect: typeof socket.disconnect,
|
||||
on: typeof socket.on,
|
||||
emit: typeof socket.emit,
|
||||
subscribeToEvents: typeof socket.subscribeToEvents,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[Socket Debug] Configuration Check:', config);
|
||||
|
||||
// 检查潜在问题
|
||||
const issues = [];
|
||||
if (SOCKET_TYPE === 'MOCK' && process.env.NODE_ENV === 'production') {
|
||||
issues.push('⚠️ WARNING: Using MOCK socket in production!');
|
||||
}
|
||||
if (!socket.subscribeToEvents) {
|
||||
issues.push('❌ ERROR: subscribeToEvents method missing');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.warn('[Socket Debug] Issues found:', issues);
|
||||
} else {
|
||||
console.log('[Socket Debug] ✅ No issues found');
|
||||
}
|
||||
|
||||
return { config, issues };
|
||||
},
|
||||
};
|
||||
|
||||
console.log(
|
||||
@@ -92,6 +175,190 @@ if (typeof window !== 'undefined') {
|
||||
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__SOCKET_DEBUG__.checkConfig() - 检查配置',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__SOCKET_DEBUG__.subscribe() - 手动订阅事件',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__SOCKET_DEBUG__.testConnection() - 测试连接',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
|
||||
// ========== 通知系统专用调试 API ==========
|
||||
window.__NOTIFY_DEBUG__ = {
|
||||
// 完整检查(配置+连接+订阅状态)
|
||||
checkAll: () => {
|
||||
console.log('\n==========【通知系统诊断】==========');
|
||||
|
||||
// 1. 检查 Socket 配置
|
||||
const socketCheck = window.__SOCKET_DEBUG__.checkConfig();
|
||||
console.log('\n✓ Socket 配置检查完成');
|
||||
|
||||
// 2. 检查连接状态
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
console.log('\n✓ 连接状态:', status.connected ? '✅ 已连接' : '❌ 未连接');
|
||||
|
||||
// 3. 检查环境变量
|
||||
console.log('\n✓ API Base:', process.env.REACT_APP_API_URL || '(使用相对路径)');
|
||||
|
||||
// 4. 检查浏览器通知权限
|
||||
const browserPermission = Notification?.permission || 'unsupported';
|
||||
console.log('\n✓ 浏览器通知权限:', browserPermission);
|
||||
|
||||
// 5. 汇总报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
socket: {
|
||||
type: SOCKET_TYPE,
|
||||
connected: status.connected,
|
||||
reconnectAttempts: status.reconnectAttempts,
|
||||
},
|
||||
env: socketCheck.config.envVars,
|
||||
browserNotification: browserPermission,
|
||||
issues: socketCheck.issues,
|
||||
};
|
||||
|
||||
console.log('\n========== 诊断报告 ==========');
|
||||
console.table(report);
|
||||
|
||||
if (report.issues.length > 0) {
|
||||
console.warn('\n⚠️ 发现问题:', report.issues);
|
||||
} else {
|
||||
console.log('\n✅ 系统正常,未发现问题');
|
||||
}
|
||||
|
||||
// 提供修复建议
|
||||
if (!status.connected) {
|
||||
console.log('\n💡 修复建议:');
|
||||
console.log(' 1. 检查网络连接');
|
||||
console.log(' 2. 尝试手动重连: __SOCKET_DEBUG__.reconnect()');
|
||||
console.log(' 3. 检查后端服务是否运行');
|
||||
}
|
||||
|
||||
if (browserPermission === 'denied') {
|
||||
console.log('\n💡 浏览器通知已被拒绝,请在浏览器设置中允许通知权限');
|
||||
}
|
||||
|
||||
console.log('\n====================================\n');
|
||||
|
||||
return report;
|
||||
},
|
||||
|
||||
// 手动订阅事件(简化版)
|
||||
subscribe: (eventType = 'all', importance = 'all') => {
|
||||
console.log(`\n[通知调试] 手动订阅事件: type=${eventType}, importance=${importance}`);
|
||||
window.__SOCKET_DEBUG__.subscribe({ eventType, importance });
|
||||
},
|
||||
|
||||
// 模拟接收通知(用于测试UI)
|
||||
testNotify: (type = 'announcement') => {
|
||||
console.log('\n[通知调试] 模拟通知:', type);
|
||||
|
||||
const mockNotifications = {
|
||||
announcement: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'announcement',
|
||||
priority: 'important',
|
||||
title: '🧪 测试公告通知',
|
||||
content: '这是一条测试消息,用于验证通知系统是否正常工作',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
stock_alert: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'stock_alert',
|
||||
priority: 'urgent',
|
||||
title: '🧪 测试股票预警',
|
||||
content: '贵州茅台触发价格预警: 1850.00元 (+5.2%)',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
event_alert: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'event_alert',
|
||||
priority: 'important',
|
||||
title: '🧪 测试事件动向',
|
||||
content: 'AI大模型新政策发布,影响科技板块',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
analysis_report: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'analysis_report',
|
||||
priority: 'normal',
|
||||
title: '🧪 测试分析报告',
|
||||
content: '2024年Q1市场策略报告已发布',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const notification = mockNotifications[type] || mockNotifications.announcement;
|
||||
|
||||
// 触发 new_event 事件
|
||||
if (socket.emit) {
|
||||
// 对于真实 Socket,模拟服务端推送(实际上客户端无法这样做,仅用于Mock模式)
|
||||
console.warn('⚠️ 真实 Socket 无法模拟服务端推送,请使用 Mock 模式或等待真实推送');
|
||||
}
|
||||
|
||||
// 直接触发事件监听器(如果是 Mock 模式)
|
||||
if (SOCKET_TYPE === 'MOCK' && socket.emit) {
|
||||
socket.emit('new_event', notification);
|
||||
console.log('✅ 已触发 Mock 通知事件');
|
||||
}
|
||||
|
||||
console.log('通知数据:', notification);
|
||||
return notification;
|
||||
},
|
||||
|
||||
// 导出完整诊断报告
|
||||
exportReport: () => {
|
||||
const report = window.__NOTIFY_DEBUG__.checkAll();
|
||||
|
||||
// 生成可下载的 JSON
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `notification-debug-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 诊断报告已导出');
|
||||
return report;
|
||||
},
|
||||
|
||||
// 快捷帮助
|
||||
help: () => {
|
||||
console.log('\n========== 通知系统调试 API ==========');
|
||||
console.log('window.__NOTIFY_DEBUG__.checkAll() - 完整诊断检查');
|
||||
console.log('window.__NOTIFY_DEBUG__.subscribe() - 手动订阅事件');
|
||||
console.log('window.__NOTIFY_DEBUG__.testNotify(type) - 模拟通知 (announcement/stock_alert/event_alert/analysis_report)');
|
||||
console.log('window.__NOTIFY_DEBUG__.exportReport() - 导出诊断报告');
|
||||
console.log('\n========== Socket 调试 API ==========');
|
||||
console.log('window.__SOCKET_DEBUG__.getStatus() - 获取连接状态');
|
||||
console.log('window.__SOCKET_DEBUG__.checkConfig() - 检查配置');
|
||||
console.log('window.__SOCKET_DEBUG__.reconnect() - 手动重连');
|
||||
console.log('====================================\n');
|
||||
},
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Notify Debug] Notification Debug API available at window.__NOTIFY_DEBUG__',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__NOTIFY_DEBUG__.checkAll() - 完整诊断',
|
||||
'color: #FF9800;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__NOTIFY_DEBUG__.help() - 查看所有命令',
|
||||
'color: #FF9800;'
|
||||
);
|
||||
}
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -64,6 +64,12 @@ class SocketService {
|
||||
logger.info('socketService', 'Socket.IO connected successfully', {
|
||||
socketId: this.socket.id,
|
||||
});
|
||||
|
||||
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] Socket ID:', this.socket.id);
|
||||
|
||||
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
|
||||
// this.subscribeToAllEvents();
|
||||
});
|
||||
|
||||
// 监听断开连接
|
||||
@@ -142,11 +148,20 @@ class SocketService {
|
||||
on(event, callback) {
|
||||
if (!this.socket) {
|
||||
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
|
||||
console.warn(`[socketService] ❌ 无法监听事件 ${event}: Socket 未初始化`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on(event, callback);
|
||||
// 包装回调函数,添加日志
|
||||
const wrappedCallback = (...args) => {
|
||||
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
|
||||
console.log(`[socketService] 事件数据 (${event}):`, ...args);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
this.socket.on(event, wrappedCallback);
|
||||
logger.info('socketService', `Event listener added: ${event}`);
|
||||
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,13 +352,14 @@ class SocketService {
|
||||
});
|
||||
|
||||
// 监听新事件推送
|
||||
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext)
|
||||
// 多个监听器可以共存,都会被触发
|
||||
if (onNewEvent) {
|
||||
console.log('[SocketService DEBUG] 设置 new_event 监听器');
|
||||
// 先移除之前的监听器(避免重复)
|
||||
this.socket.off('new_event');
|
||||
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
|
||||
|
||||
// 添加新的监听器
|
||||
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
|
||||
|
||||
// 添加新的监听器(与其他监听器共存)
|
||||
this.socket.on('new_event', (eventData) => {
|
||||
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
|
||||
console.log('[SocketService DEBUG] 事件数据:', eventData);
|
||||
@@ -355,7 +371,7 @@ class SocketService {
|
||||
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
|
||||
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
|
||||
});
|
||||
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
|
||||
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
|
||||
}
|
||||
|
||||
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
|
||||
@@ -403,14 +419,26 @@ class SocketService {
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅所有类型的事件
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数(可选)
|
||||
* @returns {Function} 取消订阅的函数
|
||||
*/
|
||||
subscribeToAllEvents(onNewEvent) {
|
||||
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
// 如果没有提供回调,添加一个默认的日志回调
|
||||
const defaultCallback = (event) => {
|
||||
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] 事件数据:', event);
|
||||
};
|
||||
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
onNewEvent: onNewEvent || defaultCallback,
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] 订阅确认:', data);
|
||||
},
|
||||
});
|
||||
|
||||
// 返回取消订阅的清理函数
|
||||
|
||||
144
src/store/api/eventsApi.js
Normal file
144
src/store/api/eventsApi.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// src/store/api/eventsApi.js
|
||||
// RTK Query API for Events - 事件数据获取 API
|
||||
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Events API Slice - 使用 RTK Query 管理事件数据
|
||||
*
|
||||
* 特性:
|
||||
* - ✅ 自动缓存管理(按 queryKey 缓存)
|
||||
* - ✅ 自动去重请求
|
||||
* - ✅ 返回第一页刷新数据(invalidateTags)
|
||||
* - ✅ 预加载支持(prefetch)
|
||||
* - ✅ 统一在 Redux DevTools 中调试
|
||||
* - ✅ 无需额外 Provider
|
||||
*/
|
||||
export const eventsApi = createApi({
|
||||
reducerPath: 'eventsApi',
|
||||
|
||||
// 基础查询配置
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: '/api',
|
||||
prepareHeaders: (headers) => {
|
||||
// 可以在这里添加认证 token
|
||||
// const token = localStorage.getItem('token');
|
||||
// if (token) {
|
||||
// headers.set('Authorization', `Bearer ${token}`);
|
||||
// }
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
|
||||
// 标签类型定义(用于缓存失效)
|
||||
tagTypes: ['Events'],
|
||||
|
||||
// API 端点定义
|
||||
endpoints: (builder) => ({
|
||||
/**
|
||||
* 获取分页事件列表
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.per_page - 每页数量
|
||||
* @param {string} params.mode - 显示模式(vertical / four-row)
|
||||
* @param {string} params.sort - 排序方式
|
||||
* @param {string} params.importance - 重要性筛选
|
||||
* @param {string} params.q - 搜索关键词
|
||||
* @param {string} params.date_range - 日期范围
|
||||
* @param {string} params.industry_code - 行业代码
|
||||
*
|
||||
* @returns {Object} { events: Array, pagination: Object }
|
||||
*/
|
||||
getEvents: builder.query({
|
||||
query: ({ page = 1, per_page = 5, mode = 'vertical', ...filters }) => {
|
||||
logger.debug('eventsApi', 'getEvents 请求', {
|
||||
page,
|
||||
per_page,
|
||||
mode,
|
||||
filters,
|
||||
});
|
||||
|
||||
return {
|
||||
url: '/events',
|
||||
params: {
|
||||
page,
|
||||
per_page,
|
||||
...filters,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// 🔥 缓存标签:用于缓存失效
|
||||
providesTags: (result, error, { page, mode }) => {
|
||||
if (error) return [];
|
||||
|
||||
return [
|
||||
{ type: 'Events', id: `${mode}-${page}` }, // 具体页面的标签
|
||||
{ type: 'Events', id: `${mode}-LIST` }, // 模式的总标签
|
||||
];
|
||||
},
|
||||
|
||||
// 转换响应数据
|
||||
transformResponse: (response) => {
|
||||
logger.debug('eventsApi', 'getEvents 响应', {
|
||||
eventsCount: response.data?.events?.length,
|
||||
total: response.data?.pagination?.total,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data?.events) {
|
||||
throw new Error('数据格式错误');
|
||||
}
|
||||
|
||||
return {
|
||||
events: response.data.events,
|
||||
pagination: response.data.pagination || {},
|
||||
};
|
||||
},
|
||||
|
||||
// 错误处理
|
||||
transformErrorResponse: (response) => {
|
||||
logger.error('eventsApi', 'getEvents 失败', new Error(response.status));
|
||||
return {
|
||||
status: response.status,
|
||||
message: response.data?.message || '获取事件数据失败',
|
||||
};
|
||||
},
|
||||
|
||||
// 🔥 keepUnusedDataFor: 缓存保留时间(秒)
|
||||
keepUnusedDataFor: 600, // 10分钟
|
||||
|
||||
// 🔥 合并查询结果(用于无限滚动)
|
||||
// serializeQueryArgs: ({ endpointName, queryArgs }) => {
|
||||
// const { mode, ...filters } = queryArgs;
|
||||
// return `${endpointName}(${mode})`;
|
||||
// },
|
||||
// merge: (currentCache, newItems) => {
|
||||
// currentCache.events.push(...newItems.events);
|
||||
// },
|
||||
// forceRefetch: ({ currentArg, previousArg }) => {
|
||||
// return currentArg.page !== previousArg?.page;
|
||||
// },
|
||||
}),
|
||||
|
||||
/**
|
||||
* 预加载下一页(性能优化)
|
||||
*
|
||||
* 用法:
|
||||
* dispatch(eventsApi.util.prefetch('getEvents', { page: 2, ... }))
|
||||
*/
|
||||
}),
|
||||
});
|
||||
|
||||
// 导出自动生成的 Hooks
|
||||
export const {
|
||||
useGetEventsQuery,
|
||||
useLazyGetEventsQuery, // 手动触发的版本
|
||||
usePrefetch, // 预加载 Hook
|
||||
} = eventsApi;
|
||||
|
||||
// 导出工具方法
|
||||
export const {
|
||||
util: { invalidateTags, prefetch },
|
||||
} = eventsApi;
|
||||
@@ -7,6 +7,7 @@ import stockReducer from './slices/stockSlice';
|
||||
import authModalReducer from './slices/authModalSlice';
|
||||
import subscriptionReducer from './slices/subscriptionSlice';
|
||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -16,6 +17,7 @@ export const store = configureStore({
|
||||
stock: stockReducer, // ✅ 股票和事件数据管理
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
@@ -29,7 +31,9 @@ export const store = configureStore({
|
||||
'stock/fetchStockQuotes/fulfilled',
|
||||
],
|
||||
},
|
||||
}).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件
|
||||
})
|
||||
.concat(posthogMiddleware) // ✅ PostHog 自动追踪中间件
|
||||
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -103,7 +103,6 @@ const createDataReducers = (builder, asyncThunk, dataKey) => {
|
||||
.addCase(asyncThunk.fulfilled, (state, action) => {
|
||||
state.loading[dataKey] = false;
|
||||
state[dataKey] = action.payload;
|
||||
state.lastUpdated[dataKey] = new Date().toISOString();
|
||||
})
|
||||
.addCase(asyncThunk.rejected, (state, action) => {
|
||||
state.loading[dataKey] = false;
|
||||
@@ -157,63 +156,114 @@ export const fetchHotEvents = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取动态新闻(客户端缓存 + 智能请求)
|
||||
* 获取动态新闻(客户端缓存 + 虚拟滚动)
|
||||
* 用于 DynamicNewsCard 组件
|
||||
* @param {Object} params - 请求参数
|
||||
* @param {string} params.mode - 显示模式('vertical' | 'four-row')
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.per_page - 每页数量
|
||||
* @param {number} params.per_page - 每页数量(可选,不提供时自动根据 mode 计算)
|
||||
* @param {boolean} params.clearCache - 是否清空缓存(默认 false)
|
||||
* @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false)
|
||||
* @param {string} params.sort - 排序方式(new/hot)
|
||||
* @param {string} params.importance - 重要性筛选(all/1/2/3/4/5)
|
||||
* @param {string} params.q - 搜索关键词
|
||||
* @param {string} params.date_range - 时间范围
|
||||
* @param {string} params.industry_code - 行业代码
|
||||
*/
|
||||
export const fetchDynamicNews = createAsyncThunk(
|
||||
'communityData/fetchDynamicNews',
|
||||
async ({
|
||||
mode = 'vertical',
|
||||
page = 1,
|
||||
per_page = 5,
|
||||
pageSize = 5, // 每页实际显示的数据量(用于计算索引)
|
||||
per_page, // 移除默认值,下面动态计算
|
||||
pageSize, // 向后兼容(已废弃,使用 per_page)
|
||||
clearCache = false,
|
||||
prependMode = false
|
||||
prependMode = false,
|
||||
sort = 'new',
|
||||
importance,
|
||||
q,
|
||||
date_range, // 兼容旧格式(已废弃)
|
||||
industry_code,
|
||||
// 时间筛选参数(从 TradingTimeFilter 传递)
|
||||
start_date,
|
||||
end_date,
|
||||
recent_days
|
||||
} = {}, { rejectWithValue }) => {
|
||||
try {
|
||||
// 【动态计算 per_page】根据 mode 自动选择合适的每页大小
|
||||
// - 平铺模式 (four-row): 30 条(7.5行 × 4列,提供充足的缓冲数据)
|
||||
// - 纵向模式 (vertical): 10 条(传统分页)
|
||||
// 优先使用传入的 per_page,其次使用 pageSize(向后兼容),最后根据 mode 计算
|
||||
const finalPerPage = per_page || pageSize || (mode === 'four-row' ? 30 : 10);
|
||||
|
||||
// 构建筛选参数
|
||||
const filters = {};
|
||||
if (sort) filters.sort = sort;
|
||||
if (importance && importance !== 'all') filters.importance = importance;
|
||||
if (q) filters.q = q;
|
||||
if (date_range) filters.date_range = date_range; // 兼容旧格式
|
||||
if (industry_code) filters.industry_code = industry_code;
|
||||
// 时间筛选参数
|
||||
if (start_date) filters.start_date = start_date;
|
||||
if (end_date) filters.end_date = end_date;
|
||||
if (recent_days) filters.recent_days = recent_days;
|
||||
|
||||
logger.debug('CommunityData', '开始获取动态新闻', {
|
||||
mode,
|
||||
page,
|
||||
per_page,
|
||||
per_page: finalPerPage,
|
||||
clearCache,
|
||||
prependMode
|
||||
prependMode,
|
||||
filters
|
||||
});
|
||||
|
||||
const response = await eventService.getEvents({
|
||||
page,
|
||||
per_page,
|
||||
sort: 'new'
|
||||
per_page: finalPerPage,
|
||||
...filters
|
||||
});
|
||||
|
||||
if (response.success && response.data?.events) {
|
||||
logger.info('CommunityData', '动态新闻加载成功', {
|
||||
count: response.data.events.length,
|
||||
page: response.data.pagination?.page || page,
|
||||
total: response.data.pagination?.total || 0
|
||||
});
|
||||
return {
|
||||
events: response.data.events,
|
||||
total: response.data.pagination?.total || 0,
|
||||
per_page: finalPerPage
|
||||
});
|
||||
// 【兜底处理】支持多种 pagination 字段名:pages (后端) / total_pages (旧Mock) / totalPages
|
||||
const paginationData = response.data.pagination || {};
|
||||
const calculatedTotalPages = paginationData.pages // ← 后端格式 (优先)
|
||||
|| paginationData.total_pages // ← Mock 旧格式 (兼容)
|
||||
|| paginationData.totalPages // ← 其他可能格式
|
||||
|| Math.ceil((paginationData.total || 0) / finalPerPage); // ← 兜底计算
|
||||
|
||||
return {
|
||||
mode,
|
||||
events: response.data.events,
|
||||
total: paginationData.total || 0,
|
||||
totalPages: calculatedTotalPages,
|
||||
page,
|
||||
per_page,
|
||||
pageSize, // 返回 pageSize 用于索引计算
|
||||
per_page: finalPerPage,
|
||||
clearCache,
|
||||
prependMode
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn('CommunityData', '动态新闻返回数据为空', response);
|
||||
// 【兜底处理】空数据情况也尝试读取 pagination
|
||||
const emptyPaginationData = response.data?.pagination || {};
|
||||
const emptyTotalPages = emptyPaginationData.pages || emptyPaginationData.total_pages || 0;
|
||||
|
||||
return {
|
||||
mode,
|
||||
events: [],
|
||||
total: 0,
|
||||
totalPages: emptyTotalPages,
|
||||
page,
|
||||
per_page,
|
||||
pageSize, // 返回 pageSize 用于索引计算
|
||||
per_page: finalPerPage,
|
||||
clearCache,
|
||||
prependMode
|
||||
prependMode,
|
||||
isEmpty: true // 标记为空数据,用于边界条件处理
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', '获取动态新闻失败', error);
|
||||
@@ -269,42 +319,75 @@ export const toggleEventFollow = createAsyncThunk(
|
||||
|
||||
// ==================== Slice 定义 ====================
|
||||
|
||||
/**
|
||||
* 【Redux State 架构设计】
|
||||
*
|
||||
* 核心原则:
|
||||
* 1. **模式独立存储**: verticalEvents 和 fourRowEvents 完全独立
|
||||
* - 原因:两种模式使用不同的 pageSize (10 vs 30),共享缓存会导致分页混乱
|
||||
* - 代价:~50% 内存冗余(同一事件可能存在于两个数组)
|
||||
* - 权衡:简化逻辑复杂度,避免 pageSize 切换时的边界计算问题
|
||||
*
|
||||
* 2. **数据去重**: 使用 Set 去重,防止重复事件
|
||||
* - 场景1:网络请求乱序(慢请求后返回)
|
||||
* - 场景2:定时刷新 + prepend 模式(新事件插入头部)
|
||||
* - 场景3:后端分页漂移(新数据导致页码偏移)
|
||||
*
|
||||
* 3. **追加模式 (append)**: 虚拟滚动必须使用累积数组
|
||||
* - 原因:虚拟滚动需要完整数据计算 totalHeight
|
||||
* - 对比:传统分页每次替换数据(page mode)
|
||||
*
|
||||
* 4. **加载状态管理**: 分模式独立管理 loading/error
|
||||
* - 避免模式切换时的加载状态冲突
|
||||
*/
|
||||
const communityDataSlice = createSlice({
|
||||
name: 'communityData',
|
||||
initialState: {
|
||||
// 数据
|
||||
popularKeywords: [],
|
||||
hotEvents: [],
|
||||
dynamicNews: [], // 动态新闻完整缓存列表
|
||||
dynamicNewsTotal: 0, // 服务端总数量
|
||||
eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } }
|
||||
|
||||
// 加载状态
|
||||
// 【纵向模式】独立存储(传统分页 + 每页10条)
|
||||
verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] }
|
||||
verticalPagination: { // 分页元数据
|
||||
total: 0, // 总记录数
|
||||
total_pages: 0, // 总页数
|
||||
current_page: 1, // 当前页码
|
||||
per_page: 10 // 每页大小
|
||||
},
|
||||
|
||||
// 【平铺模式】独立存储(虚拟滚动 + 每页30条)
|
||||
fourRowEvents: [], // 完整缓存列表(虚拟滚动的数据源)
|
||||
fourRowPagination: { // 分页元数据
|
||||
total: 0, // 总记录数
|
||||
total_pages: 0, // 总页数
|
||||
current_page: 1, // 当前页码
|
||||
per_page: 30 // 每页大小
|
||||
},
|
||||
|
||||
eventFollowStatus: {}, // 事件关注状态(全局共享){ [eventId]: { isFollowing: boolean, followerCount: number } }
|
||||
|
||||
// 加载状态(分模式管理)
|
||||
loading: {
|
||||
popularKeywords: false,
|
||||
hotEvents: false,
|
||||
dynamicNews: false
|
||||
verticalEvents: false,
|
||||
fourRowEvents: false
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
// 错误信息(分模式管理)
|
||||
error: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null,
|
||||
dynamicNews: null
|
||||
},
|
||||
|
||||
// 最后更新时间
|
||||
lastUpdated: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null,
|
||||
dynamicNews: null
|
||||
verticalEvents: null,
|
||||
fourRowEvents: null
|
||||
}
|
||||
},
|
||||
|
||||
reducers: {
|
||||
/**
|
||||
* 清除所有缓存(Redux + localStorage)
|
||||
* 注意:dynamicNews 不使用 localStorage 缓存
|
||||
* 注意:verticalEvents 和 fourRowEvents 不使用 localStorage 缓存
|
||||
*/
|
||||
clearCache: (state) => {
|
||||
// 清除 localStorage
|
||||
@@ -313,17 +396,19 @@ const communityDataSlice = createSlice({
|
||||
// 清除 Redux 状态
|
||||
state.popularKeywords = [];
|
||||
state.hotEvents = [];
|
||||
state.dynamicNews = []; // 动态新闻也清除
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
state.lastUpdated.hotEvents = null;
|
||||
state.lastUpdated.dynamicNews = null;
|
||||
|
||||
// 清除动态新闻数据(两个模式)
|
||||
state.verticalEventsByPage = {};
|
||||
state.fourRowEvents = [];
|
||||
state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 };
|
||||
state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 };
|
||||
|
||||
logger.info('CommunityData', '所有缓存已清除');
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定类型的缓存
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'dynamicNews')
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'verticalEvents' | 'fourRowEvents')
|
||||
*/
|
||||
clearSpecificCache: (state, action) => {
|
||||
const type = action.payload;
|
||||
@@ -331,18 +416,21 @@ const communityDataSlice = createSlice({
|
||||
if (type === 'popularKeywords') {
|
||||
localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS);
|
||||
state.popularKeywords = [];
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
logger.info('CommunityData', '热门关键词缓存已清除');
|
||||
} else if (type === 'hotEvents') {
|
||||
localCacheManager.remove(CACHE_KEYS.HOT_EVENTS);
|
||||
state.hotEvents = [];
|
||||
state.lastUpdated.hotEvents = null;
|
||||
logger.info('CommunityData', '热点事件缓存已清除');
|
||||
} else if (type === 'dynamicNews') {
|
||||
// dynamicNews 不使用 localStorage,只清除 Redux state
|
||||
state.dynamicNews = [];
|
||||
state.lastUpdated.dynamicNews = null;
|
||||
logger.info('CommunityData', '动态新闻数据已清除');
|
||||
} else if (type === 'verticalEvents') {
|
||||
// verticalEvents 不使用 localStorage,只清除 Redux state
|
||||
state.verticalEventsByPage = {};
|
||||
state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 };
|
||||
logger.info('CommunityData', '纵向模式事件数据已清除');
|
||||
} else if (type === 'fourRowEvents') {
|
||||
// fourRowEvents 不使用 localStorage,只清除 Redux state
|
||||
state.fourRowEvents = [];
|
||||
state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 };
|
||||
logger.info('CommunityData', '平铺模式事件数据已清除');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -350,7 +438,7 @@ const communityDataSlice = createSlice({
|
||||
* 预加载数据(用于应用启动时)
|
||||
* 注意:这不是异步 action,只是触发标记
|
||||
*/
|
||||
preloadData: (state) => {
|
||||
preloadData: (_state) => {
|
||||
logger.info('CommunityData', '准备预加载数据');
|
||||
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
|
||||
},
|
||||
@@ -363,6 +451,17 @@ const communityDataSlice = createSlice({
|
||||
const { eventId, isFollowing, followerCount } = action.payload;
|
||||
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
|
||||
logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分页页码(用于缓存场景,无需 API 请求)
|
||||
* @param {Object} action.payload - { mode, page }
|
||||
*/
|
||||
updatePaginationPage: (state, action) => {
|
||||
const { mode, page } = action.payload;
|
||||
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
|
||||
state[paginationKey].current_page = page;
|
||||
logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -372,101 +471,142 @@ const communityDataSlice = createSlice({
|
||||
createDataReducers(builder, fetchHotEvents, 'hotEvents');
|
||||
|
||||
// dynamicNews 需要特殊处理(缓存 + 追加模式)
|
||||
// 根据 mode 更新不同的 state(verticalEvents 或 fourRowEvents)
|
||||
builder
|
||||
.addCase(fetchDynamicNews.pending, (state) => {
|
||||
state.loading.dynamicNews = true;
|
||||
state.error.dynamicNews = null;
|
||||
.addCase(fetchDynamicNews.pending, (state, action) => {
|
||||
const mode = action.meta.arg.mode || 'vertical';
|
||||
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
|
||||
state.loading[stateKey] = true;
|
||||
state.error[stateKey] = null;
|
||||
})
|
||||
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
|
||||
const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload;
|
||||
const { mode, events, total, page, per_page, clearCache, prependMode, isEmpty } = action.payload;
|
||||
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
|
||||
const totalKey = mode === 'four-row' ? 'fourRowTotal' : 'verticalTotal';
|
||||
|
||||
if (clearCache) {
|
||||
// 清空缓存模式:直接替换
|
||||
state.dynamicNews = events;
|
||||
logger.debug('CommunityData', '清空缓存并加载新数据', {
|
||||
count: events.length
|
||||
});
|
||||
} else if (prependMode) {
|
||||
// 追加到头部模式(用于定时刷新):去重后插入头部
|
||||
const existingIds = new Set(state.dynamicNews.map(e => e.id));
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.dynamicNews = [...newEvents, ...state.dynamicNews];
|
||||
logger.debug('CommunityData', '追加新数据到头部', {
|
||||
newCount: newEvents.length,
|
||||
totalCount: state.dynamicNews.length
|
||||
});
|
||||
} else {
|
||||
// 智能插入模式:根据页码计算正确的插入位置
|
||||
// 使用 pageSize(每页显示量)而不是 per_page(请求数量)
|
||||
const startIndex = (page - 1) * (pageSize || per_page);
|
||||
// 边界条件:空数据只记录日志,不更新 state(保留现有数据)
|
||||
if (isEmpty || (events.length === 0 && !clearCache)) {
|
||||
logger.info('CommunityData', `${mode} 模式返回空数据,跳过更新`);
|
||||
state.loading[stateKey] = false;
|
||||
state.error[stateKey] = '暂无更多数据'; // 设置提示信息供组件显示 toast
|
||||
return; // 提前返回,不更新数据
|
||||
}
|
||||
|
||||
// 判断插入模式
|
||||
const isAppend = startIndex === state.dynamicNews.length;
|
||||
const isReplace = startIndex < state.dynamicNews.length;
|
||||
const isJump = startIndex > state.dynamicNews.length;
|
||||
// 🔍 调试:收到数据
|
||||
console.log('%c[Redux] fetchDynamicNews.fulfilled 收到数据', 'color: #10B981; font-weight: bold;', {
|
||||
mode,
|
||||
stateKey,
|
||||
eventsCount: events.length,
|
||||
total,
|
||||
page,
|
||||
clearCache,
|
||||
prependMode,
|
||||
'state[stateKey] 类型': Array.isArray(state[stateKey]) ? 'Array' : 'Object',
|
||||
'state[stateKey] 之前': Array.isArray(state[stateKey])
|
||||
? `数组长度: ${state[stateKey].length}`
|
||||
: `对象页数: ${Object.keys(state[stateKey] || {}).length}`,
|
||||
});
|
||||
|
||||
// 只在 append 模式下去重(避免定时刷新重复)
|
||||
// 替换和跳页模式直接使用原始数据(避免因去重导致数据丢失)
|
||||
if (isAppend) {
|
||||
// Append 模式:连续加载,需要去重
|
||||
const existingIds = new Set(
|
||||
state.dynamicNews
|
||||
.filter(e => e !== null)
|
||||
.map(e => e.id)
|
||||
);
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.dynamicNews = [...state.dynamicNews, ...newEvents];
|
||||
|
||||
logger.debug('CommunityData', '连续追加数据(去重)', {
|
||||
/**
|
||||
* 【数据存储逻辑】根据模式选择不同的存储策略
|
||||
*
|
||||
* 纵向模式(vertical):页码映射存储
|
||||
* - clearCache=true: 清空所有页,存储新页(第1页专用)
|
||||
* - clearCache=false: 存储到对应页码(第2、3、4...页)
|
||||
* - 优点:每页独立,不受去重影响,支持缓存
|
||||
*
|
||||
* 平铺模式(four-row):去重追加存储
|
||||
* - clearCache=true: 直接替换(用于刷新)
|
||||
* - prependMode=true: 去重后插入头部(定时刷新)
|
||||
* - 默认:去重后追加到末尾(无限滚动)
|
||||
* - 优点:累积显示,支持虚拟滚动
|
||||
*/
|
||||
if (mode === 'vertical') {
|
||||
// 【纵向模式】页码映射存储
|
||||
if (clearCache) {
|
||||
// 第1页:清空所有页,只保留新页
|
||||
state.verticalEventsByPage = { [page]: events };
|
||||
logger.debug('CommunityData', `清空缓存并加载第${page}页 (纵向模式)`, {
|
||||
count: events.length
|
||||
});
|
||||
console.log('%c[Redux] 纵向模式 clearCache,清空所有页', 'color: #10B981; font-weight: bold;', {
|
||||
page,
|
||||
eventsCount: events.length
|
||||
});
|
||||
} else {
|
||||
// 其他页:存储到对应页码
|
||||
state.verticalEventsByPage = state.verticalEventsByPage || {};
|
||||
state.verticalEventsByPage[page] = events;
|
||||
logger.debug('CommunityData', `存储第${page}页数据 (纵向模式)`, {
|
||||
page,
|
||||
count: events.length,
|
||||
totalPages: Object.keys(state.verticalEventsByPage || {}).length
|
||||
});
|
||||
console.log('%c[Redux] 纵向模式追加页面', 'color: #10B981; font-weight: bold;', {
|
||||
page,
|
||||
eventsCount: events.length,
|
||||
cachedPages: Object.keys(state.verticalEventsByPage || {})
|
||||
});
|
||||
}
|
||||
} else if (mode === 'four-row') {
|
||||
// 【平铺模式】去重追加存储
|
||||
if (clearCache) {
|
||||
// 清空缓存模式:直接替换
|
||||
state.fourRowEvents = events;
|
||||
logger.debug('CommunityData', `清空缓存并加载新数据 (平铺模式)`, {
|
||||
count: events.length
|
||||
});
|
||||
console.log('%c[Redux] 平铺模式 clearCache,直接替换数据', 'color: #10B981; font-weight: bold;', {
|
||||
eventsCount: events.length
|
||||
});
|
||||
} else if (prependMode) {
|
||||
// 追加到头部模式(用于定时刷新):去重后插入头部
|
||||
const existingIds = new Set((state.fourRowEvents || []).map(e => e.id));
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.fourRowEvents = [...newEvents, ...(state.fourRowEvents || [])];
|
||||
logger.debug('CommunityData', `追加新数据到头部 (平铺模式)`, {
|
||||
newCount: newEvents.length,
|
||||
totalCount: state.fourRowEvents.length
|
||||
});
|
||||
} else {
|
||||
// 默认追加模式:去重后追加到末尾(用于虚拟滚动加载下一页)
|
||||
const existingIds = new Set((state.fourRowEvents || []).map(e => e.id));
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.fourRowEvents = [...(state.fourRowEvents || []), ...newEvents];
|
||||
|
||||
logger.debug('CommunityData', `追加新数据(去重,平铺模式)`, {
|
||||
page,
|
||||
startIndex,
|
||||
endIndex: startIndex + newEvents.length,
|
||||
originalEventsCount: events.length,
|
||||
newEventsCount: newEvents.length,
|
||||
filteredCount: events.length - newEvents.length,
|
||||
totalCount: state.dynamicNews.length
|
||||
});
|
||||
} else if (isReplace) {
|
||||
// 替换模式:直接覆盖,不去重
|
||||
const endIndex = startIndex + events.length;
|
||||
const before = state.dynamicNews.slice(0, startIndex);
|
||||
const after = state.dynamicNews.slice(endIndex);
|
||||
state.dynamicNews = [...before, ...events, ...after];
|
||||
|
||||
logger.debug('CommunityData', '替换重叠数据(不去重)', {
|
||||
page,
|
||||
startIndex,
|
||||
endIndex,
|
||||
eventsCount: events.length,
|
||||
beforeLength: before.length,
|
||||
afterLength: after.length,
|
||||
totalCount: state.dynamicNews.length
|
||||
});
|
||||
} else {
|
||||
// 跳页模式:填充间隔,不去重
|
||||
const gap = startIndex - state.dynamicNews.length;
|
||||
const fillers = Array(gap).fill(null);
|
||||
state.dynamicNews = [...state.dynamicNews, ...fillers, ...events];
|
||||
|
||||
logger.debug('CommunityData', '跳页加载,填充间隔(不去重)', {
|
||||
page,
|
||||
startIndex,
|
||||
endIndex: startIndex + events.length,
|
||||
gap,
|
||||
eventsCount: events.length,
|
||||
totalCount: state.dynamicNews.length
|
||||
totalCount: state.fourRowEvents.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.dynamicNewsTotal = total;
|
||||
state.loading.dynamicNews = false;
|
||||
state.lastUpdated.dynamicNews = new Date().toISOString();
|
||||
// 【元数据存储】存储完整的 pagination 对象
|
||||
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
|
||||
const finalPerPage = per_page || (mode === 'four-row' ? 30 : 10); // 兜底默认值
|
||||
state[paginationKey] = {
|
||||
total: total,
|
||||
total_pages: action.payload.totalPages || Math.ceil(total / finalPerPage),
|
||||
current_page: page,
|
||||
per_page: finalPerPage
|
||||
};
|
||||
|
||||
console.log('%c[Redux] 更新分页元数据', 'color: #8B5CF6; font-weight: bold;', {
|
||||
mode,
|
||||
pagination: state[paginationKey]
|
||||
});
|
||||
|
||||
state.loading[stateKey] = false;
|
||||
})
|
||||
.addCase(fetchDynamicNews.rejected, (state, action) => {
|
||||
state.loading.dynamicNews = false;
|
||||
state.error.dynamicNews = action.payload;
|
||||
logger.error('CommunityData', 'dynamicNews 加载失败', new Error(action.payload));
|
||||
const mode = action.meta.arg.mode || 'vertical';
|
||||
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
|
||||
state.loading[stateKey] = false;
|
||||
state.error[stateKey] = action.payload;
|
||||
logger.error('CommunityData', `${stateKey} 加载失败`, new Error(action.payload));
|
||||
})
|
||||
// toggleEventFollow
|
||||
.addCase(toggleEventFollow.fulfilled, (state, action) => {
|
||||
@@ -474,7 +614,7 @@ const communityDataSlice = createSlice({
|
||||
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
|
||||
logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount });
|
||||
})
|
||||
.addCase(toggleEventFollow.rejected, (state, action) => {
|
||||
.addCase(toggleEventFollow.rejected, (_state, action) => {
|
||||
logger.error('CommunityData', 'toggleEventFollow rejected', action.payload);
|
||||
});
|
||||
}
|
||||
@@ -482,46 +622,60 @@ const communityDataSlice = createSlice({
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions;
|
||||
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
|
||||
|
||||
// 基础选择器(Selectors)
|
||||
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
|
||||
export const selectHotEvents = (state) => state.communityData.hotEvents;
|
||||
export const selectDynamicNews = (state) => state.communityData.dynamicNews;
|
||||
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
|
||||
export const selectLoading = (state) => state.communityData.loading;
|
||||
export const selectError = (state) => state.communityData.error;
|
||||
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
|
||||
|
||||
// 纵向模式数据选择器
|
||||
export const selectVerticalEventsByPage = (state) => state.communityData.verticalEventsByPage;
|
||||
export const selectVerticalPagination = (state) => state.communityData.verticalPagination;
|
||||
export const selectVerticalCachedPageCount = (state) => Object.keys(state.communityData.verticalEventsByPage || {}).length;
|
||||
|
||||
// 平铺模式数据选择器
|
||||
export const selectFourRowEvents = (state) => state.communityData.fourRowEvents;
|
||||
export const selectFourRowPagination = (state) => state.communityData.fourRowPagination;
|
||||
export const selectFourRowCachedCount = (state) => (state.communityData.fourRowEvents || []).length;
|
||||
|
||||
// 向后兼容的选择器(已废弃,建议使用 selectVerticalPagination.total)
|
||||
export const selectVerticalTotal = (state) => state.communityData.verticalPagination?.total || 0;
|
||||
export const selectFourRowTotal = (state) => state.communityData.fourRowPagination?.total || 0;
|
||||
|
||||
// 组合选择器
|
||||
export const selectPopularKeywordsWithLoading = (state) => ({
|
||||
data: state.communityData.popularKeywords,
|
||||
loading: state.communityData.loading.popularKeywords,
|
||||
error: state.communityData.error.popularKeywords,
|
||||
lastUpdated: state.communityData.lastUpdated.popularKeywords
|
||||
error: state.communityData.error.popularKeywords
|
||||
});
|
||||
|
||||
export const selectHotEventsWithLoading = (state) => ({
|
||||
data: state.communityData.hotEvents,
|
||||
loading: state.communityData.loading.hotEvents,
|
||||
error: state.communityData.error.hotEvents,
|
||||
lastUpdated: state.communityData.lastUpdated.hotEvents
|
||||
error: state.communityData.error.hotEvents
|
||||
});
|
||||
|
||||
export const selectDynamicNewsWithLoading = (state) => ({
|
||||
data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符)
|
||||
loading: state.communityData.loading.dynamicNews,
|
||||
error: state.communityData.error.dynamicNews,
|
||||
total: state.communityData.dynamicNewsTotal, // 服务端总数量
|
||||
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null)
|
||||
lastUpdated: state.communityData.lastUpdated.dynamicNews
|
||||
// 纵向模式数据 + 加载状态选择器
|
||||
export const selectVerticalEventsWithLoading = (state) => ({
|
||||
data: state.communityData.verticalEventsByPage, // 页码映射 { 1: [...], 2: [...] }
|
||||
loading: state.communityData.loading.verticalEvents,
|
||||
error: state.communityData.error.verticalEvents,
|
||||
pagination: state.communityData.verticalPagination, // 完整分页元数据 { total, total_pages, current_page, per_page }
|
||||
total: state.communityData.verticalPagination?.total || 0, // 向后兼容:服务端总数量
|
||||
cachedPageCount: Object.keys(state.communityData.verticalEventsByPage || {}).length // 已缓存页数
|
||||
});
|
||||
|
||||
// 工具函数:检查数据是否需要刷新(超过指定时间)
|
||||
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
|
||||
if (!lastUpdated) return true;
|
||||
const elapsed = Date.now() - new Date(lastUpdated).getTime();
|
||||
return elapsed > thresholdMinutes * 60 * 1000;
|
||||
};
|
||||
// 平铺模式数据 + 加载状态选择器
|
||||
export const selectFourRowEventsWithLoading = (state) => ({
|
||||
data: state.communityData.fourRowEvents, // 完整缓存列表
|
||||
loading: state.communityData.loading.fourRowEvents,
|
||||
error: state.communityData.error.fourRowEvents,
|
||||
pagination: state.communityData.fourRowPagination, // 完整分页元数据 { total, total_pages, current_page, per_page }
|
||||
total: state.communityData.fourRowPagination?.total || 0, // 向后兼容:服务端总数量
|
||||
cachedCount: (state.communityData.fourRowEvents || []).length // 已缓存有效数量
|
||||
});
|
||||
|
||||
export default communityDataSlice.reducer;
|
||||
|
||||
98
src/utils/colorUtils.js
Normal file
98
src/utils/colorUtils.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/utils/colorUtils.js
|
||||
// 颜色工具函数 - 根据涨跌幅动态计算颜色深浅
|
||||
|
||||
/**
|
||||
* 根据涨跌幅获取颜色(深浅动态变化)
|
||||
* @param {number} change - 涨跌幅百分比
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
export const getChangeColor = (change) => {
|
||||
if (change === null || change === undefined || isNaN(change)) {
|
||||
return 'gray.500';
|
||||
}
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
|
||||
if (change > 0) {
|
||||
// 涨:红色系,根据涨幅深浅
|
||||
if (absChange >= 9) return 'red.900'; // 涨停或接近涨停
|
||||
if (absChange >= 7) return 'red.800';
|
||||
if (absChange >= 5) return 'red.700';
|
||||
if (absChange >= 3) return 'red.600';
|
||||
if (absChange >= 2) return 'red.500';
|
||||
if (absChange >= 1) return 'red.400';
|
||||
return 'red.300'; // 微涨
|
||||
} else if (change < 0) {
|
||||
// 跌:绿色系,根据跌幅深浅
|
||||
if (absChange >= 9) return 'green.900'; // 跌停或接近跌停
|
||||
if (absChange >= 7) return 'green.800';
|
||||
if (absChange >= 5) return 'green.700';
|
||||
if (absChange >= 3) return 'green.600';
|
||||
if (absChange >= 2) return 'green.500';
|
||||
if (absChange >= 1) return 'green.400';
|
||||
return 'green.300'; // 微跌
|
||||
}
|
||||
|
||||
return 'gray.500'; // 平盘
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取涨跌幅背景渐变色(用于精简模式卡片)
|
||||
* @param {number} change - 涨跌幅百分比
|
||||
* @param {boolean} useDark - 是否使用深色模式
|
||||
* @returns {string} Chakra UI bgGradient 值
|
||||
*/
|
||||
export const getChangeBackgroundGradient = (change, useDark = false) => {
|
||||
if (change === null || change === undefined || isNaN(change)) {
|
||||
return 'linear(to-br, gray.50, gray.100)';
|
||||
}
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
|
||||
if (change > 0) {
|
||||
// 涨:红色渐变背景
|
||||
if (absChange >= 9) return 'linear(to-br, red.100, red.200)';
|
||||
if (absChange >= 7) return 'linear(to-br, red.50, red.150)';
|
||||
if (absChange >= 5) return 'linear(to-br, red.50, red.100)';
|
||||
if (absChange >= 3) return 'linear(to-br, red.50, red.100)';
|
||||
return 'linear(to-br, red.50, red.50)';
|
||||
} else if (change < 0) {
|
||||
// 跌:绿色渐变背景
|
||||
if (absChange >= 9) return 'linear(to-br, green.100, green.200)';
|
||||
if (absChange >= 7) return 'linear(to-br, green.50, green.150)';
|
||||
if (absChange >= 5) return 'linear(to-br, green.50, green.100)';
|
||||
if (absChange >= 3) return 'linear(to-br, green.50, green.100)';
|
||||
return 'linear(to-br, green.50, green.50)';
|
||||
}
|
||||
|
||||
return 'linear(to-br, gray.50, gray.100)';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取涨跌幅边框颜色
|
||||
* @param {number} change - 涨跌幅百分比
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
export const getChangeBorderColor = (change) => {
|
||||
if (change === null || change === undefined || isNaN(change)) {
|
||||
return 'gray.300';
|
||||
}
|
||||
|
||||
const absChange = Math.abs(change);
|
||||
|
||||
if (change > 0) {
|
||||
if (absChange >= 9) return 'red.700';
|
||||
if (absChange >= 7) return 'red.600';
|
||||
if (absChange >= 5) return 'red.500';
|
||||
if (absChange >= 3) return 'red.400';
|
||||
return 'red.300';
|
||||
} else if (change < 0) {
|
||||
if (absChange >= 9) return 'green.700';
|
||||
if (absChange >= 7) return 'green.600';
|
||||
if (absChange >= 5) return 'green.500';
|
||||
if (absChange >= 3) return 'green.400';
|
||||
return 'green.300';
|
||||
}
|
||||
|
||||
return 'gray.300';
|
||||
};
|
||||
1038
src/views/AgentChat/index.js
Normal file
1038
src/views/AgentChat/index.js
Normal file
File diff suppressed because it is too large
Load Diff
53
src/views/AgentChat/index_backup.js
Normal file
53
src/views/AgentChat/index_backup.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// src/views/AgentChat/index.js
|
||||
// Agent聊天页面
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChatInterfaceV2 } from '../../components/ChatBot';
|
||||
|
||||
/**
|
||||
* Agent聊天页面
|
||||
* 提供基于MCP的AI助手对话功能
|
||||
*/
|
||||
const AgentChat = () => {
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
return (
|
||||
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
|
||||
<Container maxW="container.xl" h="100%">
|
||||
<VStack spacing={6} align="stretch" h="100%">
|
||||
{/* 页面标题 */}
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>AI投资助手</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
基于MCP协议的智能投资顾问,支持股票查询、新闻搜索、概念分析等多种功能
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 聊天界面 */}
|
||||
<Box
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="xl"
|
||||
overflow="hidden"
|
||||
h="calc(100vh - 300px)"
|
||||
minH="600px"
|
||||
>
|
||||
<ChatInterfaceV2 />
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentChat;
|
||||
857
src/views/AgentChat/index_v3.js
Normal file
857
src/views/AgentChat/index_v3.js
Normal file
@@ -0,0 +1,857 @@
|
||||
// src/views/AgentChat/index_v3.js
|
||||
// Agent聊天页面 V3 - 带左侧会话列表和用户信息集成
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Input,
|
||||
IconButton,
|
||||
Button,
|
||||
Avatar,
|
||||
Heading,
|
||||
Divider,
|
||||
Spinner,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Progress,
|
||||
Fade,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiSend,
|
||||
FiSearch,
|
||||
FiPlus,
|
||||
FiMessageSquare,
|
||||
FiTrash2,
|
||||
FiMoreVertical,
|
||||
FiRefreshCw,
|
||||
FiDownload,
|
||||
FiCpu,
|
||||
FiUser,
|
||||
FiZap,
|
||||
FiClock,
|
||||
} from 'react-icons/fi';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import { PlanCard } from '@components/ChatBot/PlanCard';
|
||||
import { StepResultCard } from '@components/ChatBot/StepResultCard';
|
||||
import { logger } from '@utils/logger';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Agent消息类型
|
||||
*/
|
||||
const MessageTypes = {
|
||||
USER: 'user',
|
||||
AGENT_THINKING: 'agent_thinking',
|
||||
AGENT_PLAN: 'agent_plan',
|
||||
AGENT_EXECUTING: 'agent_executing',
|
||||
AGENT_RESPONSE: 'agent_response',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent聊天页面 V3
|
||||
*/
|
||||
const AgentChatV3 = () => {
|
||||
const { user } = useAuth(); // 获取当前用户信息
|
||||
const toast = useToast();
|
||||
|
||||
// 会话相关状态
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
|
||||
|
||||
// 消息相关状态
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
|
||||
// UI 状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true });
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const sidebarBg = useColorModeValue('white', 'gray.800');
|
||||
const chatBg = useColorModeValue('white', 'gray.800');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const agentBubbleBg = useColorModeValue('white', 'gray.700');
|
||||
|
||||
// ==================== 会话管理函数 ====================
|
||||
|
||||
// 加载会话列表
|
||||
const loadSessions = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setIsLoadingSessions(true);
|
||||
try {
|
||||
const response = await axios.get('/mcp/agent/sessions', {
|
||||
params: { user_id: user.id, limit: 50 },
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setSessions(response.data.data);
|
||||
logger.info('会话列表加载成功', response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载会话列表失败', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载会话列表',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载会话历史
|
||||
const loadSessionHistory = async (sessionId) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
|
||||
params: { limit: 100 },
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const history = response.data.data;
|
||||
|
||||
// 将历史记录转换为消息格式
|
||||
const formattedMessages = history.map((msg, idx) => ({
|
||||
id: `${sessionId}-${idx}`,
|
||||
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
|
||||
content: msg.message,
|
||||
plan: msg.plan ? JSON.parse(msg.plan) : null,
|
||||
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
setMessages(formattedMessages);
|
||||
logger.info('会话历史加载成功', formattedMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载会话历史失败', error);
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载会话历史',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新会话
|
||||
const createNewSession = () => {
|
||||
setCurrentSessionId(null);
|
||||
setMessages([
|
||||
{
|
||||
id: Date.now(),
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: `你好${user?.nickname || ''}!我是价小前,北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 切换会话
|
||||
const switchSession = (sessionId) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
loadSessionHistory(sessionId);
|
||||
};
|
||||
|
||||
// 删除会话(需要后端API支持)
|
||||
const deleteSession = async (sessionId) => {
|
||||
// TODO: 实现删除会话的后端API
|
||||
toast({
|
||||
title: '删除会话',
|
||||
description: '此功能尚未实现',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 消息处理函数 ====================
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 添加消息
|
||||
const addMessage = (message) => {
|
||||
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim() || isProcessing) return;
|
||||
|
||||
// 权限检查
|
||||
if (user?.id !== 'max') {
|
||||
toast({
|
||||
title: '权限不足',
|
||||
description: '「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
type: MessageTypes.USER,
|
||||
content: inputValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addMessage(userMessage);
|
||||
const userInput = inputValue;
|
||||
setInputValue('');
|
||||
setIsProcessing(true);
|
||||
setCurrentProgress(0);
|
||||
|
||||
let currentPlan = null;
|
||||
let stepResults = [];
|
||||
|
||||
try {
|
||||
// 1. 显示思考状态
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_THINKING,
|
||||
content: '正在分析你的问题...',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setCurrentProgress(10);
|
||||
|
||||
// 2. 调用后端API(非流式)
|
||||
const response = await axios.post('/mcp/agent/chat', {
|
||||
message: userInput,
|
||||
conversation_history: messages
|
||||
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||
.map((m) => ({
|
||||
isUser: m.type === MessageTypes.USER,
|
||||
content: m.content,
|
||||
})),
|
||||
user_id: user?.id || 'anonymous',
|
||||
user_nickname: user?.nickname || '匿名用户',
|
||||
user_avatar: user?.avatar || '',
|
||||
session_id: currentSessionId,
|
||||
});
|
||||
|
||||
// 移除思考消息
|
||||
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
|
||||
|
||||
if (response.data.success) {
|
||||
const data = response.data;
|
||||
|
||||
// 更新 session_id(如果是新会话)
|
||||
if (data.session_id && !currentSessionId) {
|
||||
setCurrentSessionId(data.session_id);
|
||||
}
|
||||
|
||||
// 显示执行计划
|
||||
if (data.plan) {
|
||||
currentPlan = data.plan;
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_PLAN,
|
||||
content: '已制定执行计划',
|
||||
plan: data.plan,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(30);
|
||||
}
|
||||
|
||||
// 显示执行步骤
|
||||
if (data.steps && data.steps.length > 0) {
|
||||
stepResults = data.steps;
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_EXECUTING,
|
||||
content: '正在执行步骤...',
|
||||
plan: currentPlan,
|
||||
stepResults: stepResults,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setCurrentProgress(70);
|
||||
}
|
||||
|
||||
// 移除执行中消息
|
||||
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
|
||||
|
||||
// 显示最终结果
|
||||
addMessage({
|
||||
type: MessageTypes.AGENT_RESPONSE,
|
||||
content: data.final_answer || data.message || '处理完成',
|
||||
plan: currentPlan,
|
||||
stepResults: stepResults,
|
||||
metadata: data.metadata,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setCurrentProgress(100);
|
||||
|
||||
// 重新加载会话列表
|
||||
loadSessions();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Agent chat error', error);
|
||||
|
||||
// 移除思考/执行中消息
|
||||
setMessages((prev) =>
|
||||
prev.filter(
|
||||
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
|
||||
)
|
||||
);
|
||||
|
||||
const errorMessage = error.response?.data?.error || error.message || '处理失败';
|
||||
|
||||
addMessage({
|
||||
type: MessageTypes.ERROR,
|
||||
content: `处理失败:${errorMessage}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '处理失败',
|
||||
description: errorMessage,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setCurrentProgress(0);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
createNewSession();
|
||||
};
|
||||
|
||||
// 导出对话
|
||||
const handleExportChat = () => {
|
||||
const chatText = messages
|
||||
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
|
||||
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
const blob = new Blob([chatText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadSessions();
|
||||
createNewSession();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
// 快捷问题
|
||||
const quickQuestions = [
|
||||
'全面分析贵州茅台这只股票',
|
||||
'今日涨停股票有哪些亮点',
|
||||
'新能源概念板块的投资机会',
|
||||
'半导体行业最新动态',
|
||||
];
|
||||
|
||||
// 筛选会话
|
||||
const filteredSessions = sessions.filter(
|
||||
(session) =>
|
||||
!searchQuery ||
|
||||
session.last_message?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex h="calc(100vh - 80px)" bg={bgColor}>
|
||||
{/* 左侧会话列表 */}
|
||||
<Collapse in={isSidebarOpen} animateOpacity>
|
||||
<Box
|
||||
w="300px"
|
||||
bg={sidebarBg}
|
||||
borderRight="1px"
|
||||
borderColor={borderColor}
|
||||
h="100%"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
{/* 侧边栏头部 */}
|
||||
<Box p={4} borderBottom="1px" borderColor={borderColor}>
|
||||
<Button
|
||||
leftIcon={<FiPlus />}
|
||||
colorScheme="blue"
|
||||
w="100%"
|
||||
onClick={createNewSession}
|
||||
size="sm"
|
||||
>
|
||||
新建对话
|
||||
</Button>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<InputGroup mt={3} size="sm">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<FiSearch color="gray.300" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索对话..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Box>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<VStack
|
||||
flex="1"
|
||||
overflowY="auto"
|
||||
spacing={0}
|
||||
align="stretch"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { width: '6px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#CBD5E0',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isLoadingSessions ? (
|
||||
<Flex justify="center" align="center" h="200px">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<Flex justify="center" align="center" h="200px" direction="column">
|
||||
<FiMessageSquare size={32} color="gray" />
|
||||
<Text mt={2} fontSize="sm" color="gray.500">
|
||||
{searchQuery ? '没有找到匹配的对话' : '暂无对话记录'}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
filteredSessions.map((session) => (
|
||||
<Box
|
||||
key={session.session_id}
|
||||
p={3}
|
||||
cursor="pointer"
|
||||
bg={currentSessionId === session.session_id ? activeBg : 'transparent'}
|
||||
_hover={{ bg: hoverBg }}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
onClick={() => switchSession(session.session_id)}
|
||||
>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex="1">
|
||||
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
|
||||
{session.last_message || '新对话'}
|
||||
</Text>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.500">
|
||||
<FiClock size={12} />
|
||||
<Text>
|
||||
{new Date(session.last_timestamp).toLocaleDateString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
<Badge colorScheme="blue" fontSize="xx-small">
|
||||
{session.message_count} 条
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<FiMoreVertical />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<FiTrash2 />}
|
||||
color="red.500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(session.session_id);
|
||||
}}
|
||||
>
|
||||
删除对话
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<Box p={4} borderTop="1px" borderColor={borderColor}>
|
||||
<HStack spacing={3}>
|
||||
<Avatar size="sm" name={user?.nickname} src={user?.avatar} />
|
||||
<VStack align="start" spacing={0} flex="1">
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{user?.nickname || '未登录'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{user?.id || 'anonymous'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* 主聊天区域 */}
|
||||
<Flex flex="1" direction="column" h="100%">
|
||||
{/* 聊天头部 */}
|
||||
<Box bg={chatBg} borderBottom="1px" borderColor={borderColor} px={6} py={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={4}>
|
||||
<IconButton
|
||||
icon={<FiMessageSquare />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="切换侧边栏"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
<Avatar size="md" bg="blue.500" icon={<FiCpu fontSize="1.5rem" />} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Heading size="md">价小前投研</Heading>
|
||||
<HStack>
|
||||
<Badge colorScheme="green" fontSize="xs">
|
||||
<HStack spacing={1}>
|
||||
<FiZap size={10} />
|
||||
<span>智能分析</span>
|
||||
</HStack>
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
多步骤深度研究
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiRefreshCw />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="清空对话"
|
||||
onClick={handleClearChat}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiDownload />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="导出对话"
|
||||
onClick={handleExportChat}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isProcessing && (
|
||||
<Progress
|
||||
value={currentProgress}
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
mt={3}
|
||||
borderRadius="full"
|
||||
isAnimated
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<Box
|
||||
flex="1"
|
||||
overflowY="auto"
|
||||
px={6}
|
||||
py={4}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { width: '8px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#CBD5E0',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{messages.map((message) => (
|
||||
<Fade in key={message.id}>
|
||||
<MessageRenderer message={message} userAvatar={user?.avatar} />
|
||||
</Fade>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 快捷问题 */}
|
||||
{messages.length <= 2 && !isProcessing && (
|
||||
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
💡 试试这些问题:
|
||||
</Text>
|
||||
<Flex wrap="wrap" gap={2}>
|
||||
{quickQuestions.map((question, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
fontSize="xs"
|
||||
onClick={() => {
|
||||
setInputValue(question);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
|
||||
<Flex>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="输入你的问题,我会进行深度分析..."
|
||||
bg={inputBg}
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
|
||||
mr={2}
|
||||
disabled={isProcessing}
|
||||
size="lg"
|
||||
/>
|
||||
<IconButton
|
||||
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
|
||||
colorScheme="blue"
|
||||
aria-label="发送"
|
||||
onClick={handleSendMessage}
|
||||
isLoading={isProcessing}
|
||||
disabled={!inputValue.trim() || isProcessing}
|
||||
size="lg"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息渲染器
|
||||
*/
|
||||
const MessageRenderer = ({ message, userAvatar }) => {
|
||||
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const agentBubbleBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
switch (message.type) {
|
||||
case MessageTypes.USER:
|
||||
return (
|
||||
<Flex justify="flex-end">
|
||||
<HStack align="flex-start" maxW="75%">
|
||||
<Box
|
||||
bg={userBubbleBg}
|
||||
color="white"
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
>
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
</Box>
|
||||
<Avatar size="sm" src={userAvatar} icon={<FiUser fontSize="1rem" />} />
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_THINKING:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="75%">
|
||||
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<Box
|
||||
bg={agentBubbleBg}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<HStack>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="purple.600">
|
||||
{message.content}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_PLAN:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="85%">
|
||||
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<VStack align="stretch" flex="1">
|
||||
<PlanCard plan={message.plan} stepResults={[]} />
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_EXECUTING:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="85%">
|
||||
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<VStack align="stretch" flex="1" spacing={3}>
|
||||
<PlanCard plan={message.plan} stepResults={message.stepResults} />
|
||||
{message.stepResults?.map((result, idx) => (
|
||||
<StepResultCard key={idx} stepResult={result} />
|
||||
))}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.AGENT_RESPONSE:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="85%">
|
||||
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<VStack align="stretch" flex="1" spacing={3}>
|
||||
{/* 最终总结 */}
|
||||
<Box
|
||||
bg={agentBubbleBg}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="md"
|
||||
>
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
{/* 元数据 */}
|
||||
{message.metadata && (
|
||||
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
|
||||
<Text>总步骤: {message.metadata.total_steps}</Text>
|
||||
<Text>✓ {message.metadata.successful_steps}</Text>
|
||||
{message.metadata.failed_steps > 0 && (
|
||||
<Text>✗ {message.metadata.failed_steps}</Text>
|
||||
)}
|
||||
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 执行详情(可选) */}
|
||||
{message.plan && message.stepResults && message.stepResults.length > 0 && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Divider />
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.500">
|
||||
📊 执行详情(点击展开查看)
|
||||
</Text>
|
||||
{message.stepResults.map((result, idx) => (
|
||||
<StepResultCard key={idx} stepResult={result} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case MessageTypes.ERROR:
|
||||
return (
|
||||
<Flex justify="flex-start">
|
||||
<HStack align="flex-start" maxW="75%">
|
||||
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
|
||||
<Box
|
||||
bg="red.50"
|
||||
color="red.700"
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="red.200"
|
||||
>
|
||||
<Text fontSize="sm">{message.content}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default AgentChatV3;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsCard.js
|
||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
@@ -16,21 +16,41 @@ import {
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
useToast,
|
||||
useDisclosure,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
import { TimeIcon, BellIcon } from '@chakra-ui/icons';
|
||||
import { useNotification } from '../../../contexts/NotificationContext';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
|
||||
import PaginationControl from './DynamicNewsCard/PaginationControl';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
|
||||
import {
|
||||
fetchDynamicNews,
|
||||
toggleEventFollow,
|
||||
selectEventFollowStatus,
|
||||
selectVerticalEventsWithLoading,
|
||||
selectFourRowEventsWithLoading
|
||||
} from '../../../store/slices/communityDataSlice';
|
||||
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
|
||||
import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
|
||||
|
||||
// 🔍 调试:渲染计数器
|
||||
let dynamicNewsCardRenderCount = 0;
|
||||
|
||||
/**
|
||||
* 实时要闻·动态追踪 - 事件展示卡片组件
|
||||
* @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入)
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {number} total - 服务端总数量
|
||||
* @param {number} cachedCount - 已缓存数量
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
@@ -38,13 +58,10 @@ import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '..
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Object} trackingFunctions - PostHog 追踪函数集合
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
allCachedEvents = [],
|
||||
loading,
|
||||
total = 0,
|
||||
cachedCount = 0,
|
||||
filters = {},
|
||||
popularKeywords = [],
|
||||
lastUpdateTime,
|
||||
@@ -52,6 +69,7 @@ const DynamicNewsCard = forwardRef(({
|
||||
onSearchFocus,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
trackingFunctions = {},
|
||||
...rest
|
||||
}, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,305 +77,450 @@ const DynamicNewsCard = forwardRef(({
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 通知权限相关
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
|
||||
// 固定模式状态
|
||||
const [isFixedMode, setIsFixedMode] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const cardHeaderRef = useRef(null);
|
||||
const cardBodyRef = useRef(null);
|
||||
|
||||
// 导航栏和页脚固定高度
|
||||
const NAVBAR_HEIGHT = 64; // 主导航高度
|
||||
const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度
|
||||
const FOOTER_HEIGHT = 80; // 页脚高度(优化后)
|
||||
const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
|
||||
// 本地状态:模式(先初始化,后面会被 usePagination 更新)
|
||||
const [currentMode, setCurrentMode] = useState('vertical');
|
||||
|
||||
// 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined)
|
||||
const verticalData = useSelector(selectVerticalEventsWithLoading) || {};
|
||||
const fourRowData = useSelector(selectFourRowEventsWithLoading) || {};
|
||||
|
||||
// 🔍 调试:从 Redux 读取数据
|
||||
console.log('%c[DynamicNewsCard] 从 Redux 读取数据', 'color: #3B82F6; font-weight: bold;', {
|
||||
currentMode,
|
||||
'verticalData.data type': typeof verticalData.data,
|
||||
'verticalData.data keys': verticalData.data ? Object.keys(verticalData.data) : [],
|
||||
'verticalData.total': verticalData.total,
|
||||
'verticalData.cachedPageCount': verticalData.cachedPageCount,
|
||||
'verticalData.loading': verticalData.loading,
|
||||
'fourRowData.data?.length': fourRowData.data?.length || 0,
|
||||
'fourRowData.total': fourRowData.total,
|
||||
});
|
||||
|
||||
// 根据模式选择数据源
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式:data 是数组 [...]
|
||||
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
|
||||
const {
|
||||
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
|
||||
loading = false,
|
||||
error = null,
|
||||
pagination, // 分页元数据
|
||||
total = 0, // 向后兼容
|
||||
cachedCount = 0,
|
||||
cachedPageCount = 0
|
||||
} = modeData;
|
||||
|
||||
// 传递给 usePagination 的数据
|
||||
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
|
||||
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
|
||||
|
||||
// 🔍 调试:选择的数据源
|
||||
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
|
||||
mode: currentMode,
|
||||
'allCachedEventsByPage': allCachedEventsByPage ? Object.keys(allCachedEventsByPage) : 'undefined',
|
||||
'allCachedEvents?.length': allCachedEvents?.length,
|
||||
total,
|
||||
cachedCount,
|
||||
cachedPageCount,
|
||||
loading,
|
||||
error
|
||||
});
|
||||
|
||||
// 🔍 调试:记录每次渲染
|
||||
dynamicNewsCardRenderCount++;
|
||||
console.log(`%c🔍 [DynamicNewsCard] 渲染 #${dynamicNewsCardRenderCount} - mode=${currentMode}, allCachedEvents.length=${allCachedEvents?.length || 0}, total=${total}`, 'color: #FF9800; font-weight: bold; font-size: 14px;');
|
||||
|
||||
// 关注按钮点击处理
|
||||
const handleToggleFollow = useCallback((eventId) => {
|
||||
dispatch(toggleEventFollow(eventId));
|
||||
}, [dispatch]);
|
||||
|
||||
// 通知开关处理
|
||||
const handleNotificationToggle = useCallback(async () => {
|
||||
if (browserPermission === 'granted') {
|
||||
// 已授权,提示用户去浏览器设置中关闭
|
||||
toast({
|
||||
title: '已开启通知',
|
||||
description: '要关闭通知,请在浏览器地址栏左侧点击锁图标,找到"通知"选项进行设置',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
// 未授权,请求权限
|
||||
await requestBrowserPermission();
|
||||
}
|
||||
}, [browserPermission, requestBrowserPermission, toast]);
|
||||
|
||||
// 本地状态
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
|
||||
const [currentPage, setCurrentPage] = useState(1); // 当前页码
|
||||
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
|
||||
|
||||
// 根据模式决定每页显示数量
|
||||
const pageSize = mode === 'carousel' ? 5 : 10;
|
||||
// 弹窗状态(用于四排模式)
|
||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||
const [modalEvent, setModalEvent] = useState(null);
|
||||
|
||||
// 计算总页数(基于服务端总数据量)
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
// 初始化标记 - 确保初始加载只执行一次
|
||||
const hasInitialized = useRef(false);
|
||||
// 追踪是否已自动选中过首个事件
|
||||
const hasAutoSelectedFirstEvent = useRef(false);
|
||||
// 追踪筛选条件 useEffect 是否是第一次渲染(避免初始加载时重复请求)
|
||||
const isFirstRenderForFilters = useRef(true);
|
||||
|
||||
// 检查是否还有更多数据
|
||||
const hasMore = cachedCount < total;
|
||||
// 使用分页 Hook
|
||||
const {
|
||||
currentPage,
|
||||
mode,
|
||||
loadingPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
currentPageEvents,
|
||||
displayEvents, // 当前显示的事件列表
|
||||
handlePageChange,
|
||||
handleModeToggle,
|
||||
loadNextPage, // 加载下一页
|
||||
loadPrevPage // 加载上一页
|
||||
} = usePagination({
|
||||
allCachedEventsByPage, // 纵向模式:页码映射
|
||||
allCachedEvents, // 平铺模式:数组
|
||||
pagination, // 分页元数据对象
|
||||
total, // 向后兼容
|
||||
cachedCount,
|
||||
dispatch,
|
||||
toast,
|
||||
filters, // 传递筛选条件
|
||||
initialMode: currentMode // 传递当前显示模式
|
||||
});
|
||||
|
||||
// 从缓存中切片获取当前页数据(过滤 null 占位符)
|
||||
const currentPageEvents = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
|
||||
}, [allCachedEvents, currentPage, pageSize]);
|
||||
|
||||
// 翻页处理(智能预加载)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
// 🔍 诊断日志 - 记录翻页开始状态
|
||||
console.log('[handlePageChange] 开始翻页', {
|
||||
currentPage,
|
||||
newPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
total,
|
||||
allCachedEventsLength: allCachedEvents.length,
|
||||
cachedCount
|
||||
});
|
||||
|
||||
// 0. 首先检查目标页数据是否已完整缓存
|
||||
const targetPageStartIndex = (newPage - 1) * pageSize;
|
||||
const targetPageEndIndex = targetPageStartIndex + pageSize;
|
||||
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
|
||||
const validTargetData = targetPageData.filter(e => e !== null);
|
||||
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
|
||||
const isTargetPageCached = validTargetData.length >= expectedCount;
|
||||
|
||||
console.log('[handlePageChange] 目标页缓存检查', {
|
||||
newPage,
|
||||
targetPageStartIndex,
|
||||
targetPageEndIndex,
|
||||
targetPageDataLength: targetPageData.length,
|
||||
validTargetDataLength: validTargetData.length,
|
||||
expectedCount,
|
||||
isTargetPageCached
|
||||
});
|
||||
|
||||
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
|
||||
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
|
||||
|
||||
// 2. 计算预加载范围
|
||||
let preloadRange;
|
||||
if (isSequentialNavigation) {
|
||||
// 连续翻页:前后各2页(共5页)
|
||||
const start = Math.max(1, newPage - 2);
|
||||
const end = Math.min(totalPages, newPage + 2);
|
||||
preloadRange = Array.from(
|
||||
{ length: end - start + 1 },
|
||||
(_, i) => start + i
|
||||
);
|
||||
} else {
|
||||
// 跳转翻页:只加载当前页
|
||||
preloadRange = [newPage];
|
||||
}
|
||||
|
||||
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
|
||||
const missingPages = preloadRange.filter(page => {
|
||||
const pageStartIndex = (page - 1) * pageSize;
|
||||
const pageEndIndex = pageStartIndex + pageSize;
|
||||
|
||||
// 如果该页超出数组范围,说明未缓存
|
||||
if (pageEndIndex > allCachedEvents.length) {
|
||||
console.log(`[missingPages] 页面${page}超出数组范围`, {
|
||||
pageStartIndex,
|
||||
pageEndIndex,
|
||||
allCachedEventsLength: allCachedEvents.length
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查该页的数据是否包含 null 占位符或数据不足
|
||||
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
|
||||
const validData = pageData.filter(e => e !== null);
|
||||
const expectedCount = Math.min(pageSize, total - pageStartIndex);
|
||||
const hasNullOrIncomplete = validData.length < expectedCount;
|
||||
|
||||
console.log(`[missingPages] 页面${page}检查`, {
|
||||
pageStartIndex,
|
||||
pageEndIndex,
|
||||
pageDataLength: pageData.length,
|
||||
validDataLength: validData.length,
|
||||
expectedCount,
|
||||
hasNullOrIncomplete
|
||||
});
|
||||
|
||||
return hasNullOrIncomplete;
|
||||
});
|
||||
|
||||
console.log('[handlePageChange] 缺失页面检测完成', {
|
||||
preloadRange,
|
||||
missingPages,
|
||||
missingPagesCount: missingPages.length
|
||||
});
|
||||
|
||||
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
|
||||
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
|
||||
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
|
||||
currentPage,
|
||||
newPage,
|
||||
缺失页面: missingPages,
|
||||
目标页已缓存: true
|
||||
});
|
||||
|
||||
// 立即切换页码(用户无感知延迟)
|
||||
setCurrentPage(newPage);
|
||||
|
||||
// 在后台静默预加载其他缺失页面(拆分为单页请求)
|
||||
try {
|
||||
console.log('[DynamicNewsCard] 开始后台预加载', {
|
||||
缺失页面: missingPages,
|
||||
每页数量: pageSize
|
||||
});
|
||||
|
||||
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
||||
for (const page of missingPages) {
|
||||
await dispatch(fetchDynamicNews({
|
||||
page: page,
|
||||
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
||||
pageSize: pageSize,
|
||||
clearCache: false
|
||||
})).unwrap();
|
||||
|
||||
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
|
||||
}
|
||||
|
||||
console.log('[DynamicNewsCard] 后台预加载全部完成', {
|
||||
预加载页面: missingPages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DynamicNewsCard] 后台预加载失败', error);
|
||||
// 静默失败,不影响用户体验
|
||||
}
|
||||
|
||||
return; // 提前返回,不执行下面的加载逻辑
|
||||
}
|
||||
|
||||
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
|
||||
if (missingPages.length > 0 && hasMore) {
|
||||
console.log('[DynamicNewsCard] 目标页未缓存,显示loading', {
|
||||
currentPage,
|
||||
newPage,
|
||||
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
|
||||
预加载范围: preloadRange,
|
||||
缺失页面: missingPages,
|
||||
每页数量: pageSize,
|
||||
目标页已缓存: false
|
||||
});
|
||||
|
||||
try {
|
||||
// 设置加载状态(显示"正在加载第X页...")
|
||||
setLoadingPage(newPage);
|
||||
|
||||
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
|
||||
for (const page of missingPages) {
|
||||
console.log(`[DynamicNewsCard] 开始加载第 ${page} 页`);
|
||||
|
||||
await dispatch(fetchDynamicNews({
|
||||
page: page,
|
||||
per_page: pageSize, // 固定值(5或10),不使用动态计算
|
||||
pageSize: pageSize, // 传递原始 pageSize,用于正确计算索引
|
||||
clearCache: false
|
||||
})).unwrap();
|
||||
|
||||
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
|
||||
}
|
||||
|
||||
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
|
||||
缺失页面: missingPages
|
||||
});
|
||||
|
||||
// 数据加载成功后才更新当前页码
|
||||
setCurrentPage(newPage);
|
||||
} catch (error) {
|
||||
console.error('[DynamicNewsCard] 翻页加载失败', error);
|
||||
|
||||
// 显示错误提示
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
// 加载失败时不更新页码,保持在当前页
|
||||
} finally {
|
||||
// 清除加载状态
|
||||
setLoadingPage(null);
|
||||
}
|
||||
} else if (missingPages.length === 0) {
|
||||
// 只有在确实不需要加载时才直接切换
|
||||
console.log('[handlePageChange] 无需加载,直接切换', {
|
||||
currentPage,
|
||||
newPage,
|
||||
preloadRange,
|
||||
missingPages,
|
||||
reason: '所有页面均已缓存'
|
||||
});
|
||||
setCurrentPage(newPage);
|
||||
} else {
|
||||
// 理论上不应该到这里(missingPages.length > 0 但 hasMore=false)
|
||||
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
|
||||
missingPages,
|
||||
hasMore,
|
||||
currentPage,
|
||||
newPage,
|
||||
total,
|
||||
cachedCount
|
||||
});
|
||||
|
||||
// 尝试切换页码,但可能会显示空数据
|
||||
setCurrentPage(newPage);
|
||||
// 同步 mode 到 currentMode
|
||||
useEffect(() => {
|
||||
setCurrentMode(mode);
|
||||
}, [mode]);
|
||||
|
||||
// 监听 error 状态,显示空数据提示
|
||||
useEffect(() => {
|
||||
if (error && error.includes('暂无更多数据')) {
|
||||
toast({
|
||||
title: '数据不完整',
|
||||
description: `第 ${newPage} 页数据可能不完整`,
|
||||
status: 'warning',
|
||||
title: '提示',
|
||||
description: error,
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
|
||||
}, [error, toast]);
|
||||
|
||||
// 模式切换处理
|
||||
const handleModeToggle = useCallback((newMode) => {
|
||||
if (newMode === mode) return;
|
||||
// 四排模式的事件点击处理(打开弹窗)
|
||||
const handleFourRowEventClick = useCallback((event) => {
|
||||
console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
|
||||
|
||||
setMode(newMode);
|
||||
setCurrentPage(1);
|
||||
// 🎯 追踪事件详情打开
|
||||
if (trackingFunctions.trackNewsDetailOpened) {
|
||||
trackingFunctions.trackNewsDetailOpened({
|
||||
eventId: event.id,
|
||||
eventTitle: event.title,
|
||||
importance: event.importance,
|
||||
source: 'four_row_mode',
|
||||
displayMode: 'modal',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const newPageSize = newMode === 'carousel' ? 5 : 10;
|
||||
setModalEvent(event);
|
||||
onModalOpen();
|
||||
}, [onModalOpen, trackingFunctions]);
|
||||
|
||||
// 检查第1页的数据是否完整(排除 null)
|
||||
const firstPageData = allCachedEvents.slice(0, newPageSize);
|
||||
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
|
||||
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
|
||||
// 初始加载 - 只在组件首次挂载且对应模式数据为空时执行
|
||||
useEffect(() => {
|
||||
// 添加防抖:如果已经初始化,不再执行
|
||||
if (hasInitialized.current) return;
|
||||
|
||||
if (needsRefetch) {
|
||||
// 第1页数据不完整,清空缓存重新请求
|
||||
const isDataEmpty = currentMode === 'vertical'
|
||||
? Object.keys(allCachedEventsByPage || {}).length === 0
|
||||
: (allCachedEvents?.length || 0) === 0;
|
||||
|
||||
if (isDataEmpty) {
|
||||
hasInitialized.current = true;
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: newPageSize,
|
||||
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
|
||||
clearCache: true
|
||||
mode: mode, // 传递当前模式
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize, // 传递 pageSize 确保索引计算一致
|
||||
clearCache: true,
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
}));
|
||||
}
|
||||
// 如果第1页数据完整,不发起请求,直接切换
|
||||
}, [mode, allCachedEvents, total, dispatch]);
|
||||
}, [dispatch, currentMode, mode, pageSize]); // 移除 allCachedEventsByPage, allCachedEvents 依赖,避免数据更新触发重复请求
|
||||
|
||||
// 初始加载
|
||||
// 监听筛选条件变化 - 清空缓存并重新请求数据
|
||||
useEffect(() => {
|
||||
if (allCachedEvents.length === 0) {
|
||||
// 跳过初始加载(由上面的 useEffect 处理)
|
||||
if (!hasInitialized.current) return;
|
||||
|
||||
// 跳过第一次渲染(避免与初始加载 useEffect 重复)
|
||||
if (isFirstRenderForFilters.current) {
|
||||
isFirstRenderForFilters.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', filters);
|
||||
|
||||
// 筛选条件改变时,清空对应模式的缓存并从第1页开始加载
|
||||
dispatch(fetchDynamicNews({
|
||||
mode: mode, // 传递当前模式
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize,
|
||||
clearCache: true, // 清空缓存
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
}));
|
||||
}, [
|
||||
filters.sort,
|
||||
filters.importance,
|
||||
filters.q,
|
||||
filters.start_date, // 时间筛选参数:开始时间
|
||||
filters.end_date, // 时间筛选参数:结束时间
|
||||
filters.recent_days, // 时间筛选参数:近N天
|
||||
filters.industry_code,
|
||||
filters._forceRefresh, // 强制刷新标志(用于重置按钮)
|
||||
mode, // 添加 mode 到依赖
|
||||
pageSize, // 添加 pageSize 到依赖
|
||||
dispatch
|
||||
]); // 只监听筛选参数的变化,不监听 page
|
||||
|
||||
// 监听模式切换 - 如果新模式数据为空,请求数据
|
||||
useEffect(() => {
|
||||
const isDataEmpty = currentMode === 'vertical'
|
||||
? Object.keys(allCachedEventsByPage || {}).length === 0
|
||||
: (allCachedEvents?.length || 0) === 0;
|
||||
|
||||
if (hasInitialized.current && isDataEmpty) {
|
||||
console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;');
|
||||
|
||||
// 🔧 根据 mode 直接计算 per_page,避免使用可能过时的 pageSize prop
|
||||
const modePageSize = mode === DISPLAY_MODES.FOUR_ROW
|
||||
? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30
|
||||
: PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
|
||||
|
||||
console.log(`%c 计算的 per_page: ${modePageSize} (mode: ${mode})`, 'color: #8B5CF6;');
|
||||
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: 5,
|
||||
pageSize: 5, // 传递 pageSize 确保索引计算一致
|
||||
clearCache: true
|
||||
mode: mode,
|
||||
per_page: modePageSize, // 使用计算的值,不是 pageSize prop
|
||||
pageSize: modePageSize,
|
||||
clearCache: true,
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
}));
|
||||
}
|
||||
}, [dispatch, allCachedEvents.length]);
|
||||
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖
|
||||
|
||||
// 默认选中第一个事件
|
||||
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
|
||||
useEffect(() => {
|
||||
if (currentPageEvents.length > 0 && !selectedEvent) {
|
||||
setSelectedEvent(currentPageEvents[0]);
|
||||
if (currentPageEvents.length > 0) {
|
||||
// 情况1: 首次加载 - 自动选中第一个事件并触发详情加载
|
||||
if (!hasAutoSelectedFirstEvent.current && !selectedEvent) {
|
||||
console.log('%c🎯 [首次加载] 自动选中第一个事件', 'color: #10B981; font-weight: bold;');
|
||||
hasAutoSelectedFirstEvent.current = true;
|
||||
setSelectedEvent(currentPageEvents[0]);
|
||||
|
||||
// 🎯 追踪事件点击(首次自动选中)
|
||||
if (trackingFunctions.trackNewsArticleClicked) {
|
||||
trackingFunctions.trackNewsArticleClicked({
|
||||
eventId: currentPageEvents[0].id,
|
||||
eventTitle: currentPageEvents[0].title,
|
||||
importance: currentPageEvents[0].importance,
|
||||
source: 'auto_select_first',
|
||||
displayMode: mode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 情况2: 翻页 - 如果选中的事件不在当前页,根据模式决定处理方式
|
||||
const selectedEventInCurrentPage = currentPageEvents.find(
|
||||
e => e.id === selectedEvent?.id
|
||||
);
|
||||
}
|
||||
}, [currentPageEvents, selectedEvent]);
|
||||
}, [currentPageEvents, selectedEvent?.id, mode, trackingFunctions]);
|
||||
|
||||
// 组件卸载时清理选中状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 页码切换时滚动到顶部
|
||||
const handlePageChangeWithScroll = useCallback((page) => {
|
||||
// 先切换页码
|
||||
handlePageChange(page);
|
||||
|
||||
// 延迟一帧,确保DOM更新完成后再滚动
|
||||
requestAnimationFrame(() => {
|
||||
// 查找所有标记为滚动容器的元素
|
||||
const containers = document.querySelectorAll('[data-scroll-container]');
|
||||
containers.forEach(container => {
|
||||
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
console.log('📜 页码切换,滚动到顶部', { containersFound: containers.length });
|
||||
});
|
||||
}, [handlePageChange]);
|
||||
|
||||
// 测量 CardHeader 高度
|
||||
useEffect(() => {
|
||||
const cardHeaderElement = cardHeaderRef.current;
|
||||
if (!cardHeaderElement) return;
|
||||
|
||||
// 测量并更新高度
|
||||
const updateHeaderHeight = () => {
|
||||
const height = cardHeaderElement.offsetHeight;
|
||||
setHeaderHeight(height);
|
||||
};
|
||||
|
||||
// 初始测量
|
||||
updateHeaderHeight();
|
||||
|
||||
// 监听窗口大小变化(响应式调整)
|
||||
window.addEventListener('resize', updateHeaderHeight);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateHeaderHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听 CardHeader 是否到达触发点,动态切换固定模式
|
||||
useEffect(() => {
|
||||
const cardHeaderElement = cardHeaderRef.current;
|
||||
const cardBodyElement = cardBodyRef.current;
|
||||
if (!cardHeaderElement || !cardBodyElement) return;
|
||||
|
||||
let ticking = false;
|
||||
const TRIGGER_OFFSET = 150; // 提前 150px 触发(进入固定模式)
|
||||
const EXIT_OFFSET = 200; // 提前 200px 退出(退出比进入更容易)
|
||||
const EXIT_THRESHOLD = 30; // 接近顶部 30px 内即可退出
|
||||
|
||||
// 外部滚动监听:触发固定模式
|
||||
const handleExternalScroll = () => {
|
||||
// 只在非固定模式下监听外部滚动
|
||||
if (!isFixedMode && !ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
// 获取 CardHeader 相对视口的位置
|
||||
const rect = cardHeaderElement.getBoundingClientRect();
|
||||
const elementTop = rect.top;
|
||||
|
||||
// 计算触发点:总导航高度 + 150px 偏移量
|
||||
const triggerPoint = TOTAL_NAV_HEIGHT + TRIGGER_OFFSET;
|
||||
|
||||
// 向上滑动:元素顶部到达触发点 → 激活固定模式
|
||||
if (elementTop <= triggerPoint) {
|
||||
setIsFixedMode(true);
|
||||
console.log('🔒 切换为固定全屏模式', {
|
||||
elementTop,
|
||||
triggerPoint,
|
||||
offset: TRIGGER_OFFSET
|
||||
});
|
||||
}
|
||||
|
||||
ticking = false;
|
||||
});
|
||||
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 内部滚动监听:退出固定模式
|
||||
const handleWheel = (e) => {
|
||||
// 只在固定模式下监听内部滚动
|
||||
if (!isFixedMode) return;
|
||||
|
||||
// 检测向上滚动(deltaY < 0)
|
||||
if (e.deltaY < 0) {
|
||||
window.requestAnimationFrame(() => {
|
||||
// 🎯 检查 1:CardHeader 位置(主要条件)
|
||||
const rect = cardHeaderElement.getBoundingClientRect();
|
||||
const elementTop = rect.top;
|
||||
const exitPoint = TOTAL_NAV_HEIGHT + EXIT_OFFSET;
|
||||
|
||||
// 🎯 检查 2:左侧事件列表滚动位置(辅助条件)
|
||||
const eventListContainers = cardBodyElement.querySelectorAll('[data-event-list-container]');
|
||||
const allNearTop = eventListContainers.length === 0 ||
|
||||
Array.from(eventListContainers).every(
|
||||
container => container.scrollTop <= EXIT_THRESHOLD
|
||||
);
|
||||
|
||||
// 🎯 退出条件:CardHeader 超过退出点 OR 左侧列表接近顶部
|
||||
if (elementTop > exitPoint || allNearTop) {
|
||||
setIsFixedMode(false);
|
||||
console.log('🔓 恢复正常文档流模式', {
|
||||
elementTop,
|
||||
exitPoint,
|
||||
listNearTop: allNearTop,
|
||||
exitThreshold: EXIT_THRESHOLD,
|
||||
reason: elementTop > exitPoint ? 'CardHeader位置' : '左侧列表滚动'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听外部滚动
|
||||
window.addEventListener('scroll', handleExternalScroll, { passive: true });
|
||||
|
||||
// 监听内部滚轮事件(固定模式下)
|
||||
if (isFixedMode) {
|
||||
cardBodyElement.addEventListener('wheel', handleWheel, { passive: true });
|
||||
}
|
||||
|
||||
// 初次检查位置
|
||||
handleExternalScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleExternalScroll);
|
||||
cardBodyElement.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, [isFixedMode]);
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
<Card
|
||||
ref={ref}
|
||||
{...rest}
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
mb={4}
|
||||
>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<CardHeader
|
||||
ref={cardHeaderRef}
|
||||
position={isFixedMode ? 'fixed' : 'relative'}
|
||||
top={isFixedMode ? `${TOTAL_NAV_HEIGHT}px` : 'auto'}
|
||||
left={isFixedMode ? 0 : 'auto'}
|
||||
right={isFixedMode ? 0 : 'auto'}
|
||||
maxW={isFixedMode ? '1600px' : '100%'}
|
||||
mx={isFixedMode ? 'auto' : 0}
|
||||
px={isFixedMode ? { base: 3, md: 4 } : undefined}
|
||||
zIndex={isFixedMode ? 999 : 1}
|
||||
bg={cardBg}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
@@ -372,9 +535,66 @@ const DynamicNewsCard = forwardRef(({
|
||||
<Badge colorScheme="blue">快讯</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
|
||||
<VStack align="end" spacing={2}>
|
||||
{/* 通知开关 */}
|
||||
<Tooltip
|
||||
label={browserPermission === 'granted'
|
||||
? '浏览器通知已开启,新事件将实时推送'
|
||||
: '开启后可接收实时事件推送通知'}
|
||||
placement="left"
|
||||
hasArrow
|
||||
>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
bg={browserPermission === 'granted'
|
||||
? useColorModeValue('green.50', 'green.900')
|
||||
: useColorModeValue('gray.50', 'gray.700')}
|
||||
borderWidth="1px"
|
||||
borderColor={browserPermission === 'granted'
|
||||
? useColorModeValue('green.200', 'green.700')
|
||||
: useColorModeValue('gray.200', 'gray.600')}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
borderColor: browserPermission === 'granted'
|
||||
? useColorModeValue('green.300', 'green.600')
|
||||
: useColorModeValue('blue.300', 'blue.600'),
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={handleNotificationToggle}
|
||||
>
|
||||
<Icon
|
||||
as={BellIcon}
|
||||
boxSize={4}
|
||||
color={browserPermission === 'granted'
|
||||
? useColorModeValue('green.600', 'green.300')
|
||||
: useColorModeValue('gray.500', 'gray.400')}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={browserPermission === 'granted'
|
||||
? useColorModeValue('green.700', 'green.200')
|
||||
: useColorModeValue('gray.600', 'gray.300')}
|
||||
>
|
||||
{browserPermission === 'granted' ? '通知已开启' : '开启通知'}
|
||||
</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={browserPermission === 'granted'}
|
||||
pointerEvents="none"
|
||||
colorScheme="green"
|
||||
/>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选组件 */}
|
||||
@@ -384,54 +604,111 @@ const DynamicNewsCard = forwardRef(({
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
mode={mode}
|
||||
pageSize={pageSize}
|
||||
trackingFunctions={trackingFunctions}
|
||||
/>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody position="relative" pt={0}>
|
||||
{/* 横向滚动事件列表 - 始终渲染(除非为空) */}
|
||||
{currentPageEvents && currentPageEvents.length > 0 ? (
|
||||
<CardBody
|
||||
ref={cardBodyRef}
|
||||
position={isFixedMode ? 'fixed' : 'relative'}
|
||||
top={isFixedMode ? `${TOTAL_NAV_HEIGHT + headerHeight}px` : 'auto'}
|
||||
left={isFixedMode ? 0 : 'auto'}
|
||||
right={isFixedMode ? 0 : 'auto'}
|
||||
bottom={isFixedMode ? `${FOOTER_HEIGHT}px` : 'auto'}
|
||||
maxW={isFixedMode ? '1600px' : '100%'}
|
||||
mx={isFixedMode ? 'auto' : 0}
|
||||
h={isFixedMode ? `calc(100vh - ${TOTAL_NAV_HEIGHT + headerHeight + FOOTER_HEIGHT}px)` : 'auto'}
|
||||
px={isFixedMode ? { base: 3, md: 4 } : undefined}
|
||||
pt={4}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
zIndex={isFixedMode ? 1000 : 1}
|
||||
bg={cardBg}
|
||||
>
|
||||
{/* 顶部控制栏:模式切换按钮 + 筛选按钮 + 分页控制器(固定不滚动) */}
|
||||
<Flex justify="space-between" align="center" mb={2} flexShrink={0}>
|
||||
{/* 左侧:模式切换按钮 + 筛选按钮 */}
|
||||
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
|
||||
|
||||
{/* 右侧:分页控制器(仅在纵向模式显示) */}
|
||||
{mode === 'vertical' && totalPages > 1 && (
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChangeWithScroll}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域 - 撑满剩余高度 */}
|
||||
<Box flex="1" minH={0} position="relative">
|
||||
{/* Loading 蒙层 - 数据请求时显示 */}
|
||||
{loading && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg={useColorModeValue('rgba(255, 255, 255, 0.85)', 'rgba(26, 32, 44, 0.85)')}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
zIndex={10}
|
||||
borderRadius="md"
|
||||
>
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')} fontWeight="medium">
|
||||
正在加载最新事件...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 列表内容 - 始终渲染 */}
|
||||
<EventScrollList
|
||||
events={currentPageEvents}
|
||||
displayEvents={displayEvents} // 累积显示的事件列表(平铺模式)
|
||||
loadNextPage={loadNextPage} // 加载下一页
|
||||
loadPrevPage={loadPrevPage} // 加载上一页
|
||||
onFourRowEventClick={handleFourRowEventClick} // 四排模式事件点击
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={setSelectedEvent}
|
||||
borderColor={borderColor}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onPageChange={handlePageChangeWithScroll}
|
||||
loading={loadingPage !== null}
|
||||
loadingPage={loadingPage}
|
||||
error={error}
|
||||
mode={mode}
|
||||
onModeChange={handleModeToggle}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
) : !loading ? (
|
||||
/* Empty 状态 - 只在非加载且无数据时显示 */
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
/* 首次加载状态 */
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 详情面板 - 始终显示(如果有选中事件) */}
|
||||
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
|
||||
<Box mt={6}>
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardBody>
|
||||
|
||||
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
|
||||
{isModalOpen && (
|
||||
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1600px" mx="auto" my={8}>
|
||||
<ModalHeader>
|
||||
{modalEvent?.title || '事件详情'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventDetailScrollPanel.js
|
||||
// 事件详情滚动面板组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Center, VStack, Text } from '@chakra-ui/react';
|
||||
import DynamicNewsDetailPanel from '../DynamicNewsDetail';
|
||||
|
||||
/**
|
||||
* 事件详情滚动面板
|
||||
* 带自定义滚动条样式的事件详情容器
|
||||
*
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {string} scrollbarTrackBg - 滚动条轨道背景色
|
||||
* @param {string} scrollbarThumbBg - 滚动条滑块背景色
|
||||
* @param {string} scrollbarThumbHoverBg - 滚动条滑块悬浮背景色
|
||||
* @param {string} detailMode - 详情模式:'full' | 'no-header'(默认 'full')
|
||||
* @param {boolean} showHeader - 是否显示头部(可选,优先级高于 detailMode)
|
||||
*/
|
||||
const EventDetailScrollPanel = ({
|
||||
selectedEvent,
|
||||
scrollbarTrackBg,
|
||||
scrollbarThumbBg,
|
||||
scrollbarThumbHoverBg,
|
||||
detailMode = 'full',
|
||||
showHeader,
|
||||
}) => {
|
||||
// 计算是否显示头部:showHeader 显式指定时优先,否则根据 detailMode 判断
|
||||
const shouldShowHeader = showHeader !== undefined
|
||||
? showHeader
|
||||
: detailMode === 'full';
|
||||
return (
|
||||
<Box
|
||||
pl={2}
|
||||
position="relative"
|
||||
data-detail-panel-container="true"
|
||||
sx={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
overscrollBehavior: 'contain',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedEvent ? (
|
||||
<DynamicNewsDetailPanel event={selectedEvent} showHeader={shouldShowHeader} />
|
||||
) : (
|
||||
<Center h="100%" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg" color="gray.500">
|
||||
请选择左侧事件查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailScrollPanel;
|
||||
@@ -1,28 +1,21 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
||||
// 横向滚动事件列表组件
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
IconButton,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Center,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
import PaginationControl from './PaginationControl';
|
||||
import VirtualizedFourRowGrid from './VirtualizedFourRowGrid';
|
||||
import VerticalModeLayout from './VerticalModeLayout';
|
||||
|
||||
/**
|
||||
* 事件列表组件 - 支持两种展示模式
|
||||
* 事件列表组件 - 支持纵向和平铺两种展示模式
|
||||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||||
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
|
||||
* @param {Function} loadNextPage - 加载下一页(无限滚动)
|
||||
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
|
||||
* @param {Function} onFourRowEventClick - 平铺模式事件点击回调(打开弹窗)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
* @param {string} borderColor - 边框颜色
|
||||
@@ -30,15 +23,18 @@ import PaginationControl from './PaginationControl';
|
||||
* @param {number} totalPages - 总页数(由服务端返回)
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
* @param {boolean} loading - 全局加载状态
|
||||
* @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页...")
|
||||
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
|
||||
* @param {Function} onModeChange - 模式切换回调
|
||||
* @param {Object} error - 错误状态
|
||||
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)
|
||||
* @param {boolean} hasMore - 是否还有更多数据
|
||||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
events,
|
||||
displayEvents,
|
||||
loadNextPage,
|
||||
loadPrevPage,
|
||||
onFourRowEventClick,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
borderColor,
|
||||
@@ -46,8 +42,8 @@ const EventScrollList = ({
|
||||
totalPages,
|
||||
onPageChange,
|
||||
loading = false,
|
||||
mode = 'carousel',
|
||||
onModeChange,
|
||||
error,
|
||||
mode = 'vertical',
|
||||
hasMore = true,
|
||||
eventFollowStatus = {},
|
||||
onToggleFollow
|
||||
@@ -59,19 +55,11 @@ const EventScrollList = ({
|
||||
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
|
||||
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
// 翻页按钮颜色
|
||||
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
||||
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
||||
|
||||
// 滚动条颜色
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
// 加载遮罩颜色
|
||||
const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700');
|
||||
const loadingTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: timelineBg,
|
||||
@@ -82,215 +70,75 @@ const EventScrollList = ({
|
||||
};
|
||||
};
|
||||
|
||||
// 重试函数
|
||||
const handleRetry = useCallback(() => {
|
||||
if (onPageChange) {
|
||||
onPageChange(currentPage);
|
||||
}
|
||||
}, [onPageChange, currentPage]);
|
||||
|
||||
{/* 事件卡片容器 */}
|
||||
return (
|
||||
<Box>
|
||||
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
{/* 模式切换按钮 */}
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
onClick={() => onModeChange('carousel')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'carousel' ? 'solid' : 'outline'}
|
||||
>
|
||||
单排
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onModeChange('grid')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'grid' ? 'solid' : 'outline'}
|
||||
>
|
||||
双排
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
overflowX="hidden"
|
||||
h="100%"
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={mode === 'four-row' ? 0 : 2}
|
||||
position="relative"
|
||||
data-scroll-container="true"
|
||||
css={{
|
||||
// 统一滚动条样式(支持横向和纵向)
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
||||
<VirtualizedFourRowGrid
|
||||
display={mode === 'four-row' ? 'block' : 'none'}
|
||||
columnsPerRow={4} // 每行显示4列
|
||||
events={displayEvents || events} // 使用累积列表(如果有)
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={onFourRowEventClick} // 四排模式点击打开弹窗
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={onToggleFollow}
|
||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||
borderColor={borderColor}
|
||||
loadNextPage={loadNextPage} // 加载下一页
|
||||
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
|
||||
hasMore={hasMore} // 是否还有更多数据
|
||||
loading={loading} // 加载状态
|
||||
error={error} // 错误状态
|
||||
onRetry={handleRetry} // 重试回调
|
||||
/>
|
||||
|
||||
{/* 分页控制器 */}
|
||||
{totalPages > 1 && (
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 横向滚动区域 */}
|
||||
<Box position="relative">
|
||||
|
||||
{/* 左侧翻页按钮 - 上一页 */}
|
||||
{currentPage > 1 && (
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
left="0"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
aria-label="上一页"
|
||||
title="上一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧翻页按钮 - 下一页 */}
|
||||
{currentPage < totalPages && hasMore && (
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
right="0"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
isDisabled={currentPage >= totalPages && !hasMore}
|
||||
aria-label="下一页"
|
||||
title="下一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 事件卡片容器 */}
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
overflowX={mode === 'carousel' ? 'auto' : 'hidden'}
|
||||
overflowY="hidden"
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={2}
|
||||
position="relative"
|
||||
css={mode === 'carousel' ? {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
} : {}}
|
||||
>
|
||||
{/* 加载遮罩 */}
|
||||
{loading && (
|
||||
<Center
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg={loadingOverlayBg}
|
||||
backdropFilter="blur(2px)"
|
||||
zIndex={10}
|
||||
borderRadius="md"
|
||||
>
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" thickness="3px" />
|
||||
<Text fontSize="sm" color={loadingTextColor}>
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 模式1: 单排轮播模式 */}
|
||||
{mode === 'carousel' && (
|
||||
<Flex gap={4}>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
minW="calc((100% - 64px) / 5)"
|
||||
maxW="calc((100% - 64px) / 5)"
|
||||
flexShrink={0}
|
||||
>
|
||||
<DynamicNewsEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 模式2: 双排网格模式 */}
|
||||
{mode === 'grid' && (
|
||||
<Grid
|
||||
templateRows="repeat(2, 1fr)"
|
||||
templateColumns="repeat(5, 1fr)"
|
||||
gap={4}
|
||||
autoFlow="column"
|
||||
>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id}>
|
||||
<DynamicNewsEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* 纵向分栏模式 */}
|
||||
<VerticalModeLayout
|
||||
display={mode === 'vertical' ? 'flex' : 'none'}
|
||||
events={events}
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={onEventSelect}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={onToggleFollow}
|
||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// src/views/Community/components/DynamicNewsCard/ModeToggleButtons.js
|
||||
// 事件列表模式切换按钮组
|
||||
|
||||
import React from 'react';
|
||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件列表模式切换按钮组
|
||||
* @param {string} mode - 当前模式 'vertical' | 'four-row'
|
||||
* @param {Function} onModeChange - 模式切换回调
|
||||
*/
|
||||
const ModeToggleButtons = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
onClick={() => onModeChange('vertical')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'vertical' ? 'solid' : 'outline'}
|
||||
>
|
||||
纵向
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onModeChange('four-row')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'four-row' ? 'solid' : 'outline'}
|
||||
>
|
||||
平铺
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModeToggleButtons;
|
||||
@@ -0,0 +1,83 @@
|
||||
// src/views/Community/components/DynamicNewsCard/PageNavigationButton.js
|
||||
// 翻页导航按钮组件
|
||||
|
||||
import React from 'react';
|
||||
import { IconButton, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 翻页导航按钮组件
|
||||
* @param {Object} props
|
||||
* @param {'prev'|'next'} props.direction - 按钮方向(prev=上一页,next=下一页)
|
||||
* @param {number} props.currentPage - 当前页码
|
||||
* @param {number} props.totalPages - 总页数
|
||||
* @param {Function} props.onPageChange - 翻页回调
|
||||
* @param {string} props.mode - 显示模式(只在carousel/grid模式下显示)
|
||||
*/
|
||||
const PageNavigationButton = ({
|
||||
direction,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
mode
|
||||
}) => {
|
||||
// 主题适配
|
||||
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
||||
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
||||
|
||||
// 根据方向计算配置
|
||||
const isPrev = direction === 'prev';
|
||||
const isNext = direction === 'next';
|
||||
|
||||
const Icon = isPrev ? ChevronLeftIcon : ChevronRightIcon;
|
||||
const position = isPrev ? 'left' : 'right';
|
||||
const label = isPrev ? '上一页' : '下一页';
|
||||
const targetPage = isPrev ? currentPage - 1 : currentPage + 1;
|
||||
const shouldShow = isPrev
|
||||
? currentPage > 1
|
||||
: currentPage < totalPages;
|
||||
const isDisabled = isNext ? currentPage >= totalPages : false;
|
||||
|
||||
// 判断是否显示(只在单排/双排模式显示)
|
||||
const shouldRender = shouldShow && (mode === 'carousel' || mode === 'grid');
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const handleClick = () => {
|
||||
console.log(
|
||||
`%c🔵 [翻页] 点击${label}: 当前页${currentPage} → 目标页${targetPage} (共${totalPages}页)`,
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
onPageChange(targetPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<Icon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
{...{ [position]: 0 }}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={handleClick}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageNavigationButton;
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 分页控制器组件
|
||||
* 分页控制器组件(使用 React.memo 优化,避免不必要的重新渲染)
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
*/
|
||||
const PaginationControl = ({ currentPage, totalPages, onPageChange }) => {
|
||||
const PaginationControl = React.memo(({ currentPage, totalPages, onPageChange }) => {
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
@@ -206,6 +206,10 @@ const PaginationControl = ({ currentPage, totalPages, onPageChange }) => {
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数:只有当 currentPage 或 totalPages 变化时才重新渲染
|
||||
return prevProps.currentPage === nextProps.currentPage &&
|
||||
prevProps.totalPages === nextProps.totalPages;
|
||||
});
|
||||
|
||||
export default PaginationControl;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// src/views/Community/components/DynamicNewsCard/VerticalModeLayout.js
|
||||
// 纵向分栏模式布局组件
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, IconButton, Tooltip, VStack, Flex, Center, Text } from '@chakra-ui/react';
|
||||
import { ViewIcon, ViewOffIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
|
||||
import EventDetailScrollPanel from './EventDetailScrollPanel';
|
||||
|
||||
/**
|
||||
* 纵向分栏模式布局
|
||||
* 支持两种展示模式:
|
||||
* - detail(默认):左侧事件列表 1fr | 右侧详情 2fr
|
||||
* - list:左侧事件列表 7fr | 右侧详情 300px
|
||||
*
|
||||
* @param {string} display - CSS display 属性(用于显示/隐藏组件)
|
||||
* @param {Array} events - 当前页的事件列表(分页数据)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
* @param {Object} eventFollowStatus - 事件关注状态
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
|
||||
* @param {string} borderColor - 边框颜色
|
||||
*/
|
||||
const VerticalModeLayout = ({
|
||||
display = 'flex',
|
||||
events,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
eventFollowStatus,
|
||||
onToggleFollow,
|
||||
getTimelineBoxStyle,
|
||||
borderColor,
|
||||
}) => {
|
||||
// 布局模式状态:'detail' = 聚焦详情(默认),'list' = 聚焦列表
|
||||
const [layoutMode, setLayoutMode] = useState('detail');
|
||||
|
||||
// 详情面板重置 key(切换到 list 模式时改变,强制重新渲染)
|
||||
const [detailPanelKey, setDetailPanelKey] = useState(0);
|
||||
|
||||
// 监听事件选择 - 自动切换到详情模式
|
||||
useEffect(() => {
|
||||
if (selectedEvent) {
|
||||
setLayoutMode('detail');
|
||||
}
|
||||
}, [selectedEvent]);
|
||||
|
||||
// 切换布局模式
|
||||
const toggleLayoutMode = () => {
|
||||
const newMode = layoutMode === 'detail' ? 'list' : 'detail';
|
||||
setLayoutMode(newMode);
|
||||
|
||||
// 如果切换到 list 模式,重置详情面板(收起所有 CollapsibleSection)
|
||||
if (newMode === 'list') {
|
||||
setDetailPanelKey(prev => prev + 1); // 改变 key,强制重新渲染
|
||||
}
|
||||
};
|
||||
|
||||
// 根据模式计算 flex 比例
|
||||
const leftFlex = layoutMode === 'detail' ? '4' : '6';
|
||||
const rightFlex = layoutMode === 'detail' ? '6' : '4';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
display={display}
|
||||
gap={6}
|
||||
position="relative"
|
||||
transition="all 0.3s ease-in-out"
|
||||
h="100%"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 左侧:事件列表 - 独立滚动 */}
|
||||
<Box
|
||||
flex={leftFlex}
|
||||
minWidth={0}
|
||||
overflowY="auto"
|
||||
h="100%"
|
||||
data-event-list-container="true"
|
||||
css={{
|
||||
overscrollBehavior: 'contain',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#888',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#555',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 事件列表 */}
|
||||
{events && events.length > 0 ? (
|
||||
<VStack
|
||||
spacing={2}
|
||||
align="stretch"
|
||||
p={2}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<HorizontalDynamicNewsEventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={() => onEventSelect(event)}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount}
|
||||
onToggleFollow={onToggleFollow}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
|
||||
layout="vertical"
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Center h="100%" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon w={12} h={12} color="gray.400" />
|
||||
<Text fontSize="lg" color="gray.500" textAlign="center">
|
||||
当前筛选条件下暂无数据
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
请尝试调整筛选条件
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 右侧:事件详情 - 独立滚动 */}
|
||||
<Box
|
||||
flex={rightFlex}
|
||||
minHeight={0}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
h="100%"
|
||||
>
|
||||
{/* 布局切换按钮 */}
|
||||
<Tooltip
|
||||
label={layoutMode === 'detail' ? '展开事件列表' : '展开详情面板'}
|
||||
placement="left"
|
||||
>
|
||||
<IconButton
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
zIndex={9999}
|
||||
size="md"
|
||||
icon={layoutMode === 'detail' ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={toggleLayoutMode}
|
||||
aria-label="切换布局模式"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* 详情面板 */}
|
||||
<EventDetailScrollPanel
|
||||
key={detailPanelKey}
|
||||
detailMode="no-header"
|
||||
selectedEvent={selectedEvent}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerticalModeLayout;
|
||||
@@ -0,0 +1,353 @@
|
||||
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
|
||||
// 虚拟化网格组件(支持多列布局 + 纵向滚动 + 无限滚动)
|
||||
|
||||
import React, { useRef, useMemo, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Box, Grid, Spinner, Text, VStack, Center, HStack, IconButton } from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
|
||||
/**
|
||||
* 虚拟化网格组件(支持多列布局 + 无限滚动)
|
||||
* @param {Object} props
|
||||
* @param {string} props.display - CSS display 属性(用于显示/隐藏组件)
|
||||
* @param {Array} props.events - 事件列表(累积显示)
|
||||
* @param {number} props.columnsPerRow - 每行列数(默认 4,单列模式传 1)
|
||||
* @param {React.Component} props.CardComponent - 卡片组件(默认 DynamicNewsEventCard)
|
||||
* @param {Object} props.selectedEvent - 当前选中的事件
|
||||
* @param {Function} props.onEventSelect - 事件选择回调
|
||||
* @param {Object} props.eventFollowStatus - 事件关注状态
|
||||
* @param {Function} props.onToggleFollow - 关注切换回调
|
||||
* @param {Function} props.getTimelineBoxStyle - 时间轴样式获取函数
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
* @param {Function} props.loadNextPage - 加载下一页(无限滚动)
|
||||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||||
* @param {boolean} props.loading - 加载状态
|
||||
*/
|
||||
const VirtualizedFourRowGrid = ({
|
||||
display = 'block',
|
||||
events,
|
||||
columnsPerRow = 4,
|
||||
CardComponent = DynamicNewsEventCard,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
eventFollowStatus,
|
||||
onToggleFollow,
|
||||
getTimelineBoxStyle,
|
||||
borderColor,
|
||||
loadNextPage,
|
||||
onRefreshFirstPage, // 修改:顶部刷新回调(替代 loadPrevPage)
|
||||
hasMore,
|
||||
loading,
|
||||
error, // 新增:错误状态
|
||||
onRetry, // 新增:重试回调
|
||||
}) => {
|
||||
const parentRef = useRef(null);
|
||||
const isLoadingMore = useRef(false); // 防止重复加载
|
||||
const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖)
|
||||
|
||||
// 滚动条颜色(主题适配)
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
// 将事件按 columnsPerRow 个一组分成行
|
||||
const rows = useMemo(() => {
|
||||
const r = [];
|
||||
for (let i = 0; i < events.length; i += columnsPerRow) {
|
||||
r.push(events.slice(i, i + columnsPerRow));
|
||||
}
|
||||
return r;
|
||||
}, [events, columnsPerRow]);
|
||||
|
||||
// 配置虚拟滚动器(纵向滚动 + 动态高度测量)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 250, // 提供初始估算值,库会自动测量实际高度
|
||||
overscan: 2, // 预加载2行(上下各1行)
|
||||
});
|
||||
|
||||
/**
|
||||
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
|
||||
*
|
||||
* 工作原理:
|
||||
* 1. 向下滚动到 90% 位置时,触发 loadNextPage()
|
||||
* - 调用 usePagination.loadNextPage()
|
||||
* - 内部执行 handlePageChange(currentPage + 1)
|
||||
* - dispatch(fetchDynamicNews({ page: nextPage }))
|
||||
* - 后端返回下一页数据(30条)
|
||||
* - Redux 去重后追加到 fourRowEvents 数组
|
||||
* - events prop 更新,虚拟滚动自动渲染新内容
|
||||
*
|
||||
* 2. 向上滚动到顶部 10% 以内时,触发 onRefreshFirstPage()
|
||||
* - 清空缓存 + 重新加载第一页(获取最新数据)
|
||||
* - 30秒防抖:避免频繁刷新
|
||||
* - 与5分钟定时刷新协同工作
|
||||
*
|
||||
* 设计要点:
|
||||
* - 90% 触发点:接近底部才加载,避免过早触发影响用户体验
|
||||
* - 防抖机制:isLoadingMore.current 防止重复触发
|
||||
* - 两层缓存:
|
||||
* - Redux 缓存(HTTP层):fourRowEvents 数组存储已加载数据,避免重复请求
|
||||
* - 虚拟滚动缓存(渲染层):@tanstack/react-virtual 只渲染可见行,复用 DOM 节点
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 如果组件被隐藏,不执行滚动监听
|
||||
if (display === 'none') return;
|
||||
|
||||
const scrollElement = parentRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const handleScroll = async () => {
|
||||
// 防止重复触发
|
||||
if (isLoadingMore.current || loading) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
// 向下滚动:滚动到 90% 时开始加载下一页(更接近底部,避免过早触发)
|
||||
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
|
||||
console.log('%c📜 [无限滚动] 接近底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||||
isLoadingMore.current = true;
|
||||
await loadNextPage();
|
||||
isLoadingMore.current = false;
|
||||
}
|
||||
|
||||
// 向上滚动到顶部:触发刷新(30秒防抖)
|
||||
if (onRefreshFirstPage && scrollTop < clientHeight * 0.1) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastRefreshTime.current;
|
||||
|
||||
// 30秒防抖:避免频繁刷新
|
||||
if (timeSinceLastRefresh >= 30000) {
|
||||
console.log('%c🔄 [顶部刷新] 滚动到顶部,清空缓存并重新加载第一页', 'color: #10B981; font-weight: bold;', {
|
||||
timeSinceLastRefresh: `${(timeSinceLastRefresh / 1000).toFixed(1)}秒`
|
||||
});
|
||||
isLoadingMore.current = true;
|
||||
lastRefreshTime.current = now;
|
||||
|
||||
await onRefreshFirstPage();
|
||||
isLoadingMore.current = false;
|
||||
} else {
|
||||
const remainingTime = Math.ceil((30000 - timeSinceLastRefresh) / 1000);
|
||||
console.log('%c🔄 [顶部刷新] 防抖中,请等待', 'color: #EAB308; font-weight: bold;', {
|
||||
remainingTime: `${remainingTime}秒`
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll);
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [display, loadNextPage, onRefreshFirstPage, hasMore, loading]);
|
||||
|
||||
/**
|
||||
* 【核心逻辑2】主动检测内容高度 - 确保内容始终填满容器
|
||||
*
|
||||
* 场景:
|
||||
* - 初次加载时,如果 30 条数据不足以填满 800px 容器(例如显示器很大)
|
||||
* - 用户无法滚动,也就无法触发上面的滚动监听逻辑
|
||||
*
|
||||
* 解决方案:
|
||||
* - 定时检查 scrollHeight 是否小于等于 clientHeight
|
||||
* - 如果内容不足,主动调用 loadNextPage() 加载更多数据
|
||||
* - 递归触发,直到内容高度超过容器高度(出现滚动条)
|
||||
*
|
||||
* 优化:
|
||||
* - 500ms 延迟:确保虚拟滚动已完成首次渲染和高度测量
|
||||
* - 监听 events.length 变化:新数据加载后重新检查
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 如果组件被隐藏,不执行高度检测
|
||||
if (display === 'none') return;
|
||||
|
||||
const scrollElement = parentRef.current;
|
||||
if (!scrollElement || !loadNextPage) return;
|
||||
|
||||
// 延迟检查,确保虚拟滚动已渲染
|
||||
const timer = setTimeout(() => {
|
||||
// 防止重复触发
|
||||
if (isLoadingMore.current || !hasMore || loading) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = scrollElement;
|
||||
|
||||
// 如果内容高度不足以填满容器(没有滚动条),主动加载下一页
|
||||
if (scrollHeight <= clientHeight) {
|
||||
console.log('%c📜 [无限滚动] 内容不足以填满容器,主动加载下一页', 'color: #8B5CF6; font-weight: bold;', {
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
eventsCount: events.length
|
||||
});
|
||||
isLoadingMore.current = true;
|
||||
loadNextPage().finally(() => {
|
||||
isLoadingMore.current = false;
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [display, events.length, hasMore, loading, loadNextPage]);
|
||||
|
||||
// 错误指示器(同行显示)
|
||||
const renderErrorIndicator = () => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Center py={6}>
|
||||
<HStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
数据加载失败,
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="ghost"
|
||||
onClick={onRetry}
|
||||
aria-label="刷新"
|
||||
/>
|
||||
<Text
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
cursor="pointer"
|
||||
onClick={onRetry}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
刷新
|
||||
</Text>
|
||||
</HStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
// 底部加载指示器
|
||||
const renderLoadingIndicator = () => {
|
||||
if (!hasMore) {
|
||||
return (
|
||||
<Center py={6}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
已加载全部内容
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={6}>
|
||||
<VStack spacing={2}>
|
||||
<Spinner size="md" color="blue.500" thickness="3px" />
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={parentRef}
|
||||
display={display}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
minH="800px"
|
||||
maxH="800px"
|
||||
w="100%"
|
||||
position="relative"
|
||||
css={{
|
||||
// 滚动条样式
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* 虚拟滚动容器 + 底部加载指示器 */}
|
||||
<Box position="relative" w="100%">
|
||||
{/* 虚拟滚动内容 */}
|
||||
<Box
|
||||
position="relative"
|
||||
w="100%"
|
||||
h={`${rowVirtualizer.getTotalSize()}px`}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowEvents = rows[virtualRow.index];
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
transform={`translateY(${virtualRow.start}px)`}
|
||||
>
|
||||
{/* 使用 Grid 横向排列卡片(列数由 columnsPerRow 决定) */}
|
||||
<Grid
|
||||
templateColumns={`repeat(${columnsPerRow}, 1fr)`}
|
||||
gap={columnsPerRow === 1 ? 3 : 4}
|
||||
w="100%"
|
||||
>
|
||||
{rowEvents.map((event, colIndex) => (
|
||||
<Box key={event.id} w="100%" minW={0}>
|
||||
<CardComponent
|
||||
event={event}
|
||||
index={virtualRow.index * columnsPerRow + colIndex}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle?.()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 底部加载指示器 - 绝对定位在虚拟内容底部 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={`${rowVirtualizer.getTotalSize()}px`}
|
||||
left={0}
|
||||
right={0}
|
||||
w="100%"
|
||||
>
|
||||
{error ? renderErrorIndicator() : renderLoadingIndicator()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
40
src/views/Community/components/DynamicNewsCard/constants.js
Normal file
40
src/views/Community/components/DynamicNewsCard/constants.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// src/views/Community/components/DynamicNewsCard/constants.js
|
||||
// 动态新闻卡片组件 - 常量配置
|
||||
|
||||
// ========== 分页配置常量 ==========
|
||||
/**
|
||||
* 分页大小计算依据:
|
||||
*
|
||||
* 【四排模式 (FOUR_ROW_PAGE_SIZE)】
|
||||
* - 容器高度: 800px (VirtualizedFourRowGrid)
|
||||
* - 单行高度: ~250px (包含卡片 + 间距)
|
||||
* - 每行显示: 4 列
|
||||
* - 可视区域: 800px / 250px ≈ 3.2 行
|
||||
* - overscan 缓冲: 2 行 (上下各预渲染1行)
|
||||
* - 实际渲染区域: 3.2 + 2 = 5.2 行
|
||||
* - 单次加载数据量: 7.5 行 × 4 列 = 30 个
|
||||
* - 设计目标: 提供充足的缓冲数据,确保快速滚动时不出现空白
|
||||
*
|
||||
* 【纵向模式 (VERTICAL_PAGE_SIZE)】
|
||||
* - 每页显示 10 条数据
|
||||
* - 使用传统分页器,用户手动翻页
|
||||
*/
|
||||
export const PAGINATION_CONFIG = {
|
||||
FOUR_ROW_PAGE_SIZE: 30, // 平铺模式每页数量 (7.5行 × 4列,包含缓冲)
|
||||
VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量 (传统分页)
|
||||
INITIAL_PAGE: 1, // 初始页码
|
||||
};
|
||||
|
||||
// ========== 显示模式常量 ==========
|
||||
export const DISPLAY_MODES = {
|
||||
FOUR_ROW: 'four-row', // 平铺网格模式
|
||||
VERTICAL: 'vertical', // 纵向分栏模式
|
||||
};
|
||||
|
||||
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;
|
||||
|
||||
// ========== Toast 提示配置 ==========
|
||||
export const TOAST_CONFIG = {
|
||||
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
|
||||
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
// src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js
|
||||
// 无限滚动 Hook
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 无限滚动 Hook
|
||||
* 监听容器滚动事件,当滚动到底部附近时触发加载更多数据
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onLoadMore - 加载更多回调函数(返回 Promise)
|
||||
* @param {boolean} options.hasMore - 是否还有更多数据
|
||||
* @param {boolean} options.isLoading - 是否正在加载
|
||||
* @param {number} options.threshold - 触发阈值(距离底部多少像素时触发,默认200px)
|
||||
* @returns {Object} { containerRef } - 容器引用
|
||||
*/
|
||||
export const useInfiniteScroll = ({
|
||||
onLoadMore,
|
||||
hasMore = true,
|
||||
isLoading = false,
|
||||
threshold = 200
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 滚动处理函数
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
// 检查条件:容器存在、未加载中、还有更多数据
|
||||
if (!container || isLoadingRef.current || !hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// 距离底部小于阈值时触发加载
|
||||
if (distanceToBottom < threshold) {
|
||||
console.log(
|
||||
'%c⬇️ [懒加载] 触发加载下一页',
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
{
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToBottom,
|
||||
threshold
|
||||
}
|
||||
);
|
||||
|
||||
isLoadingRef.current = true;
|
||||
|
||||
// 调用加载函数并更新状态
|
||||
onLoadMore()
|
||||
.then(() => {
|
||||
console.log('%c✅ [懒加载] 加载完成', 'color: #10B981; font-weight: bold;');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('%c❌ [懒加载] 加载失败', 'color: #DC2626; font-weight: bold;', error);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [onLoadMore, hasMore, threshold]);
|
||||
|
||||
// 绑定滚动事件
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 添加滚动监听
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
// 更新 loading 状态的 ref
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
return { containerRef };
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
|
||||
// 分页逻辑自定义 Hook
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { fetchDynamicNews, updatePaginationPage } from '../../../../../store/slices/communityDataSlice';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
import {
|
||||
PAGINATION_CONFIG,
|
||||
DISPLAY_MODES,
|
||||
DEFAULT_MODE,
|
||||
TOAST_CONFIG
|
||||
} from '../constants';
|
||||
|
||||
/**
|
||||
* 分页逻辑自定义 Hook
|
||||
* @param {Object} options - Hook 配置选项
|
||||
* @param {Object} options.allCachedEventsByPage - 纵向模式页码映射 { 1: [...], 2: [...] }
|
||||
* @param {Array} options.allCachedEvents - 平铺模式数组 [...]
|
||||
* @param {Object} options.pagination - 分页元数据 { total, total_pages, current_page, per_page, page }
|
||||
* @param {number} options.total - 【废弃】服务端总数量(向后兼容,建议使用 pagination.total)
|
||||
* @param {number} options.cachedCount - 已缓存数量
|
||||
* @param {Function} options.dispatch - Redux dispatch 函数
|
||||
* @param {Function} options.toast - Toast 通知函数
|
||||
* @param {Object} options.filters - 筛选条件
|
||||
* @param {string} options.initialMode - 初始显示模式(可选)
|
||||
* @returns {Object} 分页状态和方法
|
||||
*/
|
||||
export const usePagination = ({
|
||||
allCachedEventsByPage, // 纵向模式:页码映射
|
||||
allCachedEvents, // 平铺模式:数组
|
||||
pagination, // 分页元数据对象
|
||||
total, // 向后兼容
|
||||
cachedCount,
|
||||
dispatch,
|
||||
toast,
|
||||
filters = {},
|
||||
initialMode // 初始显示模式
|
||||
}) => {
|
||||
// 本地状态
|
||||
const [loadingPage, setLoadingPage] = useState(null);
|
||||
const [mode, setMode] = useState(initialMode || DEFAULT_MODE);
|
||||
|
||||
// 【核心改动】从 Redux pagination 派生 currentPage,不再使用本地状态
|
||||
const currentPage = pagination?.current_page || PAGINATION_CONFIG.INITIAL_PAGE;
|
||||
|
||||
// 使用 ref 存储最新的 filters,避免 useCallback 闭包问题
|
||||
// 当 filters 对象引用不变但内容改变时,闭包中的 filters 是旧值
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
// 根据模式决定每页显示数量
|
||||
const pageSize = (() => {
|
||||
switch (mode) {
|
||||
case DISPLAY_MODES.FOUR_ROW:
|
||||
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
|
||||
case DISPLAY_MODES.VERTICAL:
|
||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||
default:
|
||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||
}
|
||||
})();
|
||||
|
||||
// 【优化】优先使用后端返回的 total_pages,避免前端重复计算
|
||||
// 向后兼容:如果没有 pagination 对象,则使用 total 计算
|
||||
const totalPages = pagination?.total_pages || Math.ceil((pagination?.total || total || 0) / pageSize) || 1;
|
||||
|
||||
// 检查是否还有更多数据(使用页码判断,不受去重影响)
|
||||
const hasMore = currentPage < totalPages;
|
||||
|
||||
// 从页码映射或数组获取当前页数据
|
||||
const currentPageEvents = useMemo(() => {
|
||||
if (mode === DISPLAY_MODES.VERTICAL) {
|
||||
// 纵向模式:从页码映射获取当前页
|
||||
return allCachedEventsByPage?.[currentPage] || [];
|
||||
} else {
|
||||
// 平铺模式:返回全部累积数据
|
||||
return allCachedEvents || [];
|
||||
}
|
||||
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
|
||||
|
||||
// 当前显示的事件列表
|
||||
const displayEvents = useMemo(() => {
|
||||
if (mode === DISPLAY_MODES.FOUR_ROW) {
|
||||
// 平铺模式:返回全部累积数据
|
||||
return allCachedEvents || [];
|
||||
} else {
|
||||
// 纵向模式:返回当前页数据
|
||||
return currentPageEvents;
|
||||
}
|
||||
}, [mode, allCachedEvents, currentPageEvents]);
|
||||
|
||||
/**
|
||||
* 加载单个页面数据
|
||||
* @param {number} targetPage - 目标页码
|
||||
* @param {boolean} clearCache - 是否清空缓存(第1页专用)
|
||||
* @returns {Promise<boolean>} 是否加载成功
|
||||
*/
|
||||
const loadPage = useCallback(async (targetPage, clearCache = false) => {
|
||||
// 显示 loading 状态
|
||||
setLoadingPage(targetPage);
|
||||
|
||||
try {
|
||||
console.log(`%c🟢 [API请求] 开始加载第${targetPage}页数据`, 'color: #16A34A; font-weight: bold;');
|
||||
console.log(`%c 请求参数: page=${targetPage}, per_page=${pageSize}, mode=${mode}, clearCache=${clearCache}`, 'color: #16A34A;');
|
||||
console.log(`%c 筛选条件:`, 'color: #16A34A;', filtersRef.current);
|
||||
|
||||
logger.debug('DynamicNewsCard', '开始加载页面数据', {
|
||||
targetPage,
|
||||
pageSize,
|
||||
mode,
|
||||
clearCache,
|
||||
filters: filtersRef.current
|
||||
});
|
||||
|
||||
// 🔍 调试:dispatch 前
|
||||
console.log(`%c🔵 [dispatch] 准备调用 fetchDynamicNews`, 'color: #3B82F6; font-weight: bold;', {
|
||||
mode,
|
||||
page: targetPage,
|
||||
per_page: pageSize,
|
||||
pageSize,
|
||||
clearCache,
|
||||
filters: filtersRef.current
|
||||
});
|
||||
|
||||
const result = await dispatch(fetchDynamicNews({
|
||||
mode: mode, // 传递 mode 参数
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize,
|
||||
clearCache: clearCache, // 传递 clearCache 参数
|
||||
...filtersRef.current, // 从 ref 读取最新筛选条件
|
||||
page: targetPage, // 然后覆盖 page 参数(避免被 filters.page 覆盖)
|
||||
})).unwrap();
|
||||
|
||||
// 🔍 调试:dispatch 后
|
||||
console.log(`%c🔵 [dispatch] fetchDynamicNews 返回结果`, 'color: #3B82F6; font-weight: bold;', result);
|
||||
console.log(`%c🟢 [API请求] 第${targetPage}页加载完成`, 'color: #16A34A; font-weight: bold;');
|
||||
logger.debug('DynamicNewsCard', `第 ${targetPage} 页加载完成`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', 'loadPage', error, {
|
||||
targetPage
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
|
||||
status: 'error',
|
||||
duration: TOAST_CONFIG.DURATION_ERROR,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setLoadingPage(null);
|
||||
}
|
||||
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
|
||||
|
||||
// 翻页处理(第1页强制刷新 + 其他页缓存)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
// 边界检查 1: 检查页码范围
|
||||
if (newPage < 1 || newPage > totalPages) {
|
||||
console.log(`%c⚠️ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
|
||||
logger.warn('usePagination', '页码超出范围', { newPage, totalPages });
|
||||
return;
|
||||
}
|
||||
|
||||
// 边界检查 2: 检查是否重复点击
|
||||
if (newPage === currentPage) {
|
||||
console.log(`%c⚠️ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
|
||||
logger.debug('usePagination', '页码未改变', { newPage });
|
||||
return;
|
||||
}
|
||||
|
||||
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
|
||||
if (loadingPage === newPage) {
|
||||
console.log(`%c⚠️ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');
|
||||
logger.warn('usePagination', '竞态条件:相同页面正在加载', { loadingPage, newPage });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在加载其他页面,允许切换(会取消当前加载状态,开始新的加载)
|
||||
if (loadingPage !== null && loadingPage !== newPage) {
|
||||
console.log(`%c🔄 [翻页] 正在加载第${loadingPage}页,用户切换到第${newPage}页`, 'color: #8B5CF6; font-weight: bold;');
|
||||
logger.info('usePagination', '用户切换页面,继续处理新请求', { loadingPage, newPage });
|
||||
// 继续执行,loadPage 会覆盖 loadingPage 状态
|
||||
}
|
||||
|
||||
console.log(`%c🔵 [翻页逻辑] handlePageChange 开始`, 'color: #3B82F6; font-weight: bold;');
|
||||
console.log(`%c 当前页: ${currentPage}, 目标页: ${newPage}, 模式: ${mode}`, 'color: #3B82F6;');
|
||||
|
||||
// 【核心逻辑】第1页特殊处理:强制清空缓存并重新加载
|
||||
if (newPage === 1) {
|
||||
console.log(`%c🔄 [第1页] 清空缓存并重新加载`, 'color: #8B5CF6; font-weight: bold;');
|
||||
logger.info('usePagination', '第1页:强制刷新', { mode });
|
||||
|
||||
// clearCache = true:API 会更新 Redux pagination.current_page
|
||||
await loadPage(newPage, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 【其他页】检查缓存
|
||||
if (mode === DISPLAY_MODES.VERTICAL) {
|
||||
// 纵向模式:检查页码映射中是否有缓存
|
||||
const isPageCached = allCachedEventsByPage?.[newPage]?.length > 0;
|
||||
|
||||
console.log(`%c🟡 [缓存检查] 第${newPage}页缓存状态`, 'color: #EAB308; font-weight: bold;');
|
||||
console.log(`%c 是否已缓存: ${isPageCached ? '✅ 是' : '❌ 否'}`, `color: ${isPageCached ? '#16A34A' : '#DC2626'};`);
|
||||
|
||||
if (isPageCached) {
|
||||
console.log(`%c✅ [缓存] 第${newPage}页已缓存,直接切换`, 'color: #16A34A; font-weight: bold;');
|
||||
// 使用缓存数据,同步更新 Redux pagination.current_page
|
||||
dispatch(updatePaginationPage({ mode, page: newPage }));
|
||||
} else {
|
||||
console.log(`%c❌ [缓存] 第${newPage}页未缓存,加载数据`, 'color: #DC2626; font-weight: bold;');
|
||||
// clearCache = false:API 会更新 Redux pagination.current_page
|
||||
await loadPage(newPage, false);
|
||||
}
|
||||
} else {
|
||||
// 平铺模式:直接加载新页(追加模式,clearCache=false)
|
||||
console.log(`%c🟡 [平铺模式] 加载第${newPage}页`, 'color: #EAB308; font-weight: bold;');
|
||||
// clearCache = false:API 会更新 Redux pagination.current_page
|
||||
await loadPage(newPage, false);
|
||||
}
|
||||
}, [mode, currentPage, totalPages, loadingPage, allCachedEventsByPage, loadPage]);
|
||||
|
||||
|
||||
// 加载下一页(用于无限滚动)
|
||||
const loadNextPage = useCallback(async () => {
|
||||
// 使用 hasMore 判断(基于 currentPage < totalPages)
|
||||
if (!hasMore || loadingPage !== null) {
|
||||
logger.debug('DynamicNewsCard', '无法加载下一页', {
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasMore,
|
||||
loadingPage,
|
||||
reason: !hasMore ? '已加载全部数据 (currentPage >= totalPages)' : '正在加载中'
|
||||
});
|
||||
return Promise.resolve(false); // 没有更多数据或正在加载
|
||||
}
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
logger.debug('DynamicNewsCard', '懒加载:加载下一页', { currentPage, nextPage, hasMore, totalPages });
|
||||
|
||||
try {
|
||||
await handlePageChange(nextPage);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', '懒加载失败', error, { nextPage });
|
||||
return false;
|
||||
}
|
||||
}, [currentPage, totalPages, hasMore, loadingPage, handlePageChange]);
|
||||
|
||||
// 加载上一页(用于双向无限滚动)
|
||||
const loadPrevPage = useCallback(async () => {
|
||||
if (currentPage <= 1 || loadingPage !== null) {
|
||||
logger.debug('DynamicNewsCard', '无法加载上一页', {
|
||||
currentPage,
|
||||
loadingPage,
|
||||
reason: currentPage <= 1 ? '已是第一页' : '正在加载中'
|
||||
});
|
||||
return Promise.resolve(false); // 已经是第一页或正在加载
|
||||
}
|
||||
|
||||
const prevPage = currentPage - 1;
|
||||
logger.debug('DynamicNewsCard', '懒加载:加载上一页', { currentPage, prevPage });
|
||||
|
||||
try {
|
||||
await handlePageChange(prevPage);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', '懒加载上一页失败', error, { prevPage });
|
||||
return false;
|
||||
}
|
||||
}, [currentPage, loadingPage, handlePageChange]);
|
||||
|
||||
// 模式切换处理(简化版 - 模式切换时始终请求数据,因为两种模式使用独立存储)
|
||||
const handleModeToggle = useCallback((newMode) => {
|
||||
if (newMode === mode) return;
|
||||
|
||||
setMode(newMode);
|
||||
// currentPage 由 Redux pagination.current_page 派生,会在下次请求时自动更新
|
||||
// pageSize 会根据 mode 自动重新计算(第46-56行)
|
||||
}, [mode]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentPage,
|
||||
mode,
|
||||
loadingPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
currentPageEvents,
|
||||
displayEvents, // 当前显示的事件列表
|
||||
|
||||
// 方法
|
||||
handlePageChange,
|
||||
handleModeToggle,
|
||||
loadNextPage, // 加载下一页(用于无限滚动)
|
||||
loadPrevPage // 加载上一页(用于双向无限滚动)
|
||||
};
|
||||
};
|
||||
@@ -3,11 +3,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
IconButton,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
@@ -19,40 +21,97 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false)
|
||||
* @param {string} props.currentMode - 当前模式:'detailed' | 'simple'
|
||||
* @param {Function} props.onModeToggle - 模式切换回调
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
*/
|
||||
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null }) => {
|
||||
const CollapsibleHeader = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
showModeToggle = false,
|
||||
currentMode = 'detailed',
|
||||
onModeToggle = null,
|
||||
isLocked = false
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 获取按钮文案
|
||||
const getButtonText = () => {
|
||||
if (currentMode === 'simple') {
|
||||
return '查看详情'; // 简单模式时,按钮显示"查看详情"
|
||||
}
|
||||
return '精简模式'; // 详细模式时,按钮显示"精简模式"
|
||||
};
|
||||
|
||||
// 获取按钮图标
|
||||
const getButtonIcon = () => {
|
||||
if (currentMode === 'simple') {
|
||||
return null; // 简单模式不显示图标
|
||||
}
|
||||
// 详细模式:展开显示向上箭头,收起显示向下箭头
|
||||
return isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
cursor={showModeToggle ? 'default' : 'pointer'}
|
||||
onClick={showModeToggle ? undefined : onToggle}
|
||||
p={3}
|
||||
bg={sectionBg}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
_hover={showModeToggle ? {} : { bg: hoverBg }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
{title}
|
||||
</Heading>
|
||||
{count !== null && (
|
||||
{subscriptionBadge && (
|
||||
<Box>
|
||||
{subscriptionBadge}
|
||||
</Box>
|
||||
)}
|
||||
{count !== null && count > 0 && (
|
||||
<Badge colorScheme="blue" borderRadius="full">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
|
||||
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
|
||||
{showModeToggle && onModeToggle && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={getButtonIcon()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onModeToggle(e);
|
||||
}}
|
||||
>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* showModeToggle=false 时显示原有的 IconButton */}
|
||||
{!showModeToggle && (
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
|
||||
// 通用可折叠区块组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
@@ -16,24 +16,107 @@ import CollapsibleHeader from './CollapsibleHeader';
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.children - 子内容
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
* @param {Function} props.onLockedClick - 锁定时点击的回调
|
||||
* @param {React.ReactNode} props.children - 详细内容
|
||||
* @param {React.ReactNode} props.simpleContent - 精简模式的内容(可选)
|
||||
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false)
|
||||
* @param {string} props.defaultMode - 默认模式:'detailed' | 'simple'(默认 'detailed')
|
||||
*/
|
||||
const CollapsibleSection = ({ title, isOpen, onToggle, count = null, children }) => {
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
children,
|
||||
simpleContent = null,
|
||||
showModeToggle = false,
|
||||
defaultMode = 'detailed'
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
// 模式状态:'detailed' | 'simple'
|
||||
const [displayMode, setDisplayMode] = useState(defaultMode);
|
||||
|
||||
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
|
||||
const handleToggle = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (!isLocked) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理模式切换
|
||||
const handleModeToggle = (e) => {
|
||||
e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
|
||||
|
||||
if (isLocked && onLockedClick) {
|
||||
// 如果被锁定,触发付费弹窗
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayMode === 'detailed') {
|
||||
// 从详细模式切换到精简模式
|
||||
setDisplayMode('simple');
|
||||
} else {
|
||||
// 从精简模式切换回详细模式
|
||||
setDisplayMode('detailed');
|
||||
// 切换回详细模式时,如果未展开则自动展开
|
||||
if (!isOpen && onToggle) {
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染精简模式
|
||||
const renderSimpleMode = () => {
|
||||
if (!simpleContent) return null;
|
||||
|
||||
return (
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{simpleContent}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染详细模式
|
||||
const renderDetailedMode = () => {
|
||||
return (
|
||||
<Collapse
|
||||
in={isOpen && !isLocked}
|
||||
animateOpacity
|
||||
unmountOnExit={false}
|
||||
startingHeight={0}
|
||||
>
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
onToggle={handleToggle}
|
||||
count={count}
|
||||
subscriptionBadge={subscriptionBadge}
|
||||
showModeToggle={showModeToggle}
|
||||
currentMode={displayMode}
|
||||
onModeToggle={handleModeToggle}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* 根据当前模式渲染对应内容 */}
|
||||
{displayMode === 'simple' ? renderSimpleMode() : renderDetailedMode()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CompactMetaBar.js
|
||||
// 精简信息栏组件(无头部模式下右上角显示)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Badge,
|
||||
Text,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
/**
|
||||
* 精简信息栏组件
|
||||
* 在无头部模式下,显示在 CardBody 右上角
|
||||
* 包含:重要性徽章、浏览次数、关注按钮
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {Function} props.onToggleFollow - 切换关注回调
|
||||
*/
|
||||
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
|
||||
const viewCountBg = useColorModeValue('white', 'gray.700');
|
||||
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
// 获取重要性文本
|
||||
const getImportanceText = () => {
|
||||
const levelMap = {
|
||||
'S': '极高',
|
||||
'A': '高',
|
||||
'B': '中',
|
||||
'C': '低'
|
||||
};
|
||||
return levelMap[importance.level] || '中';
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack
|
||||
position="absolute"
|
||||
top={3}
|
||||
right={3}
|
||||
spacing={3}
|
||||
zIndex={1}
|
||||
>
|
||||
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */}
|
||||
<Badge
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
bgGradient={
|
||||
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
|
||||
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
|
||||
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
|
||||
'linear(to-r, gray.500, gray.700)'
|
||||
}
|
||||
color="white"
|
||||
boxShadow="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Icon as={importance.icon} boxSize={4} />
|
||||
<Text>重要性:{getImportanceText()}</Text>
|
||||
</Badge>
|
||||
|
||||
{/* 浏览次数 - 添加容器背景以提高可读性 */}
|
||||
<HStack
|
||||
spacing={1}
|
||||
bg={viewCountBg}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<ViewIcon color="gray.400" boxSize={4} />
|
||||
<Text fontSize="sm" color={viewCountTextColor} whiteSpace="nowrap">
|
||||
{(event.view_count || 0).toLocaleString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={onToggleFollow}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactMetaBar;
|
||||
@@ -0,0 +1,113 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CompactStockItem.js
|
||||
// 精简模式股票卡片组件(浮动卡片样式)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '../../../../utils/colorUtils';
|
||||
|
||||
/**
|
||||
* 精简模式股票卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* @param {Object} props.quote - 股票行情数据(可选)
|
||||
*/
|
||||
const CompactStockItem = ({ stock, quote = null }) => {
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
const handleViewDetail = () => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${stock.stock_name} - 点击查看详情`}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
bgGradient={getChangeBackgroundGradient(change)}
|
||||
borderWidth="3px"
|
||||
borderColor={getChangeBorderColor(change)}
|
||||
borderRadius="2xl"
|
||||
p={4}
|
||||
onClick={handleViewDetail}
|
||||
cursor="pointer"
|
||||
boxShadow="lg"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
bg: getChangeBorderColor(change),
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: '2xl',
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
}}
|
||||
transition="all 0.3s ease-in-out"
|
||||
display="inline-block"
|
||||
minW="150px"
|
||||
>
|
||||
{/* 股票代码 */}
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
mb={2}
|
||||
textAlign="center"
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
|
||||
{/* 涨跌幅 - 超大号显示 */}
|
||||
<Text
|
||||
fontSize="3xl"
|
||||
fontWeight="black"
|
||||
color={getChangeColor(change)}
|
||||
textAlign="center"
|
||||
lineHeight="1"
|
||||
textShadow="0 1px 2px rgba(0,0,0,0.1)"
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
|
||||
{/* 股票名称(小字) */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={nameColor}
|
||||
mt={2}
|
||||
textAlign="center"
|
||||
noOfLines={1}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactStockItem;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
@@ -17,45 +19,114 @@ import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { eventService } from '../../../../services/eventService';
|
||||
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import CompactMetaBar from './CompactMetaBar';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CompactStockItem from './CompactStockItem';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
||||
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象(包含详情数据)
|
||||
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true)
|
||||
*/
|
||||
const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { user } = useAuth();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const toast = useToast();
|
||||
|
||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||
const userTier = user?.subscription_type || 'free';
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 使用 Hook 获取实时数据
|
||||
// 🎯 浏览量机制:存储从 API 获取的完整事件详情(包含最新 view_count)
|
||||
const [fullEventDetail, setFullEventDetail] = useState(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
// 权限判断函数
|
||||
const hasAccess = useCallback((requiredTier) => {
|
||||
const tierLevel = { free: 0, pro: 1, max: 2 };
|
||||
const result = tierLevel[userTier] >= tierLevel[requiredTier];
|
||||
return result;
|
||||
}, [userTier]);
|
||||
|
||||
// 升级弹窗状态
|
||||
const [upgradeModal, setUpgradeModal] = useState({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
|
||||
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
|
||||
const {
|
||||
stocks,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
expectationScore,
|
||||
loading
|
||||
} = useEventStocks(event?.id, event?.created_at);
|
||||
loading,
|
||||
loadStocksData,
|
||||
loadHistoricalData,
|
||||
loadChainAnalysis
|
||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
|
||||
|
||||
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
|
||||
const loadEventDetail = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setFullEventDetail(response.data);
|
||||
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
|
||||
eventId: event.id,
|
||||
viewCount: response.data.view_count,
|
||||
title: response.data.title
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [event?.id]);
|
||||
|
||||
// 相关股票、相关概念、历史事件和传导链的权限
|
||||
const canAccessStocks = hasAccess('pro');
|
||||
const canAccessConcepts = hasAccess('pro');
|
||||
const canAccessHistorical = hasAccess('pro');
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 + 加载追踪
|
||||
// 初始值为 false,由 useEffect 根据权限动态设置
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
|
||||
|
||||
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
|
||||
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
||||
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
|
||||
|
||||
// 折叠状态管理
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(true);
|
||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
|
||||
|
||||
// 自选股管理(使用 localStorage)
|
||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
||||
@@ -67,6 +138,95 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 锁定点击处理 - 弹出升级弹窗
|
||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||
setUpgradeModal({
|
||||
isOpen: true,
|
||||
requiredLevel,
|
||||
featureName
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 关闭升级弹窗
|
||||
const handleCloseUpgradeModal = useCallback(() => {
|
||||
setUpgradeModal({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 相关股票 - 展开时加载(需要 PRO 权限)
|
||||
const handleStocksToggle = useCallback(() => {
|
||||
const newState = !isStocksOpen;
|
||||
setIsStocksOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedStocks) {
|
||||
console.log('%c📊 [相关股票] 首次展开,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
setIsConceptsOpen(!isConceptsOpen);
|
||||
}, [isConceptsOpen]);
|
||||
|
||||
// 历史事件对比 - 展开时加载
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
const newState = !isHistoricalOpen;
|
||||
setIsHistoricalOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedHistorical) {
|
||||
console.log('%c📜 [历史事件] 首次展开,加载历史事件数据', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
|
||||
loadHistoricalData();
|
||||
setHasLoadedHistorical(true);
|
||||
}
|
||||
}, [isHistoricalOpen, hasLoadedHistorical, loadHistoricalData, event?.id]);
|
||||
|
||||
// 传导链分析 - 展开时加载
|
||||
const handleTransmissionToggle = useCallback(() => {
|
||||
const newState = !isTransmissionOpen;
|
||||
setIsTransmissionOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedTransmission) {
|
||||
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
|
||||
loadChainAnalysis();
|
||||
setHasLoadedTransmission(true);
|
||||
}
|
||||
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
|
||||
|
||||
// 事件切换时重置所有子模块状态
|
||||
useEffect(() => {
|
||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
||||
|
||||
// 🎯 加载事件详情(增加浏览量)
|
||||
loadEventDetail();
|
||||
|
||||
// PRO 和 MAX 会员的相关股票默认展开,其他情况收起
|
||||
const shouldOpenStocks = canAccessStocks;
|
||||
setIsStocksOpen(shouldOpenStocks);
|
||||
setHasLoadedStocks(false);
|
||||
|
||||
// PRO 和 MAX 会员自动加载股票数据(无论是否展开)
|
||||
const shouldLoadStocks = canAccessStocks; // PRO 或 MAX 都有权限
|
||||
if (shouldLoadStocks) {
|
||||
console.log('%c📊 [PRO/MAX会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', {
|
||||
eventId: event?.id,
|
||||
userTier
|
||||
});
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
|
||||
setIsConceptsOpen(false);
|
||||
setIsHistoricalOpen(false);
|
||||
setHasLoadedHistorical(false);
|
||||
setIsTransmissionOpen(false);
|
||||
setHasLoadedTransmission(false);
|
||||
}, [event?.id, canAccessStocks, userTier, loadStocksData, loadEventDetail]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
@@ -127,51 +287,106 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 */}
|
||||
<EventHeaderInfo
|
||||
event={event}
|
||||
<CardBody position="relative">
|
||||
{/* 无头部模式:显示右上角精简信息栏 */}
|
||||
{!showHeader && (
|
||||
<CompactMetaBar
|
||||
event={fullEventDetail || event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
|
||||
{showHeader && (
|
||||
<EventHeaderInfo
|
||||
event={fullEventDetail || event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 事件描述 */}
|
||||
<EventDescriptionSection description={event.description} />
|
||||
|
||||
{/* 相关概念 */}
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={handleStocksToggle}
|
||||
count={stocks?.length || 0}
|
||||
subscriptionBadge={(() => {
|
||||
if (!canAccessStocks) {
|
||||
return <SubscriptionBadge tier="pro" size="sm" />;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
isLocked={!canAccessStocks}
|
||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||
showModeToggle={canAccessStocks}
|
||||
defaultMode="detailed"
|
||||
simpleContent={
|
||||
loading.stocks || loading.quotes ? (
|
||||
<Center py={2}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Wrap spacing={4}>
|
||||
{stocks?.map((stock, index) => (
|
||||
<WrapItem key={index}>
|
||||
<CompactStockItem
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
|
||||
<RelatedConceptsSection
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={isConceptsOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
|
||||
/>
|
||||
|
||||
{/* 相关股票(可折叠) */}
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={() => setIsStocksOpen(!isStocksOpen)}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 历史事件对比(可折叠) */}
|
||||
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={isHistoricalOpen}
|
||||
onToggle={() => setIsHistoricalOpen(!isHistoricalOpen)}
|
||||
onToggle={handleHistoricalToggle}
|
||||
count={historicalEvents?.length || 0}
|
||||
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessHistorical}
|
||||
onLockedClick={() => handleLockedClick('历史事件对比', 'pro')}
|
||||
>
|
||||
{loading.historicalEvents ? (
|
||||
<Center py={4}>
|
||||
@@ -181,15 +396,19 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
) : (
|
||||
<HistoricalEvents
|
||||
events={historicalEvents || []}
|
||||
expectationScore={expectationScore}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) */}
|
||||
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={isTransmissionOpen}
|
||||
onToggle={() => setIsTransmissionOpen(!isTransmissionOpen)}
|
||||
onToggle={handleTransmissionToggle}
|
||||
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
||||
isLocked={!canAccessTransmission}
|
||||
onLockedClick={() => handleLockedClick('传导链分析', 'max')}
|
||||
>
|
||||
<TransmissionChainAnalysis
|
||||
eventId={event.id}
|
||||
@@ -198,6 +417,17 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
</CollapsibleSection>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
{/* 升级弹窗 */}
|
||||
{upgradeModal.isOpen ? (
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={handleCloseUpgradeModal}
|
||||
requiredLevel={upgradeModal.requiredLevel}
|
||||
featureName={upgradeModal.featureName}
|
||||
currentLevel={userTier}
|
||||
/>
|
||||
): null }
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
@@ -100,7 +102,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:涨跌幅指标 + 重要性文本 */}
|
||||
{/* 第三行:涨跌幅指标 + 重要性徽章 */}
|
||||
<HStack spacing={3} align="center">
|
||||
<Box maxW="500px">
|
||||
<StockChangeIndicators
|
||||
@@ -110,19 +112,28 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 重要性文本 */}
|
||||
<Box
|
||||
bg={importance.bgColor}
|
||||
borderWidth="2px"
|
||||
borderColor={importance.badgeBg}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
{/* 重要性徽章 - 使用渐变色和图标 */}
|
||||
<Badge
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
bgGradient={
|
||||
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
|
||||
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
|
||||
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
|
||||
'linear(to-r, gray.500, gray.700)'
|
||||
}
|
||||
color="white"
|
||||
boxShadow="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
<Text fontSize="sm" color={importance.badgeBg} whiteSpace="nowrap" fontWeight="medium">
|
||||
重要性:{getImportanceText()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Icon as={importance.icon} boxSize={5} />
|
||||
<Text>重要性:{getImportanceText()}</Text>
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -161,8 +161,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 140,
|
||||
height: 40,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '35px',
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user