Compare commits

...

37 Commits

Author SHA1 Message Date
zdl
53fbda44e6 feat: 忽略 docs 目录(开发文档不提交到 Git) 2025-11-20 15:07:45 +08:00
zdl
540b938525 feat: 删除md文件 2025-11-20 15:07:45 +08:00
zdl
8fe11efcd7 refactor: 清理 Bytedesk 集成文件,移动文档到 docs 目录 2025-11-20 15:07:45 +08:00
zdl
e753437b86 feat: 调整客服配置 2025-11-20 15:07:45 +08:00
zdl
a6f69418f6 feat: 添加SEOpng 2025-11-20 15:07:45 +08:00
zdl
dfdd2f4134 feat: posthog 配置调整 2025-11-20 15:07:45 +08:00
zdl
4c79871ab4 pref: 去除无效配置 2025-11-20 15:07:45 +08:00
f8eb268341 update pay function 2025-11-20 15:05:58 +08:00
665f5e8416 update pay function 2025-11-20 14:51:43 +08:00
be2da54d82 update pay function 2025-11-20 14:42:26 +08:00
8bf4a0b6c6 update pay function 2025-11-20 14:36:13 +08:00
412b2c03ed update pay function 2025-11-20 14:33:09 +08:00
899500007d update pay function 2025-11-20 14:30:32 +08:00
d3879b3840 update pay function 2025-11-20 14:24:24 +08:00
80fe74c041 update pay function 2025-11-20 14:13:33 +08:00
78f7dca1f6 update pay function 2025-11-20 13:57:11 +08:00
03aee75235 update pay function 2025-11-20 13:43:04 +08:00
8eff6b1a95 update pay function 2025-11-20 13:25:50 +08:00
80676dd622 update pay function 2025-11-20 12:55:28 +08:00
082e644534 update pay function 2025-11-20 08:33:26 +08:00
b0b227a5ef update pay function 2025-11-20 08:18:02 +08:00
691c4f6eb1 update pay function 2025-11-20 08:09:34 +08:00
d5a55c4e02 update pay function 2025-11-20 08:02:34 +08:00
27cdf0aecd update pay function 2025-11-20 07:57:15 +08:00
4a1157c0b6 update pay function 2025-11-20 07:46:50 +08:00
f515dc94f4 update pay function 2025-11-19 23:41:45 +08:00
683e261756 update pay function 2025-11-19 23:06:14 +08:00
8bdfd0389c update pay function 2025-11-19 22:56:28 +08:00
eae495ac34 Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref 2025-11-19 21:46:07 +08:00
958cedefb8 update pay function 2025-11-19 21:45:55 +08:00
zdl
1fc9f4790f pref: 清理建议
6.1 立即可删除(安全)

  以下文件可以立即删除,不会影响任何功能:

  # 未使用的组件
  src/views/Community/components/EventList.js
  src/views/Community/components/EventListSection.js
  src/views/Community/components/EventTimelineHeader.js
  src/views/Community/components/MarketReviewCard.js
  src/views/Community/components/UnifiedSearchBox.js
  src/views/Community/components/ImportanceLegend.js
  src/views/Community/components/IndustryCascader.js
  src/views/Community/components/EventDetailModal.js

  # 未使用的CSS
  src/views/Community/components/EventList.css

  # 备份文件
  src/views/Community/components/EventList.js.bak

  # 测试文档
  src/views/Community/components/DynamicNewsDetail/1.md

  预计减少代码量:~2000行代码
2025-11-19 21:27:24 +08:00
b48ff99658 update pay function 2025-11-19 20:44:35 +08:00
ae558996b6 update pay function 2025-11-19 20:23:56 +08:00
71742c0116 update pay function 2025-11-19 20:15:27 +08:00
2ead50c37c update pay function 2025-11-19 19:55:07 +08:00
9e8519bb94 Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref 2025-11-19 19:41:59 +08:00
a4d16e7686 update pay function 2025-11-19 19:41:26 +08:00
40 changed files with 6908 additions and 4826 deletions

View File

@@ -18,10 +18,3 @@ REACT_APP_ENABLE_MOCK=false
# 开发环境标识
REACT_APP_ENV=development
# PostHog 配置(开发环境)
# 留空 = 仅控制台 debug
# 填入 Key = 控制台 + PostHog Cloud 双模式
REACT_APP_POSTHOG_KEY=
REACT_APP_POSTHOG_HOST=https://app.posthog.com
REACT_APP_ENABLE_SESSION_RECORDING=false

View File

@@ -35,14 +35,3 @@ REACT_APP_ENABLE_MOCK=true
# Mock 环境标识
REACT_APP_ENV=mock
# PostHog 配置Mock 环境)
# 留空 = 仅控制台 debug
# 填入 Key = 控制台 + PostHog Cloud 双模式
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
REACT_APP_POSTHOG_HOST=https://app.posthog.com
REACT_APP_ENABLE_SESSION_RECORDING=false
# PostHog Debug 模式Mock 环境永久启用)
# 在浏览器 Console 中打印详细的事件追踪日志
REACT_APP_POSTHOG_DEBUG=true

View File

@@ -1,13 +1,6 @@
# ========================================
# 生产环境配置
# ========================================
# 使用方式: npm run build
#
# 工作原理:
# 1. 此文件专门用于生产环境构建
# 2. 构建时会将环境变量嵌入到打包文件中
# 3. 确保 PostHog 等服务使用正确的生产配置
# ========================================
# 环境标识
REACT_APP_ENV=production
@@ -17,13 +10,8 @@ NODE_ENV=production
REACT_APP_ENABLE_MOCK=false
# 🔧 调试模式(生产环境临时调试用)
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
# 使用方法:
# 1. 设置为 true 并重新构建
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
# 3. 调试完成后设置为 false 并重新构建
REACT_APP_ENABLE_DEBUG=true
# 开启后会在全局暴露 window.__DEBUG__
REACT_APP_ENABLE_DEBUG=false
# 后端 API 地址(生产环境)
REACT_APP_API_URL=http://49.232.185.254:5001
@@ -49,20 +37,3 @@ TSC_COMPILE_ON_ERROR=true
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096
# ========================================
# Bytedesk 客服系统配置
# ========================================
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
# Nginx 配置location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
# 组织 UUID从管理后台 -> 设置 -> 组织信息 -> 组织UUID
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 UUID从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
REACT_APP_BYTEDESK_SID=df_wg_uid
# 客服类型2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2

4
.gitignore vendored
View File

@@ -48,6 +48,8 @@ Thumbs.db
*.md
!README.md
!CLAUDE.md
!docs/**/*.md
# 忽略 docs 目录(开发文档不提交到 Git
docs/
src/assets/img/original-backup/

512
app.py
View File

@@ -570,6 +570,28 @@ class User(UserMixin, db.Model):
'is_authenticated': True
}
# 获取用户订阅信息(从 user_subscriptions 表)
subscription = UserSubscription.query.filter_by(user_id=self.id).first()
if subscription:
data.update({
'subscription_type': subscription.subscription_type,
'subscription_status': subscription.subscription_status,
'billing_cycle': subscription.billing_cycle,
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
'auto_renewal': subscription.auto_renewal
})
else:
# 无订阅时使用默认值
data.update({
'subscription_type': 'free',
'subscription_status': 'inactive',
'billing_cycle': None,
'start_date': None,
'end_date': None,
'auto_renewal': False
})
# 敏感信息只在需要时包含
if include_sensitive:
data.update({
@@ -1127,36 +1149,61 @@ def get_user_subscription_safe(user_id):
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
"""激活用户订阅
"""
激活用户订阅(新版:续费时从当前订阅结束时间开始延长)
Args:
user_id: 用户ID
plan_type: 套餐类型
billing_cycle: 计费周期
extend_from_now: 是否从当前时间开始延长(用于升级场景
plan_type: 套餐类型 (pro/max)
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
extend_from_now: 废弃参数,保留以兼容(现在自动判断
Returns:
UserSubscription 对象 或 None
"""
try:
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription:
# 新用户,创建订阅记录
subscription = UserSubscription(user_id=user_id)
db.session.add(subscription)
# 更新订阅类型和状态
subscription.subscription_type = plan_type
subscription.subscription_status = 'active'
subscription.billing_cycle = billing_cycle
if not extend_from_now or not subscription.start_date:
subscription.start_date = beijing_now()
# 计算订阅周期天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90, # 3个月
'semiannual': 180, # 6个月
'yearly': 365
}
days = cycle_days_map.get(billing_cycle, 30)
if billing_cycle == 'monthly':
subscription.end_date = beijing_now() + timedelta(days=30)
else: # yearly
subscription.end_date = beijing_now() + timedelta(days=365)
now = beijing_now()
# 判断是新购还是续费
if subscription.end_date and subscription.end_date > now:
# 续费:从当前订阅结束时间开始延长
start_date = subscription.end_date
end_date = start_date + timedelta(days=days)
else:
# 新购或过期后重新购买:从当前时间开始
start_date = now
end_date = now + timedelta(days=days)
subscription.start_date = start_date
subscription.end_date = end_date
subscription.updated_at = now
subscription.updated_at = beijing_now()
db.session.commit()
return subscription
except Exception as e:
print(f"激活订阅失败: {e}")
db.session.rollback()
return None
@@ -1233,33 +1280,29 @@ def calculate_discount(promo_code, amount):
return 0
def calculate_remaining_value(subscription, current_plan):
"""计算当前订阅的剩余价值"""
try:
if not subscription or not subscription.end_date:
return 0
def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code=None):
"""
简化版价格计算:续费用户和新用户价格完全一致,不计算剩余价值
now = beijing_now()
if subscription.end_date <= now:
return 0
days_left = (subscription.end_date - now).days
if subscription.billing_cycle == 'monthly':
daily_value = float(current_plan.monthly_price) / 30
else: # yearly
daily_value = float(current_plan.yearly_price) / 365
return daily_value * days_left
except:
return 0
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
"""计算升级所需价格
Args:
user_id: 用户ID
to_plan_name: 目标套餐名称 (pro/max)
to_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
promo_code: 优惠码(可选)
Returns:
dict: 包含价格计算结果的字典
dict: {
'is_renewal': False/True, # 是否为续费
'subscription_type': 'new'/'renew', # 订阅类型
'current_plan': 'pro', # 当前套餐(如果有)
'current_cycle': 'yearly', # 当前周期(如果有)
'new_plan_price': 2699.00, # 新套餐价格
'original_amount': 2699.00, # 原价
'discount_amount': 0, # 优惠金额
'final_amount': 2699.00, # 实付金额
'promo_code': None, # 使用的优惠码
'promo_error': None # 优惠码错误信息
}
"""
try:
# 1. 获取当前订阅
@@ -1270,83 +1313,176 @@ def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
if not to_plan:
return {'error': '目标套餐不存在'}
# 3. 计算目标套餐价格
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
# 3. 根据计费周期获取价格
# 优先从 pricing_options 获取价格
price = None
if to_plan.pricing_options:
try:
pricing_opts = json.loads(to_plan.pricing_options)
# 查找匹配的周期
for opt in pricing_opts:
cycle_key = opt.get('cycle_key', '')
months = opt.get('months', 0)
# 4. 如果是新订阅(非升级)
if not current_sub or current_sub.subscription_type == 'free':
result = {
'is_upgrade': False,
'new_plan_price': new_price,
'remaining_value': 0,
'upgrade_amount': new_price,
'original_amount': new_price,
'discount_amount': 0,
'final_amount': new_price,
'promo_code': None
}
# 匹配逻辑
if (cycle_key == to_cycle or
(to_cycle == 'monthly' and months == 1) or
(to_cycle == 'quarterly' and months == 3) or
(to_cycle == 'semiannual' and months == 6) or
(to_cycle == 'yearly' and months == 12)):
price = float(opt.get('price', 0))
break
except:
pass
# 应用优惠码
if promo_code:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id)
if promo:
discount = calculate_discount(promo, new_price)
result['discount_amount'] = discount
result['final_amount'] = new_price - discount
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
# 如果 pricing_options 中没有找到,使用旧的 monthly_price/yearly_price
if price is None:
if to_cycle == 'yearly':
price = float(to_plan.yearly_price) if to_plan.yearly_price else 0
else: # 默认月付
price = float(to_plan.monthly_price) if to_plan.monthly_price else 0
return result
if price <= 0:
return {'error': f'{to_cycle} 周期价格未配置'}
# 5. 升级场景:计算剩余价值
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
if not current_plan:
return {'error': '当前套餐信息不存在'}
# 4. 判断订阅类型和计算价格
is_renewal = False
is_upgrade = False
is_downgrade = False
subscription_type = 'new'
current_plan = None
current_cycle = None
remaining_value = 0
final_price = price
remaining_value = calculate_remaining_value(current_sub, current_plan)
if current_sub and current_sub.subscription_type in ['pro', 'max']:
current_plan = current_sub.subscription_type
current_cycle = current_sub.billing_cycle
# 6. 计算升级差价
upgrade_amount = max(0, new_price - remaining_value)
if current_plan == to_plan_name:
# 同级续费:延长时长,全价购买
is_renewal = True
subscription_type = 'renew'
elif current_plan == 'pro' and to_plan_name == 'max':
# 升级Pro → Max需要计算差价
is_upgrade = True
subscription_type = 'upgrade'
# 7. 判断升级类型
upgrade_type = 'new'
if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle:
upgrade_type = 'both'
elif current_sub.subscription_type != to_plan_name:
upgrade_type = 'plan_upgrade'
elif current_sub.billing_cycle != to_cycle:
upgrade_type = 'cycle_change'
# 计算当前订阅的剩余价值
if current_sub.end_date and current_sub.end_date > datetime.utcnow():
# 获取当前套餐的原始价格
current_plan_obj = SubscriptionPlan.query.filter_by(name=current_plan, is_active=True).first()
if current_plan_obj:
current_price = None
# 优先从 pricing_options 获取价格
if current_plan_obj.pricing_options:
try:
pricing_opts = json.loads(current_plan_obj.pricing_options)
# 如果 current_cycle 为空或无效,根据剩余天数推断计费周期
if not current_cycle or current_cycle.strip() == '':
remaining_days_total = (current_sub.end_date - current_sub.start_date).days if current_sub.start_date else 365
# 根据总天数推断计费周期
if remaining_days_total <= 35:
inferred_cycle = 'monthly'
elif remaining_days_total <= 100:
inferred_cycle = 'quarterly'
elif remaining_days_total <= 200:
inferred_cycle = 'semiannual'
else:
inferred_cycle = 'yearly'
else:
inferred_cycle = current_cycle
for opt in pricing_opts:
if opt.get('cycle_key') == inferred_cycle:
current_price = float(opt.get('price', 0))
current_cycle = inferred_cycle # 更新周期信息
break
except:
pass
# 如果 pricing_options 中没找到,使用 yearly_price 作为默认
if current_price is None or current_price <= 0:
current_price = float(current_plan_obj.yearly_price) if current_plan_obj.yearly_price else 0
current_cycle = 'yearly'
if current_price and current_price > 0:
# 计算剩余天数
remaining_days = (current_sub.end_date - datetime.utcnow()).days
# 计算总天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
total_days = cycle_days_map.get(current_cycle, 365)
# 计算剩余价值
if total_days > 0 and remaining_days > 0:
remaining_value = current_price * (remaining_days / total_days)
# 实付金额 = 新套餐价格 - 剩余价值
final_price = max(0, price - remaining_value)
# 如果剩余价值 >= 新套餐价格,标记为免费升级
if remaining_value >= price:
final_price = 0
elif current_plan == 'max' and to_plan_name == 'pro':
# 降级Max → Pro到期后切换全价购买
is_downgrade = True
subscription_type = 'downgrade'
else:
# 其他情况视为新购
subscription_type = 'new'
# 5. 构建结果
result = {
'is_upgrade': True,
'upgrade_type': upgrade_type,
'current_plan': current_sub.subscription_type,
'current_cycle': current_sub.billing_cycle,
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None,
'new_plan_price': new_price,
'remaining_value': remaining_value,
'upgrade_amount': upgrade_amount,
'original_amount': upgrade_amount,
'is_renewal': is_renewal,
'is_upgrade': is_upgrade,
'is_downgrade': is_downgrade,
'subscription_type': subscription_type,
'current_plan': current_plan,
'current_cycle': current_cycle,
'new_plan_price': price,
'original_price': price, # 新套餐原价
'remaining_value': remaining_value, # 当前订阅剩余价值(仅升级时有效)
'original_amount': price,
'discount_amount': 0,
'final_amount': upgrade_amount,
'promo_code': None
'final_amount': final_price,
'promo_code': None,
'promo_error': None
}
# 8. 应用优惠码
if promo_code and upgrade_amount > 0:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id)
# 6. 应用优惠码(基于差价后的金额)
if promo_code and promo_code.strip():
# 优惠码作用于差价后的金额
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, final_price, user_id)
if promo:
discount = calculate_discount(promo, upgrade_amount)
result['discount_amount'] = discount
result['final_amount'] = upgrade_amount - discount
discount = calculate_discount(promo, final_price)
result['discount_amount'] = float(discount)
result['final_amount'] = final_price - float(discount)
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
except Exception as e:
return {'error': str(e)}
return {'error': f'价格计算失败: {str(e)}'}
# 保留旧函数以兼容(标记为废弃)
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
"""
【已废弃】旧版升级价格计算函数,保留以兼容旧代码
新代码请使用 calculate_subscription_price_simple
"""
# 直接调用新函数
return calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code)
def initialize_subscription_plans_safe():
@@ -1594,7 +1730,33 @@ def validate_promo_code_api():
@app.route('/api/subscription/calculate-price', methods=['POST'])
def calculate_subscription_price():
"""计算订阅价格(支持升级和优惠码)"""
"""
计算订阅价格(新版:续费和新购价格一致)
Request Body:
{
"to_plan": "pro",
"to_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
Response:
{
"success": true,
"data": {
"is_renewal": true, // 是否为续费
"subscription_type": "renew", // new 或 renew
"current_plan": "pro", // 当前套餐(如果有)
"current_cycle": "monthly", // 当前周期(如果有)
"new_plan_price": 2699.00,
"original_amount": 2699.00,
"discount_amount": 0,
"final_amount": 2699.00,
"promo_code": null,
"promo_error": null
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1607,8 +1769,8 @@ def calculate_subscription_price():
if not to_plan or not to_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 计算价格
result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code)
# 使用新的简化价格计算函数
result = calculate_subscription_price_simple(session['user_id'], to_plan, to_cycle, promo_code)
if 'error' in result:
return jsonify({
@@ -1628,9 +1790,101 @@ def calculate_subscription_price():
}), 500
@app.route('/api/subscription/free-upgrade', methods=['POST'])
@login_required
def free_upgrade_subscription():
"""
免费升级订阅(当剩余价值 >= 新套餐价格时)
Request Body:
{
"plan_name": "max",
"billing_cycle": "yearly"
}
"""
try:
data = request.get_json()
plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle')
if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
user_id = current_user.id
# 计算价格,验证是否可以免费升级
price_result = calculate_subscription_price_simple(user_id, plan_name, billing_cycle, None)
if 'error' in price_result:
return jsonify({'success': False, 'error': price_result['error']}), 400
# 检查是否为升级且实付金额为0
if not price_result.get('is_upgrade') or price_result.get('final_amount', 1) > 0:
return jsonify({'success': False, 'error': '当前情况不符合免费升级条件'}), 400
# 获取当前订阅
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription:
return jsonify({'success': False, 'error': '未找到订阅记录'}), 404
# 计算新的到期时间(按剩余价值折算)
remaining_value = price_result.get('remaining_value', 0)
new_plan_price = price_result.get('new_plan_price', 0)
if new_plan_price > 0:
# 计算可以兑换的新套餐天数
value_ratio = remaining_value / new_plan_price
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
new_cycle_days = cycle_days_map.get(billing_cycle, 365)
# 新的到期天数 = 周期天数 × 价值比例
new_days = int(new_cycle_days * value_ratio)
# 更新订阅信息
subscription.subscription_type = plan_name
subscription.billing_cycle = billing_cycle
subscription.start_date = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=new_days)
subscription.subscription_status = 'active'
subscription.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': f'升级成功!您的{plan_name.upper()}版本将持续{new_days}',
'data': {
'subscription_type': plan_name,
'end_date': subscription.end_date.isoformat(),
'days': new_days
}
})
else:
return jsonify({'success': False, 'error': '价格计算异常'}), 500
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': f'升级失败: {str(e)}'}), 500
@app.route('/api/payment/create-order', methods=['POST'])
def create_payment_order():
"""创建支付订单(支持升级和优惠码)"""
"""
创建支付订单(新版:简化逻辑,不再记录升级)
Request Body:
{
"plan_name": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1643,16 +1897,23 @@ def create_payment_order():
if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
# 计算价格(包括升级和优惠码)
price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code)
# 使用新的简化价格计算
price_result = calculate_subscription_price_simple(session['user_id'], plan_name, billing_cycle, promo_code)
if 'error' in price_result:
return jsonify({'success': False, 'error': price_result['error']}), 400
amount = price_result['final_amount']
original_amount = price_result['original_amount']
discount_amount = price_result['discount_amount']
is_upgrade = price_result.get('is_upgrade', False)
subscription_type = price_result.get('subscription_type', 'new') # new 或 renew
# 检查是否为免费升级金额为0
if amount <= 0 and price_result.get('is_upgrade'):
return jsonify({
'success': False,
'error': '当前剩余价值可直接免费升级,请使用免费升级功能',
'should_free_upgrade': True,
'price_info': price_result
}), 400
# 创建订单
try:
@@ -1663,48 +1924,23 @@ def create_payment_order():
amount=amount
)
# 添加扩展字段(使用动态属性
if hasattr(order, 'original_amount') or True: # 兼容性检查
order.original_amount = original_amount
order.discount_amount = discount_amount
order.is_upgrade = is_upgrade
# 添加订阅类型标记(用于前端展示
order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅"
# 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
# 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj:
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
# 如果没有该字段,这行会报错,可以注释掉
try:
order.promo_code_id = promo_obj.id
# 如果是升级,记录原套餐信息
if is_upgrade:
order.upgrade_from_plan = price_result.get('current_plan')
except:
pass # 如果表中没有该字段,跳过
db.session.add(order)
db.session.commit()
# 如果是升级订单,创建升级记录
if is_upgrade and price_result.get('upgrade_type'):
try:
upgrade_record = SubscriptionUpgrade(
user_id=session['user_id'],
order_id=order.id,
from_plan=price_result['current_plan'],
from_cycle=price_result['current_cycle'],
from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None,
to_plan=plan_name,
to_cycle=billing_cycle,
to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30),
remaining_value=price_result['remaining_value'],
upgrade_amount=price_result['upgrade_amount'],
actual_amount=amount,
upgrade_type=price_result['upgrade_type']
)
db.session.add(upgrade_record)
db.session.commit()
except Exception as e:
print(f"创建升级记录失败: {e}")
# 不影响主流程
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500

968
category_tree_openapi.json Normal file
View File

@@ -0,0 +1,968 @@
{
"openapi": "3.0.0",
"info": {
"title": "化工商品分类树API",
"description": "提供SMM和Mysteel化工商品数据的分类树状结构API接口。\n\n## 功能特点\n- 树状数据在服务启动时加载到内存,响应速度快\n- 支持获取完整分类树或按路径查询特定节点\n- SMM数据: 127,509个指标, 最大深度8层\n- Mysteel数据: 272,450个指标, 最大深度10层\n\n## 数据结构\n每个树节点包含:\n- name: 节点名称\n- path: 完整路径(用|分隔)\n- level: 层级深度\n- children: 子节点数组\n- metrics: 该节点下的指标列表(仅叶子节点)\n",
"version": "1.0.0",
"contact": {
"name": "API Support"
}
},
"servers": [
{
"url": "http://localhost:18827",
"description": "本地开发服务器"
},
{
"url": "http://222.128.1.157:18827",
"description": "生产服务器"
}
],
"tags": [
{
"name": "搜索",
"description": "指标搜索相关接口"
},
{
"name": "分类树",
"description": "分类树状结构相关接口"
},
{
"name": "数据查询",
"description": "指标时间序列数据查询接口"
}
],
"paths": {
"/api/search": {
"get": {
"tags": [
"搜索"
],
"summary": "搜索化工商品指标",
"description": "基于Elasticsearch的多关键词模糊搜索,支持智能分词和相关度排序。\n\n## 功能特点\n- **多关键词搜索**: 支持空格分隔多个关键词,自动AND逻辑组合\n- **模糊匹配**: 自动容错1-2个字符的拼写错误\n- **多字段匹配**: 同时搜索指标名称、分类路径等多个字段\n- **相关度排序**: 自动按匹配度评分排序,最相关的结果排在前面\n- **灵活过滤**: 支持按数据源(SMM/Mysteel)和频率(日/周/月)过滤\n\n## 搜索字段权重\n- 指标名称(metric_name): 权重最高 (3x)\n- 分类层级(category_levels): 权重中等 (2x)\n- 分类路径(category_path): 权重中等 (2x)\n\n## 使用场景\n- 用户输入关键词快速查找指标\n- 自动补全和搜索建议\n- 按类别和数据源筛选指标\n\n## 搜索示例\n- 搜索\"电解液 产量\": 查找包含\"电解液\"和\"产量\"的指标\n- 搜索\"硫酸钴\": 查找所有硫酸钴相关指标\n- 搜索\"焦炭 价格 日\": 查找焦炭日度价格数据\n",
"operationId": "searchMetrics",
"parameters": [
{
"name": "keywords",
"in": "query",
"description": "搜索关键词,支持空格分隔多个词。\n\n示例:\n- \"电解液 产量\" - 查找同时包含这两个词的指标\n- \"硫酸钴\" - 查找硫酸钴相关指标\n- \"焦炭 价格\" - 查找焦炭价格数据\n",
"required": true,
"schema": {
"type": "string"
},
"example": "电解液 产量"
},
{
"name": "source",
"in": "query",
"description": "数据源过滤(可选)。\n\n- SMM: 上海有色网数据\n- Mysteel: 我的钢铁网数据\n- 不指定: 搜索所有数据源\n",
"required": false,
"schema": {
"type": "string",
"enum": [
"SMM",
"Mysteel"
]
},
"example": "SMM"
},
{
"name": "frequency",
"in": "query",
"description": "数据频率过滤(可选)。\n\n- 日: 日度数据\n- 周: 周度数据\n- 月: 月度数据\n- 不指定: 搜索所有频率\n",
"required": false,
"schema": {
"type": "string",
"enum": [
"日",
"周",
"月"
]
},
"example": "日"
},
{
"name": "size",
"in": "query",
"description": "返回结果数量限制",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 100
},
"example": 10
}
],
"responses": {
"200": {
"description": "成功返回搜索结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResponse"
},
"examples": {
"基础搜索示例": {
"value": {
"total": 50,
"query": "电解液 产量",
"results": [
{
"source": "SMM",
"metric_id": "12345",
"metric_name": "SMM中国电解液月度产量",
"unit": "吨",
"frequency": "月",
"category_path": "新能源|电解液|产量|SMM中国电解液月度产量",
"description": "",
"score": 15.8
},
{
"source": "SMM",
"metric_id": "12346",
"metric_name": "SMM中国电解液周度产量",
"unit": "吨",
"frequency": "周",
"category_path": "新能源|电解液|产量|SMM中国电解液周度产量",
"description": "",
"score": 14.2
}
]
}
},
"过滤搜索示例": {
"value": {
"total": 15,
"query": "硫酸钴",
"results": [
{
"source": "SMM",
"metric_id": "23456",
"metric_name": "SMM中国硫酸钴月度产量",
"unit": "吨",
"frequency": "月",
"category_path": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量",
"description": "",
"score": 18.5
}
]
}
}
}
}
}
},
"400": {
"description": "请求参数错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "keywords参数不能为空"
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "搜索服务暂时不可用"
}
}
}
}
}
}
},
"/api/category-tree": {
"get": {
"tags": [
"分类树"
],
"summary": "获取分类树(支持深度控制)",
"description": "获取指定数据源的分类树状结构,支持深度控制。\n\n## 使用场景\n- 前端树形组件初始化(默认只加载第一层)\n- 懒加载:用户展开时再加载下一层\n- 级联选择器数据源\n\n## 默认行为\n- **默认只返回第一层** (max_depth=1),大幅减少数据传输量\n- SMM第一层约43个节点,Mysteel第一层约2个节点\n- 完整树数据量: SMM约53MB, Mysteel约152MB\n\n## 推荐用法\n1. 首次加载:不传max_depth(默认1层)\n2. 用户点击节点:调用 /api/category-tree/node 获取子节点\n",
"operationId": "getCategoryTree",
"parameters": [
{
"name": "source",
"in": "query",
"description": "数据源类型",
"required": true,
"schema": {
"type": "string",
"enum": [
"SMM",
"Mysteel"
]
},
"example": "SMM"
},
{
"name": "max_depth",
"in": "query",
"description": "返回的最大层级深度\n- 1: 只返回第一层(默认,推荐)\n- 2: 返回前两层\n- 999: 返回完整树(不推荐,数据量大)\n",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 1
},
"example": 1
}
],
"responses": {
"200": {
"description": "成功返回分类树",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CategoryTreeResponse"
},
"examples": {
"SMM示例": {
"value": {
"source": "SMM",
"total_metrics": 127509,
"tree": [
{
"name": "农业食品农资",
"path": "农业食品农资",
"level": 1,
"children": [
{
"name": "饲料",
"path": "农业食品农资|饲料",
"level": 2,
"children": [],
"metrics": []
}
]
}
]
}
},
"Mysteel示例": {
"value": {
"source": "Mysteel",
"total_metrics": 272450,
"tree": [
{
"name": "钢铁产业",
"path": "钢铁产业",
"level": 1,
"children": []
}
]
}
}
}
}
}
},
"404": {
"description": "未找到指定数据源",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/category-tree/node": {
"get": {
"tags": [
"分类树"
],
"summary": "获取特定节点及其子树",
"description": "根据路径获取树中的特定节点及其所有子节点。\n\n## 使用场景\n- 懒加载:用户点击节点时动态加载子节点\n- 子树查询:获取某个分类下的所有数据\n- 面包屑导航:根据路径定位节点\n\n## 路径格式\n使用竖线(|)分隔层级,例如:\n- 一级: \"钴\"\n- 二级: \"钴|钴化合物\"\n- 三级: \"钴|钴化合物|硫酸钴\"\n",
"operationId": "getCategoryTreeNode",
"parameters": [
{
"name": "path",
"in": "query",
"description": "节点完整路径,用竖线(|)分隔",
"required": true,
"schema": {
"type": "string"
},
"example": "钴|钴化合物|硫酸钴"
},
{
"name": "source",
"in": "query",
"description": "数据源类型",
"required": true,
"schema": {
"type": "string",
"enum": [
"SMM",
"Mysteel"
]
},
"example": "SMM"
}
],
"responses": {
"200": {
"description": "成功返回节点数据",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TreeNode"
},
"example": {
"name": "硫酸钴",
"path": "钴|钴化合物|硫酸钴",
"level": 3,
"children": [
{
"name": "产量",
"path": "钴|钴化合物|硫酸钴|产量",
"level": 4,
"children": [],
"metrics": [
{
"metric_id": "12345",
"metric_name": "SMM中国硫酸钴月度产量",
"source": "SMM",
"frequency": "月",
"unit": "吨",
"description": ""
}
]
}
]
}
}
}
},
"404": {
"description": "未找到指定路径的节点或数据源",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"节点不存在": {
"value": {
"detail": "未找到路径 '钴|不存在的节点' 对应的节点"
}
},
"数据源不存在": {
"value": {
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
}
}
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/metric-data": {
"get": {
"tags": [
"数据查询"
],
"summary": "获取指标时间序列数据",
"description": "根据指标ID查询历史时间序列数据,自动识别数据源(SMM或Mysteel)。\n\n## 功能特点\n- **自动识别数据源**: 无需指定source参数,系统自动查找\n- **灵活的日期范围**: 支持可选的开始/结束日期过滤\n- **数据限制**: 支持limit参数控制返回数据量\n\n## 日期格式支持\n- YYYY-MM-DD (推荐): \"2024-01-01\"\n- YYYYMMDD: \"20240101\"\n- YYYYMMDDHHmmss: \"20240101000000\"(只取日期部分)\n\n## 使用场景\n- 用户点击树节点查看指标数据\n- 图表展示时间序列数据\n- 数据导出和分析\n",
"operationId": "getMetricData",
"parameters": [
{
"name": "metric_id",
"in": "query",
"description": "指标唯一ID",
"required": true,
"schema": {
"type": "string"
},
"example": "12345"
},
{
"name": "start_date",
"in": "query",
"description": "开始日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
"required": false,
"schema": {
"type": "string"
},
"example": "2024-01-01"
},
{
"name": "end_date",
"in": "query",
"description": "结束日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
"required": false,
"schema": {
"type": "string"
},
"example": "2024-12-31"
},
{
"name": "limit",
"in": "query",
"description": "返回数据条数限制(1-10000)",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 10000,
"default": 100
},
"example": 100
}
],
"responses": {
"200": {
"description": "成功返回指标数据",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetricDataResponse"
},
"examples": {
"SMM数据示例": {
"value": {
"metric_id": "12345",
"metric_name": "SMM中国硫酸钴月度产量",
"source": "SMM",
"frequency": "月",
"unit": "吨",
"data": [
{
"date": "2024-12-01",
"value": 12500.5
},
{
"date": "2024-11-01",
"value": 12300.0
},
{
"date": "2024-10-01",
"value": 12100.8
}
],
"total_count": 120
}
},
"Mysteel数据示例": {
"value": {
"metric_id": "A0101010",
"metric_name": "唐山焦炭价格",
"source": "MYSTEEL",
"frequency": "日",
"unit": "元/吨",
"data": [
{
"date": "2024-12-20",
"value": 2350.0
},
{
"date": "2024-12-19",
"value": 2340.0
}
],
"total_count": 365
}
}
}
}
}
},
"404": {
"description": "未找到指定指标",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "未找到指标: metric_id=99999"
}
}
}
},
"400": {
"description": "请求参数错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "limit参数必须在1-10000之间"
}
}
}
},
"500": {
"description": "服务器内部错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"detail": "查询数据失败: [具体错误信息]"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"SearchResponse": {
"type": "object",
"description": "搜索结果响应对象",
"required": [
"total",
"query",
"results"
],
"properties": {
"total": {
"type": "integer",
"description": "搜索结果总数",
"example": 50
},
"query": {
"type": "string",
"description": "查询关键词",
"example": "电解液 产量"
},
"results": {
"type": "array",
"description": "指标列表(按相关度评分降序)",
"items": {
"$ref": "#/components/schemas/MetricInfo"
}
}
}
},
"MetricInfo": {
"type": "object",
"description": "指标信息对象",
"required": [
"source",
"metric_id",
"metric_name",
"unit",
"frequency",
"category_path"
],
"properties": {
"source": {
"type": "string",
"description": "数据源",
"enum": [
"SMM",
"Mysteel"
],
"example": "SMM"
},
"metric_id": {
"type": "string",
"description": "指标唯一ID",
"example": "12345"
},
"metric_name": {
"type": "string",
"description": "指标名称",
"example": "SMM中国硫酸钴月度产量"
},
"unit": {
"type": "string",
"description": "数据单位",
"example": "吨"
},
"frequency": {
"type": "string",
"description": "数据频率",
"enum": [
"日",
"周",
"月"
],
"example": "月"
},
"category_path": {
"type": "string",
"description": "完整分类路径(用|分隔)",
"example": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量"
},
"description": {
"type": "string",
"description": "指标描述备注",
"example": ""
},
"score": {
"type": "number",
"description": "搜索相关度评分(仅搜索结果返回)",
"nullable": true,
"example": 15.8
}
}
},
"CategoryTreeResponse": {
"type": "object",
"description": "分类树响应对象",
"required": [
"source",
"total_metrics",
"tree"
],
"properties": {
"source": {
"type": "string",
"description": "数据源名称",
"enum": [
"SMM",
"Mysteel"
],
"example": "SMM"
},
"total_metrics": {
"type": "integer",
"description": "总指标数量",
"example": 127509
},
"tree": {
"type": "array",
"description": "树的根节点列表",
"items": {
"$ref": "#/components/schemas/TreeNode"
}
}
}
},
"TreeNode": {
"type": "object",
"description": "树节点对象",
"required": [
"name",
"path",
"level",
"has_children"
],
"properties": {
"name": {
"type": "string",
"description": "节点名称",
"example": "钴"
},
"path": {
"type": "string",
"description": "节点完整路径,用竖线分隔",
"example": "钴|钴化合物|硫酸钴"
},
"level": {
"type": "integer",
"description": "节点层级深度(从1开始)",
"minimum": 1,
"example": 3
},
"has_children": {
"type": "boolean",
"description": "是否有子节点(用于前端判断是否可展开)",
"example": true
},
"children": {
"type": "array",
"description": "子节点列表(根据max_depth可能为空数组)",
"items": {
"$ref": "#/components/schemas/TreeNode"
}
},
"metrics": {
"type": "array",
"description": "该节点下的指标列表(通常只有叶子节点有)",
"items": {
"$ref": "#/components/schemas/TreeMetric"
}
}
}
},
"TreeMetric": {
"type": "object",
"description": "树节点中的指标信息",
"required": [
"metric_id",
"metric_name",
"source",
"frequency",
"unit"
],
"properties": {
"metric_id": {
"type": "string",
"description": "指标唯一ID",
"example": "12345"
},
"metric_name": {
"type": "string",
"description": "指标名称",
"example": "SMM中国硫酸钴月度产量"
},
"source": {
"type": "string",
"description": "数据源",
"enum": [
"SMM",
"Mysteel"
],
"example": "SMM"
},
"frequency": {
"type": "string",
"description": "数据频率",
"enum": [
"日",
"周",
"月"
],
"example": "月"
},
"unit": {
"type": "string",
"description": "指标单位",
"example": "吨"
},
"description": {
"type": "string",
"description": "指标描述",
"example": ""
}
}
},
"MetricDataResponse": {
"type": "object",
"description": "指标数据查询响应对象",
"required": [
"metric_id",
"metric_name",
"source",
"frequency",
"unit",
"data",
"total_count"
],
"properties": {
"metric_id": {
"type": "string",
"description": "指标唯一ID",
"example": "12345"
},
"metric_name": {
"type": "string",
"description": "指标名称",
"example": "SMM中国硫酸钴月度产量"
},
"source": {
"type": "string",
"description": "数据源",
"enum": [
"SMM",
"MYSTEEL"
],
"example": "SMM"
},
"frequency": {
"type": "string",
"description": "数据频率",
"enum": [
"日",
"周",
"月"
],
"example": "月"
},
"unit": {
"type": "string",
"description": "数据单位",
"example": "吨"
},
"data": {
"type": "array",
"description": "时间序列数据点列表(按日期倒序)",
"items": {
"$ref": "#/components/schemas/DataPoint"
}
},
"total_count": {
"type": "integer",
"description": "符合条件的数据总条数",
"example": 120
}
}
},
"DataPoint": {
"type": "object",
"description": "单个数据点",
"required": [
"date",
"value"
],
"properties": {
"date": {
"type": "string",
"description": "日期,格式 YYYY-MM-DD",
"example": "2024-01-01"
},
"value": {
"type": "number",
"description": "数值(可能为null)",
"nullable": true,
"example": 1234.56
}
}
},
"ErrorResponse": {
"type": "object",
"description": "错误响应对象",
"required": [
"detail"
],
"properties": {
"detail": {
"type": "string",
"description": "错误详细信息",
"example": "未找到数据源 'XXX' 的树状数据"
}
}
}
},
"examples": {
"SMM完整树示例": {
"summary": "SMM完整树结构示例",
"value": {
"source": "SMM",
"total_metrics": 127509,
"tree": [
{
"name": "农业食品农资",
"path": "农业食品农资",
"level": 1,
"children": [
{
"name": "饲料",
"path": "农业食品农资|饲料",
"level": 2,
"children": []
}
]
},
{
"name": "小金属",
"path": "小金属",
"level": 1,
"children": [
{
"name": "钴",
"path": "小金属|钴",
"level": 2,
"children": [
{
"name": "钴化合物",
"path": "小金属|钴|钴化合物",
"level": 3,
"children": []
}
]
}
]
}
]
}
},
"Mysteel完整树示例": {
"summary": "Mysteel完整树结构示例",
"value": {
"source": "Mysteel",
"total_metrics": 272450,
"tree": [
{
"name": "钢铁产业",
"path": "钢铁产业",
"level": 1,
"children": [
{
"name": "原材料",
"path": "钢铁产业|原材料",
"level": 2,
"children": []
}
]
}
]
}
},
"节点查询示例": {
"summary": "节点查询返回示例",
"value": {
"name": "钴化合物",
"path": "小金属|钴|钴化合物",
"level": 3,
"children": [
{
"name": "硫酸钴",
"path": "小金属|钴|钴化合物|硫酸钴",
"level": 4,
"metrics": [
{
"metric_id": "12345",
"metric_name": "SMM中国硫酸钴月度产量",
"source": "SMM",
"frequency": "月",
"unit": "吨",
"description": ""
}
]
}
]
}
}
}
}
}

View File

@@ -39,6 +39,13 @@ module.exports = {
priority: 30,
reuseExistingChunk: true,
},
// TradingView Lightweight Charts 单独分离(避免被压缩破坏)
lightweightCharts: {
test: /[\\/]node_modules[\\/]lightweight-charts[\\/]/,
name: 'lightweight-charts',
priority: 26,
reuseExistingChunk: true,
},
// 大型图表库分离echarts, d3, apexcharts 等)
charts: {
test: /[\\/]node_modules[\\/](echarts|echarts-for-react|apexcharts|react-apexcharts|recharts|d3|d3-.*)[\\/]/,
@@ -96,8 +103,43 @@ module.exports = {
moduleIds: 'deterministic',
// 最小化配置
minimize: true,
minimizer: [
...webpackConfig.optimization.minimizer,
],
};
// 配置 Terser 插件,保留 lightweight-charts 的方法名
const TerserPlugin = require('terser-webpack-plugin');
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map(plugin => {
if (plugin.constructor.name === 'TerserPlugin') {
const originalOptions = plugin.options || {};
const originalTerserOptions = originalOptions.terserOptions || {};
const originalMangle = originalTerserOptions.mangle || {};
// 只保留 TerserPlugin 有效的配置项
const validOptions = {
test: originalOptions.test,
include: originalOptions.include,
exclude: originalOptions.exclude,
extractComments: originalOptions.extractComments,
parallel: originalOptions.parallel,
minify: originalOptions.minify,
terserOptions: {
...originalTerserOptions,
keep_classnames: /^(IChartApi|ISeriesApi|Re)$/, // 保留 lightweight-charts 的类名
keep_fnames: /^(createChart|addLineSeries|addSeries)$/, // 保留关键方法名
mangle: {
...originalMangle,
reserved: ['createChart', 'addLineSeries', 'addSeries', 'IChartApi', 'ISeriesApi'],
},
},
};
return new TerserPlugin(validOptions);
}
return plugin;
});
// 生产环境禁用 source map 以加快构建(可节省 40-60% 时间)
webpackConfig.devtool = false;
} else {

View File

@@ -0,0 +1,918 @@
# Bytedesk客服系统 - 前端工程师集成手册
**版本**: v1.0
**最后更新**: 2025-01-07
**适用项目**: vf_react
**后端服务器**: http://43.143.189.195
---
## 📋 目录
- [1. 集成概述](#1-集成概述)
- [2. 快速开始5分钟集成](#2-快速开始5分钟集成)
- [3. 详细集成步骤](#3-详细集成步骤)
- [4. 配置说明](#4-配置说明)
- [5. 高级功能](#5-高级功能)
- [6. 样式定制](#6-样式定制)
- [7. 故障排查](#7-故障排查)
- [8. 常见问题FAQ](#8-常见问题faq)
- [9. 性能优化](#9-性能优化)
- [10. 安全注意事项](#10-安全注意事项)
---
## 1. 集成概述
### 1.1 什么是Bytedesk客服系统
Bytedesk是一个开源的在线客服系统为您的网站提供实时客户服务功能。本手册将指导您将Bytedesk客服Widget集成到vf_react项目中。
### 1.2 集成架构
```
┌────────────────────────────────────────────────────────────┐
│ vf_react前端项目 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ App.jsx │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ BytedeskWidget组件 │ │ │
│ │ │ - 动态加载客服脚本 │ │ │
│ │ │ - 显示悬浮客服图标 │ │ │
│ │ │ - 处理用户交互 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
│ HTTP/WebSocket
┌────────────────────────────────────────────────────────────┐
│ Bytedesk后端服务 (43.143.189.195) │
│ - API接口: :9003 │
│ - WebSocket: :9885 │
│ - Nginx反向代理: :80 │
└────────────────────────────────────────────────────────────┘
```
### 1.3 集成特点
-**零侵入**: 不修改vf_react原有代码逻辑
-**即插即用**: 复制文件 + 修改配置即可使用
-**样式隔离**: 使用Shadow DOM不影响全局样式
-**异步加载**: 不阻塞页面渲染
-**跨页面**: 在所有页面显示客服图标
-**响应式**: 自动适配移动端和PC端
---
## 2. 快速开始5分钟集成
### 步骤1: 复制集成文件
`bytedesk-integration`文件夹复制到vf_react项目的`src/`目录下:
```bash
# 在vf_react项目根目录执行
cd D:\【Git】\vf_react
cp -r bytedesk-integration src/
```
文件结构:
```
vf_react/
├── src/
│ ├── bytedesk-integration/ # 客服集成文件夹
│ │ ├── components/
│ │ │ └── BytedeskWidget.jsx # 客服Widget组件
│ │ ├── config/
│ │ │ └── bytedesk.config.js # 配置文件
│ │ ├── App.jsx.example # 集成示例代码
│ │ ├── .env.bytedesk.example # 环境变量示例
│ │ └── 前端工程师集成手册.md # 本手册
│ ├── App.jsx # 您的主App文件
│ └── ...
└── package.json
```
### 步骤2: 配置环境变量
复制环境变量模板到项目根目录并配置:
```bash
# 复制模板
cp src/bytedesk-integration/.env.bytedesk.example .env.local
# 编辑配置文件
vim .env.local
```
**必需配置项**(在.env.local中:
```bash
# Bytedesk服务器地址
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 组织ID由管理员提供
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组ID由管理员提供
REACT_APP_BYTEDESK_SID=df_wg_aftersales
```
> **注意**: ORG和SID需要从管理员处获取或登录后台http://43.143.189.195/admin/查看。
### 步骤3: 集成到App.jsx
打开`src/App.jsx`,参考`App.jsx.example`添加以下代码:
```jsx
// 1. 导入组件和配置(在文件顶部添加)
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
// 2. 获取配置
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
{/* 您的原有代码保持不变 */}
{/* 3. 添加客服Widget在return的JSX最后添加 */}
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
</div>
);
}
export default App;
```
### 步骤4: 启动项目测试
```bash
# 安装依赖(如果需要)
npm install
# 启动开发服务器
npm start
```
打开浏览器,您应该在页面右下角看到客服图标(💬)。
---
## 3. 详细集成步骤
### 3.1 文件说明
#### BytedeskWidget.jsx
React组件负责加载和管理Bytedesk客服Widget。
**主要功能**:
- 动态加载客服脚本https://www.weiyuai.cn/embed/bytedesk-web.js
- 初始化客服Widget
- 生命周期管理(加载、卸载、清理)
- 错误处理
**Props**:
```typescript
interface BytedeskWidgetProps {
config: Object; // 配置对象(必需)
autoLoad?: boolean; // 是否自动加载默认true
onLoad?: (bytedesk) => void; // 加载成功回调
onError?: (error) => void; // 加载失败回调
}
```
#### bytedesk.config.js
配置文件,包含客服系统的所有配置项。
**主要函数**:
- `getBytedeskConfig()`: 获取基础配置
- `getBytedeskConfigWithUser(user)`: 获取带用户信息的配置
- `shouldShowCustomerService(pathname)`: 判断是否在当前页面显示客服
### 3.2 集成方式选择
根据您的需求,选择合适的集成方式:
#### 方式一: 全局集成(推荐)
**适用场景**: 所有页面都需要客服功能
```jsx
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
{/* 您的页面内容 */}
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
</div>
);
}
```
#### 方式二: 按页面显示
**适用场景**: 只在特定页面显示客服(如排除登录页、支付页)
```jsx
import { useLocation } from 'react-router-dom';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
function App() {
const location = useLocation();
const bytedeskConfig = getBytedeskConfig();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<div className="App">
{/* 您的页面内容 */}
{showBytedesk && (
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
)}
</div>
);
}
```
自定义页面规则(修改`bytedesk.config.js`:
```javascript
export const shouldShowCustomerService = (pathname) => {
// 在以下页面显示客服
const allowedPages = [
'/',
'/home',
'/products',
'/pricing',
];
// 在以下页面隐藏客服
const blockedPages = [
'/login',
'/register',
'/payment',
];
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
return allowedPages.some(page => pathname.startsWith(page));
};
```
#### 方式三: 带用户信息集成
**适用场景**: 需要将登录用户信息传递给客服端
```jsx
import { useContext } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfigWithUser } from './bytedesk-integration/config/bytedesk.config';
import { AuthContext } from './contexts/AuthContext';
function App() {
const { user } = useContext(AuthContext);
const bytedeskConfig = getBytedeskConfigWithUser(user);
return (
<div className="App">
{/* 您的页面内容 */}
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
</div>
);
}
```
用户信息格式:
```javascript
const user = {
id: '12345', // 用户ID必需
name: '张三', // 用户名
email: 'user@example.com', // 邮箱
mobile: '13800138000', // 手机号
};
```
---
## 4. 配置说明
### 4.1 环境变量配置
`.env.local`文件中配置(项目根目录):
```bash
# ========== 必需配置 ==========
# 后端服务地址
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 组织ID
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组ID
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# ========== 可选配置 ==========
# 客服类型 (2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2
# 语言 (zh-cn, en, ja, ko)
REACT_APP_BYTEDESK_LOCALE=zh-cn
# 图标位置 (bottom-right, bottom-left, top-right, top-left)
REACT_APP_BYTEDESK_PLACEMENT=bottom-right
# 图标边距(像素)
REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
REACT_APP_BYTEDESK_MARGIN_SIDE=20
# 主题模式 (system, light, dark)
REACT_APP_BYTEDESK_THEME_MODE=system
# 主题色
REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
# 自动弹出(不推荐)
REACT_APP_BYTEDESK_AUTO_POPUP=false
```
### 4.2 代码配置
`bytedesk.config.js`中直接修改:
```javascript
export const bytedeskConfig = {
// API服务地址
apiUrl: 'http://43.143.189.195',
htmlUrl: 'http://43.143.189.195/chat/',
// 客服图标位置
placement: 'bottom-right',
// 边距设置
marginBottom: 20,
marginSide: 20,
// 自动弹出
autoPopup: false,
// 语言设置
locale: 'zh-cn',
// 客服图标配置
bubbleConfig: {
show: true,
icon: '💬', // 可以使用emoji或图片URL
title: '在线客服',
subtitle: '点击咨询',
},
// 主题配置
theme: {
mode: 'system', // light | dark | system
backgroundColor: '#0066FF',
textColor: '#ffffff',
},
// 聊天配置
chatConfig: {
org: 'df_org_uid',
t: '2', // 2=人工客服, 1=机器人
sid: 'df_wg_aftersales',
},
};
```
---
## 5. 高级功能
### 5.1 多工作组支持
根据页面显示不同工作组的客服:
```javascript
// bytedesk.config.js
export const getBytedeskConfigByPath = (pathname) => {
const config = getBytedeskConfig();
// 根据路径选择工作组
if (pathname.startsWith('/sales')) {
return {
...config,
chatConfig: {
...config.chatConfig,
sid: 'df_wg_sales', // 销售组
},
};
} else if (pathname.startsWith('/support')) {
return {
...config,
chatConfig: {
...config.chatConfig,
sid: 'df_wg_support', // 技术支持组
},
};
}
return config; // 默认售后组
};
```
使用示例:
```jsx
import { useLocation } from 'react-router-dom';
import { getBytedeskConfigByPath } from './bytedesk-integration/config/bytedesk.config';
function App() {
const location = useLocation();
const bytedeskConfig = getBytedeskConfigByPath(location.pathname);
return (
<div className="App">
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
</div>
);
}
```
### 5.2 条件性显示
根据用户登录状态或角色显示客服:
```jsx
function App() {
const { user } = useContext(AuthContext);
const bytedeskConfig = getBytedeskConfig();
// 只为普通用户显示客服(管理员不显示)
const showBytedesk = user && user.role === 'customer';
return (
<div className="App">
{showBytedesk && (
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
)}
</div>
);
}
```
### 5.3 事件回调
监听客服系统的加载状态:
```jsx
function App() {
const bytedeskConfig = getBytedeskConfig();
const handleLoad = (bytedesk) => {
console.log('客服系统加载成功', bytedesk);
// 可以在这里执行自定义逻辑
// 例如: 发送统计事件
};
const handleError = (error) => {
console.error('客服系统加载失败', error);
// 可以在这里显示降级方案
// 例如: 显示备用联系方式
};
return (
<div className="App">
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
onLoad={handleLoad}
onError={handleError}
/>
</div>
);
}
```
### 5.4 自定义触发按钮
隐藏默认图标,使用自定义按钮:
```jsx
import { useState } from 'react';
function App() {
const [showBytedesk, setShowBytedesk] = useState(false);
// 隐藏默认图标
const bytedeskConfig = {
...getBytedeskConfig(),
bubbleConfig: {
show: false, // 隐藏默认图标
},
};
return (
<div className="App">
{/* 自定义按钮 */}
<button
onClick={() => setShowBytedesk(true)}
className="custom-service-btn"
>
联系客服
</button>
{showBytedesk && (
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
)}
</div>
);
}
```
---
## 6. 样式定制
### 6.1 修改主题色
在配置中修改主题色:
```javascript
// bytedesk.config.js
theme: {
mode: 'light',
backgroundColor: '#FF6600', // 您的品牌色
textColor: '#ffffff',
},
```
### 6.2 修改图标位置
```javascript
// bytedesk.config.js
placement: 'bottom-left', // 左下角
marginBottom: 30, // 距底部30px
marginSide: 30, // 距左侧30px
```
### 6.3 使用自定义图标
使用图片URL替换emoji:
```javascript
// bytedesk.config.js
bubbleConfig: {
show: true,
icon: 'https://yourdomain.com/images/service-icon.png',
title: '在线客服',
subtitle: '点击咨询',
},
```
### 6.4 样式不冲突
Bytedesk Widget使用Shadow DOM技术样式完全隔离不会影响您的全局CSS。
---
## 7. 故障排查
### 7.1 客服图标不显示
**可能原因**:
1. 环境变量未配置
2. 配置文件路径错误
3. 后端服务未启动
4. 脚本加载失败
**解决方案**:
```bash
# 1. 检查.env.local文件是否存在
ls -la .env.local
# 2. 检查环境变量是否加载
console.log(process.env.REACT_APP_BYTEDESK_API_URL);
# 3. 检查后端服务状态
curl http://43.143.189.195/api/health
# 4. 查看浏览器控制台错误
# 打开浏览器开发者工具 -> Console标签页
```
### 7.2 连接不上后端
**检查清单**:
```bash
# 1. 后端服务是否运行
# 联系后端工程师确认docker容器状态
# 2. 防火墙是否开放
# 确认80端口可访问
# 3. CORS配置
# 后端需要在.env.production中添加您的前端地址:
# BYTEDESK_CORS_ALLOWED_ORIGINS=http://your-frontend-domain.com
```
### 7.3 ORG或SID错误
**获取正确配置**:
1. 登录管理后台: http://43.143.189.195/admin/
2. 导航到"设置" -> "组织信息",复制`组织UID`
3. 导航到"客服管理" -> "工作组",复制`工作组ID`
4. 更新`.env.local`文件
5. 重启开发服务器: `npm start`
### 7.4 开发环境正常,生产环境异常
**检查清单**:
```bash
# 1. 确认生产环境的环境变量
# 查看构建时的配置
# 2. 检查CORS配置
# 后端需要添加生产域名到CORS白名单
# 3. 检查HTTPS/HTTP
# 如果前端使用HTTPS后端也应使用HTTPS
# 4. 查看生产环境日志
npm run build
# 检查构建产物中的配置
```
---
## 8. 常见问题FAQ
### Q1: 客服系统会影响页面性能吗?
**A**: 不会。客服脚本采用异步加载不会阻塞页面渲染。Widget总大小约50KBgzip后首次加载后会被浏览器缓存。
### Q2: 可以在移动端使用吗?
**A**: 可以。Bytedesk Widget完全响应式自动适配移动端和PC端。
### Q3: 是否支持离线消息?
**A**: 支持。用户在客服离线时发送的消息会被保存,客服上线后可以查看。
### Q4: 可以集成到React Native吗
**A**: BytedeskWidget是为Web设计的。React Native需要使用Bytedesk的原生SDK另外提供
### Q5: 如何隐藏特定页面的客服?
**A**: 使用`shouldShowCustomerService`函数见3.2节"方式二")。
### Q6: 可以同时配置多个工作组吗?
**A**: 可以。参考5.1节"多工作组支持"。
### Q7: 用户信息是否安全?
**A**: 是的。所有通信使用WebSocket加密传输用户信息不会被第三方获取。建议生产环境使用HTTPS。
### Q8: 是否需要付费?
**A**: Bytedesk社区版当前使用完全免费License有效期至2040年12月31日。
---
## 9. 性能优化
### 9.1 按需加载
只在需要时加载客服系统:
```jsx
import { useState, useEffect } from 'react';
function App() {
const [loadBytedesk, setLoadBytedesk] = useState(false);
// 延迟5秒加载页面渲染完成后
useEffect(() => {
const timer = setTimeout(() => {
setLoadBytedesk(true);
}, 5000);
return () => clearTimeout(timer);
}, []);
return (
<div className="App">
{/* 您的页面内容 */}
{loadBytedesk && (
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
)}
</div>
);
}
```
### 9.2 Lazy Import
使用React.lazy延迟导入组件
```jsx
import { lazy, Suspense } from 'react';
const BytedeskWidget = lazy(() => import('./bytedesk-integration/components/BytedeskWidget'));
function App() {
return (
<div className="App">
{/* 您的页面内容 */}
<Suspense fallback={null}>
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
</Suspense>
</div>
);
}
```
### 9.3 缓存优化
客服脚本会自动被浏览器缓存,无需额外配置。
---
## 10. 安全注意事项
### 10.1 环境变量安全
```bash
# ❌ 错误: 不要在代码中硬编码配置
const config = {
apiUrl: 'http://43.143.189.195',
org: 'df_org_uid',
};
# ✅ 正确: 使用环境变量
const config = {
apiUrl: process.env.REACT_APP_BYTEDESK_API_URL,
org: process.env.REACT_APP_BYTEDESK_ORG,
};
```
### 10.2 敏感信息保护
```javascript
// ❌ 不要传递敏感信息
const user = {
id: '12345',
password: 'user-password', // 不要传递密码
creditCard: '1234-5678', // 不要传递信用卡
};
// ✅ 只传递必要信息
const user = {
id: '12345',
name: '张三',
email: 'user@example.com',
};
```
### 10.3 HTTPS使用
生产环境强烈建议使用HTTPS:
```bash
# 开发环境
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 生产环境
REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
```
### 10.4 内容安全策略CSP
如果您的项目使用CSP需要允许以下域名
```html
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://www.weiyuai.cn;
connect-src 'self' http://43.143.189.195;
img-src 'self' data: http://43.143.189.195;
"/>
```
---
## 11. 获取帮助
### 11.1 联系方式
- **技术支持**: 访问 http://43.143.189.195/chat/ 在线咨询
- **管理员**: 联系您的项目管理员获取ORG和SID
- **后端工程师**: 联系后端团队确认服务器状态
### 11.2 日志查看
```javascript
// 在浏览器控制台查看Bytedesk日志
// 日志前缀为 [Bytedesk]
// 示例:
[Bytedesk] 开始加载客服Widget...
[Bytedesk] Widget脚本加载成功
[Bytedesk] 初始化Widget
[Bytedesk] Widget初始化成功
```
### 11.3 调试技巧
```javascript
// 1. 检查配置是否正确
console.log('Bytedesk配置:', getBytedeskConfig());
// 2. 检查环境变量
console.log('API URL:', process.env.REACT_APP_BYTEDESK_API_URL);
console.log('ORG:', process.env.REACT_APP_BYTEDESK_ORG);
console.log('SID:', process.env.REACT_APP_BYTEDESK_SID);
// 3. 检查Widget是否加载
console.log('BytedeskWeb对象:', window.BytedeskWeb);
```
---
## 12. 版本历史
| 版本 | 日期 | 更新内容 |
|------|------|---------|
| v1.0 | 2025-01-07 | 初始版本,支持基础集成功能 |
---
## 13. 附录
### 13.1 完整配置示例
```javascript
// bytedesk.config.js - 完整配置
export const bytedeskConfig = {
apiUrl: 'http://43.143.189.195',
htmlUrl: 'http://43.143.189.195/chat/',
placement: 'bottom-right',
marginBottom: 20,
marginSide: 20,
autoPopup: false,
locale: 'zh-cn',
bubbleConfig: {
show: true,
icon: '💬',
title: '在线客服',
subtitle: '点击咨询',
},
theme: {
mode: 'system',
backgroundColor: '#0066FF',
textColor: '#ffffff',
},
chatConfig: {
org: 'df_org_uid',
t: '2',
sid: 'df_wg_aftersales',
},
};
```
### 13.2 文件清单
集成所需的所有文件:
```
bytedesk-integration/
├── components/
│ └── BytedeskWidget.jsx # React组件必需
├── config/
│ └── bytedesk.config.js # 配置文件(必需)
├── App.jsx.example # 集成示例(参考)
├── .env.bytedesk.example # 环境变量示例(参考)
└── 前端工程师集成手册.md # 本手册(参考)
```
---
**祝您集成顺利!**
如有任何问题,请随时联系技术支持。

View File

@@ -0,0 +1,576 @@
# 订阅支付系统重新设计方案
## 📊 问题分析
### 现有系统的问题
1. **价格配置混乱**
- 季付和月付价格相同(配置错误)
- `monthly_price``yearly_price` 字段命名不清晰
- 缺少季付、半年付等周期的价格配置
2. **升级逻辑复杂且不合理**
- 计算剩余价值折算(按天计算 `remaining_value`
- 用户难以理解升级价格
- 续费用户和新用户价格不一致
- 逻辑复杂,容易出错
3. **按钮文案不清晰**
- 已订阅用户应显示"续费 Pro"/"续费 Max"
- 而不是"升级至 Pro"/"切换至 Pro"
4. **数据库表设计问题**
- `SubscriptionUpgrade` 表记录升级,但逻辑过于复杂
- `PaymentOrder` 表缺少必要字段
- 价格配置分散在多个字段
---
## ✨ 新设计方案
### 核心原则
1. **简化续费逻辑**: **续费用户与新用户价格完全一致**,不做任何折算
2. **清晰的价格体系**: 每个套餐每个周期都有明确的价格
3. **统一的用户体验**: 无论是新购还是续费,价格透明一致
4. **独立的订阅记录**: 每次支付都创建新的订阅记录(历史可追溯)
---
## 📐 数据库表设计
### 1. `subscription_plans` - 订阅套餐表(重构)
```sql
CREATE TABLE subscription_plans (
id INT PRIMARY KEY AUTO_INCREMENT,
plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
description TEXT COMMENT '套餐描述',
features JSON COMMENT '功能列表',
-- 价格配置(所有周期价格)
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
-- 状态字段
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
display_order INT DEFAULT 0 COMMENT '展示顺序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_plan_code (plan_code),
INDEX idx_active_order (is_active, display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
```
**示例数据**:
```sql
INSERT INTO subscription_plans (plan_code, plan_name, description, price_monthly, price_quarterly, price_semiannual, price_yearly) VALUES
('pro', 'Pro 专业版', '为专业投资者打造', 299.00, 799.00, 1499.00, 2699.00),
('max', 'Max 旗舰版', '旗舰级体验', 599.00, 1599.00, 2999.00, 5399.00);
```
---
### 2. `user_subscriptions` - 用户订阅记录表(重构)
```sql
CREATE TABLE user_subscriptions (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
-- 订阅基本信息
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max',
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
-- 订阅时间
start_date DATETIME NOT NULL COMMENT '订阅开始时间',
end_date DATETIME NOT NULL COMMENT '订阅结束时间',
-- 订阅状态
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
-- 支付信息
payment_order_id INT COMMENT '关联的支付订单ID',
paid_amount DECIMAL(10,2) NOT NULL COMMENT '实际支付金额',
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
-- 订阅类型
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
-- 自动续费
auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_subscription_id (subscription_id),
INDEX idx_user_current (user_id, is_current),
INDEX idx_status (status),
INDEX idx_end_date (end_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
```
**设计说明**:
- 每次支付都创建新的订阅记录
- 通过 `is_current` 标识当前生效的订阅
- 支持订阅历史追溯
- 续费时记录 `previous_subscription_id` 形成订阅链
---
### 3. `payment_orders` - 支付订单表(重构)
```sql
CREATE TABLE payment_orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
user_id INT NOT NULL COMMENT '用户ID',
-- 订阅信息
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
-- 价格信息
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
-- 优惠码
promo_code_id INT COMMENT '优惠码ID',
promo_code VARCHAR(50) COMMENT '优惠码',
-- 支付信息
payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
payment_channel VARCHAR(50) COMMENT '支付渠道详情',
transaction_id VARCHAR(64) COMMENT '第三方交易号',
qr_code_url TEXT COMMENT '支付二维码URL',
-- 订单状态
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
-- 时间信息
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
paid_at TIMESTAMP NULL COMMENT '支付时间',
expired_at TIMESTAMP NULL COMMENT '过期时间',
-- 备注
remark TEXT COMMENT '备注信息',
INDEX idx_order_no (order_no),
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
```
---
### 4. `promo_codes` - 优惠码表(保持不变,微调)
```sql
CREATE TABLE promo_codes (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码',
description VARCHAR(200) COMMENT '描述',
-- 折扣类型
discount_type VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比), fixed_amount(固定金额)',
discount_value DECIMAL(10,2) NOT NULL COMMENT '折扣值',
-- 适用范围
applicable_plans JSON COMMENT '适用套餐: ["pro", "max"] 或 null(全部)',
applicable_cycles JSON COMMENT '适用周期: ["monthly", "yearly"] 或 null(全部)',
min_amount DECIMAL(10,2) COMMENT '最低消费金额',
-- 使用限制
max_total_uses INT COMMENT '最大使用次数(总)',
max_uses_per_user INT DEFAULT 1 COMMENT '每用户最大使用次数',
current_uses INT DEFAULT 0 COMMENT '当前使用次数',
-- 有效期
valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
valid_until TIMESTAMP NULL COMMENT '过期时间',
-- 状态
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_code (code),
INDEX idx_active (is_active),
INDEX idx_valid_period (valid_from, valid_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
```
---
### 5. `promo_code_usage` - 优惠码使用记录表(保持不变)
```sql
CREATE TABLE promo_code_usage (
id INT PRIMARY KEY AUTO_INCREMENT,
promo_code_id INT NOT NULL,
user_id INT NOT NULL,
order_id INT NOT NULL,
discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_promo_code (promo_code_id),
INDEX idx_user_id (user_id),
INDEX idx_order_id (order_id),
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
```
---
### 6. 删除不必要的表
**删除 `subscription_upgrades` 表** - 不再需要复杂的升级逻辑
---
## 💡 业务逻辑设计
### 1. 价格计算逻辑(简化版)
```python
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None):
"""
计算订阅价格(新购和续费价格完全一致)
Args:
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
promo_code: 优惠码(可选)
Returns:
dict: {
'plan_code': 'pro',
'billing_cycle': 'yearly',
'original_price': 2699.00,
'discount_amount': 0,
'final_amount': 2699.00,
'promo_code': None,
'promo_error': None
}
"""
# 1. 查询套餐价格
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
if not plan:
return {'error': '套餐不存在'}
# 2. 获取对应周期的价格
price_field = f'price_{billing_cycle}'
original_price = getattr(plan, price_field, 0)
if original_price <= 0:
return {'error': '价格配置错误'}
result = {
'plan_code': plan_code,
'plan_name': plan.plan_name,
'billing_cycle': billing_cycle,
'original_price': float(original_price),
'discount_amount': 0,
'final_amount': float(original_price),
'promo_code': None,
'promo_error': None
}
# 3. 应用优惠码(如果有)
if promo_code:
promo, error = validate_promo_code(promo_code, plan_code, billing_cycle, original_price, user_id)
if promo:
discount = calculate_discount(promo, original_price)
result['discount_amount'] = float(discount)
result['final_amount'] = float(original_price - discount)
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result
```
**关键点**:
- ✅ 不再计算 `remaining_value`(剩余价值)
- ✅ 不再区分新购/续费价格
- ✅ 逻辑简单,易于维护
- ✅ 用户体验清晰透明
---
### 2. 创建订单逻辑
```python
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None):
"""
创建订阅支付订单
"""
# 1. 计算价格
price_result = calculate_subscription_price(plan_code, billing_cycle, promo_code)
if 'error' in price_result:
return {'success': False, 'error': price_result['error']}
# 2. 判断是新购还是续费
current_sub = get_current_subscription(user_id)
subscription_type = 'new'
if current_sub and current_sub.plan_code in ['pro', 'max']:
subscription_type = 'renew'
# 3. 创建支付订单
order = PaymentOrder(
order_no=generate_order_no(user_id),
user_id=user_id,
plan_code=plan_code,
billing_cycle=billing_cycle,
subscription_type=subscription_type,
original_price=price_result['original_price'],
discount_amount=price_result['discount_amount'],
final_amount=price_result['final_amount'],
promo_code=promo_code,
status='pending',
expired_at=datetime.now() + timedelta(minutes=30)
)
db.session.add(order)
db.session.commit()
# 4. 生成支付二维码(微信支付)
qr_code_url = generate_wechat_qr_code(order)
order.qr_code_url = qr_code_url
db.session.commit()
return {'success': True, 'order': order.to_dict()}
```
---
### 3. 支付成功后的订阅激活逻辑
```python
def activate_subscription_after_payment(order_id):
"""
支付成功后激活订阅
"""
order = PaymentOrder.query.get(order_id)
if not order or order.status != 'paid':
return {'success': False, 'error': '订单状态错误'}
user_id = order.user_id
plan_code = order.plan_code
billing_cycle = order.billing_cycle
# 1. 计算订阅周期
cycle_days = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
days = cycle_days.get(billing_cycle, 30)
# 2. 获取当前订阅
current_sub = UserSubscription.query.filter_by(
user_id=user_id,
is_current=True
).first()
# 3. 计算开始和结束时间
now = datetime.now()
if current_sub and current_sub.end_date > now:
# 续费:从当前订阅结束时间开始
start_date = current_sub.end_date
else:
# 新购:从当前时间开始
start_date = now
end_date = start_date + timedelta(days=days)
# 4. 创建新订阅记录
new_subscription = UserSubscription(
user_id=user_id,
subscription_id=generate_subscription_id(),
plan_code=plan_code,
billing_cycle=billing_cycle,
start_date=start_date,
end_date=end_date,
status='active',
is_current=True,
payment_order_id=order.id,
paid_amount=order.final_amount,
original_price=order.original_price,
discount_amount=order.discount_amount,
subscription_type=order.subscription_type,
previous_subscription_id=current_sub.subscription_id if current_sub else None
)
# 5. 将旧订阅标记为非当前
if current_sub:
current_sub.is_current = False
db.session.add(new_subscription)
db.session.commit()
return {'success': True, 'subscription': new_subscription.to_dict()}
```
**关键特性**:
- ✅ 续费时从**当前订阅结束时间**开始,避免浪费
- ✅ 每次支付都创建新的订阅记录
- ✅ 保留历史订阅记录(通过 `previous_subscription_id` 形成链)
- ✅ 逻辑清晰,易于理解
---
### 4. 按钮文案逻辑
```python
def get_subscription_button_text(user, plan_code, billing_cycle):
"""
获取订阅按钮文字
Args:
user: 用户对象
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期
Returns:
str: 按钮文字
"""
current_sub = get_current_subscription(user.id)
# 1. 如果没有订阅或订阅已过期
if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
return f"选择 {get_plan_display_name(plan_code)}"
# 2. 如果是当前套餐且周期相同
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
return f"续费 {get_plan_display_name(plan_code)}"
# 3. 如果是当前套餐但周期不同
if current_sub.plan_code == plan_code:
return f"切换至{get_cycle_display_name(billing_cycle)}"
# 4. 如果是不同套餐
return f"选择 {get_plan_display_name(plan_code)}"
def get_plan_display_name(plan_code):
names = {'pro': 'Pro 专业版', 'max': 'Max 旗舰版'}
return names.get(plan_code, plan_code)
def get_cycle_display_name(billing_cycle):
names = {
'monthly': '月付',
'quarterly': '季付',
'semiannual': '半年付',
'yearly': '年付'
}
return names.get(billing_cycle, billing_cycle)
```
**示例**:
- 免费用户看 Pro 年付: "选择 Pro 专业版"
- Pro 月付用户看 Pro 年付: "切换至年付"
- Pro 年付用户看 Pro 年付: "续费 Pro 专业版"
- Pro 用户看 Max 年付: "选择 Max 旗舰版"
---
## 📊 价格配置示例
### Pro 专业版价格设定
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|---------|------|------|------|---------|
| 月付 | ¥299 | - | - | ¥299 |
| 季付(3个月) | ¥799 | ¥897 | 11% | ¥266 |
| 半年付(6个月) | ¥1499 | ¥1794 | 16% | ¥250 |
| 年付(12个月) | ¥2699 | ¥3588 | 25% | ¥225 |
### Max 旗舰版价格设定
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|---------|------|------|------|---------|
| 月付 | ¥599 | - | - | ¥599 |
| 季付(3个月) | ¥1599 | ¥1797 | 11% | ¥533 |
| 半年付(6个月) | ¥2999 | ¥3594 | 17% | ¥500 |
| 年付(12个月) | ¥5399 | ¥7188 | 25% | ¥450 |
---
## 🔄 迁移方案
### 数据迁移 SQL
参见 `database_migration.sql`
### 代码迁移步骤
1. **备份现有数据库**
2. **执行数据库迁移 SQL**
3. **更新数据库模型** (`models.py`)
4. **更新价格计算逻辑** (`calculate_price.py`)
5. **更新 API 路由** (`routes.py`)
6. **更新前端组件** (`SubscriptionContentNew.tsx`)
7. **测试完整流程**
8. **灰度发布**
---
## ✅ 优势总结
### 相比旧系统的改进
1. **价格透明** - 续费用户和新用户价格完全一致
2. **逻辑简化** - 不再计算剩余价值,代码减少 50%+
3. **易于理解** - 用户体验更清晰
4. **灵活扩展** - 轻松添加新的计费周期
5. **历史追溯** - 完整的订阅历史记录
6. **数据完整** - 每次支付都有完整的记录
### 用户体验改进
1. **按钮文案清晰** - "续费 Pro"/"选择 Pro"明确表达意图
2. **价格一致性** - 所有用户看到的价格都一样
3. **无隐藏费用** - 不会因为"升级折算"产生困惑
4. **透明计费** - 支付金额 = 显示价格 - 优惠码折扣
---
## 📝 后续优化建议
1. **自动续费** - 到期前自动扣款续费
2. **订阅提醒** - 到期前 7 天、3 天、1 天发送通知
3. **订阅暂停** - 允许用户暂停订阅
4. **订阅降级** - 从 Max 降级到 Pro当前周期结束后生效
5. **发票管理** - 支持开具电子发票
6. **支付方式扩展** - 支持支付宝、银行卡等
---
**设计时间**: 2025-11-19
**设计者**: Claude Code
**版本**: v2.0.0

View File

@@ -1,841 +0,0 @@
# PostHog 事件追踪实施总结
## ✅ 已完成的追踪
### 1. Home 页面(首页/落地页)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `LANDING_PAGE_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
- `is_authenticated` - 是否已登录
- `user_id` - 用户ID如果已登录
#### 🎯 功能卡片点击
- **事件**: `FEATURE_CARD_CLICKED`
- **触发时机**: 用户点击任何功能卡片
- **属性**:
- `feature_id` - 功能IDnews-catalyst, concepts, stocks, etc.
- `feature_title` - 功能标题
- `feature_url` - 目标URL
- `is_featured` - 是否为推荐功能(新闻中心为 true
- `link_type` - 链接类型internal/external
**追踪的6个核心功能**:
1. **新闻中心** (`news-catalyst`) - 推荐功能,黄色边框
2. **概念中心** (`concepts`)
3. **个股信息汇总** (`stocks`)
4. **涨停板块分析** (`limit-analyse`)
5. **个股罗盘** (`company`)
6. **模拟盘交易** (`trading-simulation`)
---
### 2. StockOverview 页面(个股中心)✅ 已完成
**注意**:个股中心页面已完全实现 PostHog 追踪,通过 `src/views/StockOverview/hooks/useStockOverviewEvents.js` Hook。
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `STOCK_OVERVIEW_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
#### 📊 市场统计数据查看
- **事件**: `STOCK_LIST_VIEWED`
- **触发时机**: 加载市场统计数据
- **属性**:
- `total_market_cap` - 总市值
- `total_volume` - 总成交量
- `rising_stocks` - 上涨股票数
- `falling_stocks` - 下跌股票数
- `data_date` - 数据日期
#### 🔍 搜索追踪
- **事件**: `SEARCH_INITIATED` / `STOCK_SEARCHED`
- **触发时机**: 用户输入搜索、完成搜索
- **属性**:
- `query` - 搜索关键词
- `result_count` - 搜索结果数量
- `has_results` - 是否有结果
- `context` - 固定为 'stock_overview'
#### 🎯 搜索结果点击
- **事件**: `SEARCH_RESULT_CLICKED`
- **触发时机**: 用户点击搜索结果
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `exchange` - 交易所
- `position` - 在搜索结果中的位置
- `context` - 固定为 'stock_overview'
#### 🔥 概念卡片点击
- **事件**: `CONCEPT_CLICKED`
- **触发时机**: 用户点击热门概念卡片
- **属性**:
- `concept_name` - 概念名称
- `concept_code` - 概念代码
- `change_percent` - 涨跌幅
- `stock_count` - 股票数量
- `rank` - 排名
- `source` - 固定为 'daily_hot_concepts'
#### 🏷️ 概念股票标签点击
- **事件**: `CONCEPT_STOCK_CLICKED`
- **触发时机**: 点击概念下的股票标签
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `concept_name` - 所属概念
- `source` - 固定为 'daily_hot_concepts_tag'
#### 📊 热力图股票点击
- **事件**: `STOCK_CLICKED`
- **触发时机**: 点击热力图中的股票
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `change_percent` - 涨跌幅
- `market_cap_range` - 市值区间
- `source` - 固定为 'market_heatmap'
#### 📅 日期选择变化
- **事件**: `SEARCH_FILTER_APPLIED`
- **触发时机**: 用户选择不同的交易日期
- **属性**:
- `filter_type` - 固定为 'date'
- `filter_value` - 新选择的日期
- `previous_value` - 之前的日期
- `context` - 固定为 'stock_overview'
**实施方式**: Custom Hook (`useStockOverviewEvents.js`) 已集成
---
### 3. Concept 页面(概念中心)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `CONCEPT_CENTER_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
#### 🔍 搜索查询
- **事件**: `SEARCH_QUERY_SUBMITTED`
- **触发时机**: 用户搜索概念
- **属性**:
- `query` - 搜索关键词
- `category` - 固定为 'concept'
- `result_count` - 搜索结果数量
- `has_results` - 是否有结果
#### 🎚️ 筛选追踪
- **事件**: `SEARCH_FILTER_APPLIED`
- **触发时机**: 用户更改筛选条件
- **属性**:
- `filter_type` - 筛选类型sort/date
- `filter_value` - 筛选值
- `previous_value` - 之前的值
- `context` - 固定为 'concept_center'
**支持的筛选类型**:
1. **排序** (`sort`): 涨跌幅/相关度/股票数量/概念名称
2. **日期范围** (`date`): 选择交易日期
#### 🎯 概念卡片点击
- **事件**: `CONCEPT_CLICKED`
- **触发时机**: 用户点击概念卡片
- **属性**:
- `concept_id` - 概念ID
- `concept_name` - 概念名称
- `change_percent` - 涨跌幅
- `stock_count` - 股票数量
- `position` - 在列表中的位置
- `source` - 固定为 'concept_center_list'
#### 👀 查看个股
- **事件**: `CONCEPT_STOCKS_VIEWED`
- **触发时机**: 用户点击"查看个股"按钮
- **属性**:
- `concept_name` - 概念名称
- `stock_count` - 股票数量
- `source` - 固定为 'concept_center'
#### 🏷️ 概念股票点击
- **事件**: `CONCEPT_STOCK_CLICKED`
- **触发时机**: 点击概念股票表格中的股票
- **属性**:
- `stock_code` - 股票代码
- `stock_name` - 股票名称
- `concept_name` - 所属概念
- `source` - 固定为 'concept_center_stock_table'
#### 📊 历史时间轴查看
- **事件**: `CONCEPT_TIMELINE_VIEWED`
- **触发时机**: 用户点击"历史时间轴"按钮
- **属性**:
- `concept_id` - 概念ID
- `concept_name` - 概念名称
- `source` - 固定为 'concept_center'
#### 📄 翻页追踪
- **事件**: `NEWS_LIST_VIEWED`
- **触发时机**: 用户翻页
- **属性**:
- `page` - 页码
- `filters` - 当前筛选条件
- `sort` - 排序方式
- `has_query` - 是否有搜索词
- `date` - 日期
- `context` - 固定为 'concept_center'
#### 🔄 视图模式切换
- **事件**: `VIEW_MODE_CHANGED`
- **触发时机**: 用户切换网格/列表视图
- **属性**:
- `view_mode` - 新视图模式grid/list
- `previous_mode` - 之前的模式
- `context` - 固定为 'concept_center'
---
### 4. Company 页面(公司详情/个股罗盘)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `COMPANY_PAGE_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
- `stock_code` - 当前查看的股票代码
#### 🔍 股票搜索
- **事件**: `STOCK_SEARCHED`
- **触发时机**: 用户输入股票代码并查询
- **属性**:
- `query` - 搜索的股票代码
- `stock_code` - 股票代码
- `previous_stock_code` - 之前查看的股票代码
- `context` - 固定为 'company_page'
#### 🔄 Tab 切换
- **事件**: `TAB_CHANGED`
- **触发时机**: 用户切换不同的 Tab
- **属性**:
- `tab_index` - Tab 索引0-3
- `tab_name` - Tab 名称(公司概览/股票行情/财务全景/盈利预测)
- `previous_tab_index` - 之前的 Tab 索引
- `stock_code` - 当前股票代码
- `context` - 固定为 'company_page'
**支持的 Tab**:
1. **公司概览** (index 0): 公司基本信息
2. **股票行情** (index 1): 实时行情数据
3. **财务全景** (index 2): 财务报表分析
4. **盈利预测** (index 3): 盈利预测数据
#### ⭐ 自选股管理
- **事件**: `WATCHLIST_ADDED` / `WATCHLIST_REMOVED`
- **触发时机**: 用户添加/移除自选股
- **属性**:
- `stock_code` - 股票代码
- `source` - 固定为 'company_page'
---
### 5. Community 页面(新闻催化分析)
**已实施的追踪事件**:
#### 📄 页面浏览
- **事件**: `COMMUNITY_PAGE_VIEWED`
- **触发时机**: 页面加载
- **属性**:
- `timestamp` - 访问时间
- `has_hot_events` - 是否有热点事件
- `has_keywords` - 是否有热门关键词
#### 🔍 搜索追踪
- **事件**: `SEARCH_QUERY_SUBMITTED`
- **触发时机**: 用户输入搜索关键词
- **属性**:
- `query` - 搜索关键词
- `category` - 分类(固定为 'news'
- `previous_query` - 上一次搜索词
#### 🎚️ 筛选追踪
- **事件**: `SEARCH_FILTER_APPLIED`
- **触发时机**: 用户更改筛选条件
- **属性**:
- `filter_type` - 筛选类型sort/importance/date_range/industry
- `filter_value` - 筛选值
- `previous_value` - 上一次的值
**支持的筛选类型**:
1. **排序** (`sort`): 最新/最热/重要性
2. **重要性** (`importance`): 全部/高/中/低
3. **时间范围** (`date_range`): 今天/近7天/近30天
4. **行业** (`industry`): 各行业代码
#### 🗞️ 新闻点击追踪
- **事件**: `NEWS_ARTICLE_CLICKED`
- **触发时机**: 用户点击新闻事件
- **属性**:
- `event_id` - 事件ID
- `event_title` - 事件标题
- `importance` - 重要性等级
- `source` - 来源(固定为 'community_page'
- `has_stocks` - 是否包含相关股票
- `has_concepts` - 是否包含相关概念
#### 📖 详情查看追踪
- **事件**: `NEWS_DETAIL_OPENED`
- **触发时机**: 用户点击"查看详情"
- **属性**:
- `event_id` - 事件ID
- `source` - 来源(固定为 'community_page'
#### 📄 翻页追踪
- **事件**: `NEWS_LIST_VIEWED`
- **触发时机**: 用户翻页
- **属性**:
- `page` - 页码
- `filters` - 当前筛选条件
- `sort` - 排序方式
- `importance` - 重要性
- `has_query` - 是否有搜索词
---
## 🛠️ 实施方式
### 方案Custom Hook 集成(推荐)
**优势**:
- ✅ 集中管理,易于维护
- ✅ 自动追踪,无需修改组件
- ✅ 符合关注点分离原则
- ✅ 便于测试和调试
### 修改的文件
#### 0. `src/views/StockOverview/hooks/useStockOverviewEvents.js` ✅
**文件已存在**,无需修改。已完整实现个股中心的所有追踪事件。
#### 1. `src/views/Concept/hooks/useConceptEvents.js`
**新建 Hook 文件**:
```javascript
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
```
**提供的追踪函数**:
- `trackConceptSearched()` - 搜索概念
- `trackFilterApplied()` - 筛选变化
- `trackConceptClicked()` - 概念点击
- `trackConceptStocksViewed()` - 查看个股
- `trackConceptStockClicked()` - 点击概念股票
- `trackConceptTimelineViewed()` - 历史时间轴
- `trackPageChange()` - 翻页
- `trackViewModeChanged()` - 视图切换
#### 2. `src/views/Company/hooks/useCompanyEvents.js`
**新建 Hook 文件**:
```javascript
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
```
**提供的追踪函数**:
- `trackStockSearched()` - 股票搜索
- `trackTabChanged()` - Tab 切换
- `trackWatchlistAdded()` - 加入自选
- `trackWatchlistRemoved()` - 移除自选
#### 3. `src/views/Company/index.js`
**添加的导入**:
```javascript
import { useCompanyEvents } from './hooks/useCompanyEvents';
```
**添加的 Hook**:
```javascript
const {
trackStockSearched,
trackTabChanged,
trackWatchlistAdded,
trackWatchlistRemoved,
} = useCompanyEvents({ stockCode });
```
**添加的 State**:
```javascript
const [currentTabIndex, setCurrentTabIndex] = useState(0);
```
**修改的函数**:
1. **`handleSearch`**: 追踪股票搜索
2. **`handleWatchlistToggle`**: 追踪自选股添加/移除
3. **Tabs `onChange`**: 追踪 Tab 切换
#### 4. `src/views/Concept/index.js`
**添加的导入**:
```javascript
import { useConceptEvents } from './hooks/useConceptEvents';
```
**添加的 Hook**:
```javascript
const {
trackConceptSearched,
trackFilterApplied,
trackConceptClicked,
trackConceptStocksViewed,
trackConceptStockClicked,
trackConceptTimelineViewed,
trackPageChange,
trackViewModeChanged,
} = useConceptEvents({ navigate });
```
**修改的函数**:
1. **`handleSearch`**: 追踪搜索查询
2. **`handleSortChange`**: 追踪排序变化
3. **`handleDateChange`**: 追踪日期变化
4. **`handlePageChange`**: 追踪翻页
5. **`handleConceptClick`**: 追踪概念点击
6. **`handleViewStocks`**: 追踪查看个股
7. **`handleViewContent`**: 追踪历史时间轴
8. **视图切换按钮**: 追踪网格/列表切换
#### 3. `src/views/Home/HomePage.js`
**添加的导入**:
```javascript
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
import { ACQUISITION_EVENTS } from '../../lib/constants';
```
**添加的 Hook**:
```javascript
const { track } = usePostHogTrack();
```
**添加的 useEffect**(页面浏览追踪):
```javascript
useEffect(() => {
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
timestamp: new Date().toISOString(),
is_authenticated: isAuthenticated,
user_id: user?.id || null,
});
}, [track, isAuthenticated, user?.id]);
```
**修改的函数**:
- **`handleProductClick`**: 从接收 URL 改为接收完整 feature 对象,添加追踪逻辑
**修改后的代码**:
```javascript
const handleProductClick = useCallback((feature) => {
// 🎯 PostHog 追踪:功能卡片点击
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
feature_id: feature.id,
feature_title: feature.title,
feature_url: feature.url,
is_featured: feature.featured || false,
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
});
// 原有导航逻辑
if (feature.url.startsWith('http')) {
window.open(feature.url, '_blank');
} else {
navigate(feature.url);
}
}, [track, navigate]);
```
**更新的 onClick 事件**:
```javascript
// 从
onClick={() => handleProductClick(coreFeatures[0].url)}
// 改为
onClick={() => handleProductClick(coreFeatures[0])}
```
#### 1. `src/views/Community/hooks/useEventFilters.js`
**添加的导入**:
```javascript
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
```
**添加的Hook**:
```javascript
const { track } = usePostHogTrack();
```
**修改的函数**:
1. **`updateFilters`**: 追踪搜索和筛选
2. **`handlePageChange`**: 追踪翻页
3. **`handleEventClick`**: 追踪新闻点击
4. **`handleViewDetail`**: 追踪详情查看
#### 2. `src/views/Community/index.js`
**添加的导入**:
```javascript
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../lib/constants';
```
**添加的Hook**:
```javascript
const { track } = usePostHogTrack();
```
**添加的useEffect**:
```javascript
useEffect(() => {
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
timestamp: new Date().toISOString(),
has_hot_events: hotEvents && hotEvents.length > 0,
has_keywords: popularKeywords && popularKeywords.length > 0,
});
}, [track]);
```
---
## 📊 追踪效果示例
### 用户行为路径示例
**首页转化路径**:
```
1. 游客访问首页
→ 触发: LANDING_PAGE_VIEWED
→ 属性: { is_authenticated: false, user_id: null }
2. 点击"新闻中心"功能卡片
→ 触发: FEATURE_CARD_CLICKED
→ 属性: { feature_id: "news-catalyst", feature_title: "新闻中心", is_featured: true, link_type: "internal" }
3. 进入 Community 页面
→ 触发: COMMUNITY_PAGE_VIEWED
```
**Community 页面行为路径**:
```
1. 用户进入 Community 页面
→ 触发: COMMUNITY_PAGE_VIEWED
2. 用户搜索 "人工智能"
→ 触发: SEARCH_QUERY_SUBMITTED
→ 属性: { query: "人工智能", category: "news" }
3. 用户筛选 "重要性:高"
→ 触发: SEARCH_FILTER_APPLIED
→ 属性: { filter_type: "importance", filter_value: "high" }
4. 用户点击第一条新闻
→ 触发: NEWS_ARTICLE_CLICKED
→ 属性: { event_id: "123", event_title: "...", importance: "high", source: "community_page" }
5. 用户翻到第2页
→ 触发: NEWS_LIST_VIEWED
→ 属性: { page: 2, filters: { sort: "new", importance: "high", has_query: true } }
6. 用户点击"查看详情"
→ 触发: NEWS_DETAIL_OPENED
→ 属性: { event_id: "456", source: "community_page" }
```
---
## 🧪 测试方法
### 1. 使用 Redux DevTools
1. 打开应用:`npm start`
2. 打开浏览器 Redux DevTools
3. 筛选 `posthog/trackEvent` actions
4. 执行各种操作
5. 查看追踪的事件和属性
### 2. 控制台日志
开发环境下PostHog 会自动输出日志:
```
📍 Event tracked: Community Page Viewed { timestamp: "...", has_hot_events: true }
📍 Event tracked: Search Query Submitted { query: "人工智能", category: "news" }
📍 Event tracked: Search Filter Applied { filter_type: "importance", filter_value: "high" }
```
### 3. PostHog Dashboard
1. 登录 PostHog 后台
2. 查看 "Events" 页面
3. 筛选 Community 相关事件:
- `Community Page Viewed`
- `Search Query Submitted`
- `Search Filter Applied`
- `News Article Clicked`
- `News List Viewed`
---
## 📈 数据分析建议
### 1. 搜索行为分析
**问题**: 用户最常搜索什么?
**方法**:
- 筛选 `SEARCH_QUERY_SUBMITTED` 事件
-`query` 属性分组
- 查看 Top 关键词
### 2. 筛选偏好分析
**问题**: 用户更喜欢什么排序方式?
**方法**:
- 筛选 `SEARCH_FILTER_APPLIED` 事件
-`filter_type: "sort"` 筛选
-`filter_value` 分组统计
### 3. 新闻热度分析
**问题**: 哪些新闻最受欢迎?
**方法**:
- 筛选 `NEWS_ARTICLE_CLICKED` 事件
-`event_id` 分组
- 统计点击次数
### 4. 用户旅程分析
**问题**: 用户从搜索到点击的转化率?
**方法**:
- 创建漏斗:
1. `COMMUNITY_PAGE_VIEWED`
2. `SEARCH_QUERY_SUBMITTED`
3. `NEWS_ARTICLE_CLICKED`
- 分析每一步的流失率
---
## 🔧 扩展计划
### 下一步:其他页面追踪
按优先级排序:
1. **Concept概念中心** ⭐⭐⭐
- 搜索概念
- 点击概念卡片
- 查看概念详情
- 点击概念内股票
2. **StockOverview个股中心** ⭐⭐⭐
- 搜索股票
- 点击股票卡片
- 查看股票详情
- 切换 Tab
3. **LimitAnalyse涨停分析** ⭐⭐
- 进入页面
- 点击涨停板块
- 展开板块详情
- 点击涨停个股
4. **TradingSimulation模拟盘** ⭐⭐
- 进入模拟盘
- 下单操作
- 查看持仓
- 查看历史
5. **Company公司详情**
- 查看公司概览
- 查看财务全景
- 查看盈利预测
- Tab 切换
---
## 💡 最佳实践
### 1. 属性命名规范
- 使用 **snake_case** 命名(与 PostHog 推荐一致)
- 属性名要 **描述性强**,易于理解
- 使用 **布尔值** 表示是/否has_xxx, is_xxx
- 使用 **枚举值** 表示类别filter_type: "sort"
### 2. 事件追踪原则
- **追踪用户意图**,而不仅仅是点击
- **添加上下文**帮助分析previous_value, source
- **保持一致性**,相似事件使用相似属性
- **避免敏感信息**,不追踪用户隐私数据
### 3. 性能优化
- 使用 **`usePostHogTrack`** 而不是 `usePostHogRedux`
- 更轻量,只订阅追踪功能
- 避免不必要的重渲染
-**Custom Hooks** 中集成,而不是每个组件
- 集中管理,易于维护
- 减少重复代码
---
## ⚠️ 注意事项
### 1. 依赖管理
确保 `useCallback` 的依赖数组包含 `track`
```javascript
// ✅ 正确
const handleClick = useCallback(() => {
track(EVENT_NAME, { ... });
}, [track]);
// ❌ 错误(缺少 track
const handleClick = useCallback(() => {
track(EVENT_NAME, { ... });
}, []);
```
### 2. 事件去重
避免重复追踪相同事件:
```javascript
// ✅ 正确(只在值变化时追踪)
if (newFilters.sort !== filters.sort) {
track(SEARCH_FILTER_APPLIED, { ... });
}
// ❌ 错误(每次都追踪)
track(SEARCH_FILTER_APPLIED, { ... });
```
### 3. 空值处理
使用安全的属性访问:
```javascript
// ✅ 正确
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0)
// ❌ 错误(可能报错)
has_stocks: event.related_stocks.length > 0
```
---
## 📚 参考资料
- **PostHog Events 文档**: https://posthog.com/docs/data/events
- **PostHog Properties 文档**: https://posthog.com/docs/data/properties
- **Redux PostHog 集成**: `POSTHOG_REDUX_INTEGRATION.md`
- **事件常量定义**: `src/lib/constants.js`
---
## 🎉 总结
### 已实现的功能
- ✅ Home 页面追踪2个事件
- ✅ StockOverview 页面完整追踪10个事件✨ 已完成
- ✅ Concept 页面完整追踪9个事件
- ✅ Company 页面完整追踪5个事件
- ✅ Community 页面完整追踪7个事件
- ✅ Custom Hook 集成方案
- ✅ Redux DevTools 调试支持
- ✅ 详细的事件属性
### 追踪的用户行为
**Home 页面**:
1. **页面访问** - 了解流量来源、登录转化率
2. **功能卡片点击** - 识别最受欢迎的功能
3. **推荐功能效果** - 分析特色功能(新闻中心)的点击率
**StockOverview 页面** ✨:
1. **页面访问** - 了解个股中心流量
2. **搜索行为** - 股票搜索、搜索结果点击
3. **概念交互** - 热门概念点击、概念股票标签点击
4. **热力图交互** - 热力图中股票点击
5. **数据筛选** - 日期选择变化
6. **市场统计** - 市场数据查看
**Concept 页面**:
1. **页面访问** - 了解概念中心流量
2. **搜索行为** - 概念搜索、搜索结果数量
3. **筛选偏好** - 排序方式、日期选择
4. **概念交互** - 概念点击、位置追踪
5. **个股查看** - 查看个股、股票点击
6. **时间轴查看** - 历史时间轴
7. **翻页行为** - 优化分页逻辑
8. **视图切换** - 网格/列表偏好
**Company 页面**:
1. **页面访问** - 了解公司详情页流量
2. **股票搜索** - 用户查询哪些股票
3. **Tab 切换** - 用户最关注哪个 Tab概览/行情/财务/预测)
4. **自选股管理** - 自选股添加/移除行为
5. **股票切换** - 分析用户查看股票的路径
**Community 页面**:
1. **页面访问** - 了解流量来源
2. **搜索行为** - 了解用户需求
3. **筛选偏好** - 优化默认设置
4. **内容点击** - 识别热门内容
5. **详情查看** - 分析用户兴趣
6. **翻页行为** - 优化分页逻辑
### 下一步计划
1. ~~在关键页面实施追踪Home, StockOverview, Concept, Company, Community~~ ✅ 已完成
2. **下一步**:其他页面追踪
- LimitAnalyse涨停分析⭐⭐
- TradingSimulation模拟盘⭐⭐
3. 创建 PostHog Dashboard 和 Insights
4. 设置用户行为漏斗分析
5. 配置 Feature Flags 进行 A/B 测试
---
**Home, StockOverview, Concept, Company, Community 页面追踪全部完成!** 🚀
现在你可以在 PostHog 后台看到完整的用户行为数据:
- **首页** → **个股中心/概念中心/公司详情/新闻中心** 的完整转化路径
- **搜索行为**、**筛选偏好**、**内容点击** 的详细数据
- **Tab 切换**、**视图切换**、**翻页行为** 的用户习惯分析
- **自选股管理** 的用户行为追踪
共追踪 **33个事件**,覆盖 **5个核心页面**

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -1,237 +0,0 @@
/**
* vf_react App.jsx集成示例
*
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
*
* 集成步骤:
* 1. 将bytedesk-integration文件夹复制到src/目录
* 2. 在App.jsx中导入BytedeskWidget和配置
* 3. 添加BytedeskWidget组件代码如下
* 4. 配置.env文件参考.env.bytedesk.example
*/
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; // 如果使用react-router
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
// ============================================================================
// 方案一: 全局集成(推荐)
// 适用场景: 客服系统需要在所有页面显示
// ============================================================================
function App() {
// ========== vf_react原有代码保持不变 ==========
// 这里是您原有的App.jsx代码
// 例如: const [user, setUser] = useState(null);
// 例如: const [theme, setTheme] = useState('light');
// ... 保持原有逻辑不变 ...
// ========== Bytedesk集成代码开始 ==========
const location = useLocation(); // 获取当前路径
const [showBytedesk, setShowBytedesk] = useState(false);
// 根据页面路径决定是否显示客服
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 获取Bytedesk配置
const bytedeskConfig = getBytedeskConfig();
// 客服加载成功回调
const handleBytedeskLoad = (bytedesk) => {
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
};
// 客服加载失败回调
const handleBytedeskError = (error) => {
console.error('[App] Bytedesk客服系统加载失败', error);
};
// ========== Bytedesk集成代码结束 ==========
return (
<div className="App">
{/* ========== vf_react原有内容保持不变 ========== */}
{/* 这里是您原有的App.jsx JSX代码 */}
{/* 例如: <Header /> */}
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
{/* ... 保持原有结构不变 ... */}
{/* ========== Bytedesk客服Widget ========== */}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
onLoad={handleBytedeskLoad}
onError={handleBytedeskError}
/>
)}
</div>
);
}
export default App;
// ============================================================================
// 方案二: 带用户信息集成
// 适用场景: 需要将登录用户信息传递给客服端
// ============================================================================
/*
import React, { useState, useEffect, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
function App() {
// 获取登录用户信息
const { user } = useContext(AuthContext);
const location = useLocation();
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 根据用户信息生成配置
const bytedeskConfig = user
? getBytedeskConfigWithUser(user)
: getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案三: 条件性加载
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
// ============================================================================
/*
import React, { useState, useEffect } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [user, setUser] = useState(null);
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
// 只有在用户登录且为普通用户时显示客服
if (user && user.role === 'customer') {
setShowBytedesk(true);
} else {
setShowBytedesk(false);
}
}, [user]);
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案四: 动态控制显示/隐藏
// 适用场景: 需要通过按钮或其他交互控制客服显示
// ============================================================================
/*
import React, { useState } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [showBytedesk, setShowBytedesk] = useState(false);
const bytedeskConfig = getBytedeskConfig();
const toggleBytedesk = () => {
setShowBytedesk(prev => !prev);
};
return (
<div className="App">
// ... 您的原有代码 ...
{/* 自定义客服按钮 *\/}
<button onClick={toggleBytedesk} className="custom-service-button">
{showBytedesk ? '关闭客服' : '联系客服'}
</button>
{/* 客服Widget *\/}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 重要提示
// ============================================================================
/**
* 1. CSS样式兼容性
* - Bytedesk Widget使用Shadow DOM不会影响您的全局样式
* - Widget的样式可通过config中的theme配置调整
*
* 2. 性能优化
* - Widget脚本采用异步加载不会阻塞页面渲染
* - 建议在非关键页面(如登录、支付页)隐藏客服
*
* 3. 错误处理
* - 如果客服脚本加载失败,不会影响主应用
* - 建议添加onError回调进行错误监控
*
* 4. 调试模式
* - 查看浏览器控制台的[Bytedesk]前缀日志
* - 检查Network面板确认脚本加载成功
*
* 5. 生产部署
* - 确保.env文件配置正确特别是REACT_APP_BYTEDESK_API_URL
* - 确保CORS已在后端配置允许您的前端域名
* - 在管理后台配置正确的工作组IDsid
*/

View File

@@ -1,27 +1,10 @@
/**
* Bytedesk客服配置文件
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_uid
*
* 架构说明:
* - iframe 使用完整域名https://valuefrontier.cn/bytedesk/chat/
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
* - 本地CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
* - baseUrl 保持官方 CDN用于加载 SDK 外部模块)
*
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
*/
// 从环境变量读取配置
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
/**
* Bytedesk客服基础配置
- iframe 使用完整域名https://valuefrontier.cn/bytedesk/chat/
- 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
- 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
- baseUrl 保持官方 CDN用于加载 SDK 外部模块)
*/
export const bytedeskConfig = {
// API服务地址如果 SDK 需要调用 API
@@ -61,9 +44,9 @@ export const bytedeskConfig = {
// 聊天配置(必需)
chatConfig: {
org: BYTEDESK_ORG, // 组织ID
org: df_org_uid, // 组织ID
t: '1', // 类型: 1=人工客服, 2=机器人
sid: BYTEDESK_SID, // 工作组ID
sid: df_wg_uid, // 工作组ID
},
};
@@ -111,45 +94,8 @@ export const getBytedeskConfigWithUser = (user) => {
return config;
};
/**
* 根据页面路径判断是否显示客服
*
* @param {string} pathname - 当前页面路径
* @returns {boolean} 是否显示客服
*/
export const shouldShowCustomerService = (pathname) => {
// 在以下页面隐藏客服(黑名单)
const blockedPages = [
// '/home', // 登录页
];
// 检查是否在黑名单
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
// 默认所有页面都显示客服
return true;
/* ============================================
白名单模式(备用,需要时取消注释)
============================================
const allowedPages = [
'/', // 首页
'/home', // 主页
'/products', // 产品页
'/pricing', // 价格页
'/contact', // 联系我们
];
// 只在白名单页面显示客服
return allowedPages.some(page => pathname.startsWith(page));
============================================ */
};
export default {
bytedeskConfig,
getBytedeskConfig,
getBytedeskConfigWithUser,
shouldShowCustomerService,
};

View File

@@ -0,0 +1,53 @@
import React from "react";
import Link, { LinkProps } from "next/link";
type CommonProps = {
className?: string;
children?: React.ReactNode;
isPrimary?: boolean;
isSecondary?: boolean;
};
type ButtonAsButton = {
as?: "button";
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonAsAnchor = {
as: "a";
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
type ButtonAsLink = {
as: "link";
} & LinkProps;
type ButtonProps = CommonProps &
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
const Button: React.FC<ButtonProps> = ({
className,
children,
isPrimary,
isSecondary,
as = "button",
...props
}) => {
const isLink = as === "link";
const Component: React.ElementType = isLink ? Link : as;
return (
<Component
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
} ${
isSecondary
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
: ""
} ${className || ""}`}
{...(isLink ? (props as LinkProps) : props)}
>
{children}
</Component>
);
};
export default Button;

View File

@@ -14,7 +14,7 @@ import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
@@ -74,7 +74,6 @@ function ConnectionStatusBarWrapper() {
*/
export function GlobalComponents() {
const location = useLocation();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<>
@@ -91,12 +90,10 @@ export function GlobalComponents() {
<NotificationContainer />
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && (
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
)}
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
</>
);
}

View File

@@ -0,0 +1,2 @@
// Type declarations for SubscriptionContentNew component
export {};

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,11 @@ let isInitialized = false;
* Should be called once when the app starts
*/
export const initPostHog = () => {
// 开发环境禁用 PostHog减少日志噪音仅生产环境启用
if (process.env.NODE_ENV === 'development') {
return;
}
// 防止重复初始化
if (isInitializing || isInitialized) {
console.log('📊 PostHog 已初始化或正在初始化中,跳过重复调用');
@@ -33,79 +38,68 @@ export const initPostHog = () => {
posthog.init(apiKey, {
api_host: apiHost,
// Pageview tracking - auto-capture for DAU/MAU analytics
capture_pageview: true, // Auto-capture all page views (required for DAU tracking)
capture_pageleave: true, // Auto-capture when user leaves page
// 📄 页面浏览追踪
capture_pageview: true, // 自动捕获页面浏览事件
capture_pageleave: true, // 自动捕获用户离开页面事件
// Session Recording Configuration
// 📹 会话录制配置(Session Recording
session_recording: {
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
// Privacy: Mask sensitive input fields
// 🔒 隐私保护:遮蔽敏感输入字段(录制时会自动打码)
maskInputOptions: {
password: true,
email: true,
phone: true,
'data-sensitive': true, // Custom attribute for sensitive fields
password: true, // 遮蔽密码输入框
email: true, // 遮蔽邮箱输入框
phone: true, // 遮蔽手机号输入框
'data-sensitive': true, // 遮蔽带有 data-sensitive 属性的字段(可在 HTML 中自定义)
},
// Record canvas for charts/graphs
// 📊 录制 Canvas 画布内容(用于记录图表、图形等可视化内容)
recordCanvas: true,
// Network payload capture (useful for debugging API issues)
// 🌐 网络请求数据捕获(用于调试 API 问题)
networkPayloadCapture: {
recordHeaders: true,
recordBody: true,
// Don't record sensitive endpoints
recordHeaders: true, // 捕获请求头
recordBody: true, // 捕获请求体
// 🚫 敏感接口黑名单(不记录以下接口的数据)
urlBlocklist: [
'/api/auth/session',
'/api/auth/login',
'/api/auth/register',
'/api/payment',
'/api/auth/session', // 会话接口
'/api/auth/login', // 登录接口
'/api/auth/register', // 注册接口
'/api/payment', // 支付接口
],
},
},
// Performance optimization
batch_size: 10, // Send events in batches of 10
batch_interval_ms: 3000, // Or every 3 seconds
// ⚡ 性能优化:批量发送事件
batch_size: 10, // 每 10 个事件发送一次
batch_interval_ms: 3000, // 或每 3 秒发送一次(两个条件满足其一即发送)
// Privacy settings
respect_dnt: true, // Respect Do Not Track browser setting
persistence: 'localStorage+cookie', // Use both for reliability
// 🔐 隐私设置
respect_dnt: true, // 尊重浏览器的"禁止追踪"Do Not Track设置
persistence: 'localStorage+cookie', // 同时使用 localStorage 和 Cookie 存储(提高可靠性)
// Feature flags (for A/B testing)
// 🚩 功能开关(Feature Flags- 用于 A/B 测试和灰度发布
bootstrap: {
featureFlags: {},
featureFlags: {}, // 初始功能开关配置(可从服务端动态加载)
},
// Autocapture settings
// 🖱️ 自动捕获设置(Autocapture
autocapture: {
// Automatically capture clicks on buttons, links, etc.
// 自动捕获用户交互事件(点击、提交、修改等)
dom_event_allowlist: ['click', 'submit', 'change'],
// Capture additional element properties
capture_copied_text: false, // Don't capture copied text (privacy)
},
// Development debugging
loaded: (posthogInstance) => {
if (process.env.NODE_ENV === 'development') {
console.log('✅ PostHog initialized successfully');
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
}
// 捕获额外的元素属性
capture_copied_text: false, // 不捕获用户复制的文本(隐私保护)
},
});
isInitialized = true;
console.log('📊 PostHog Analytics initialized');
} catch (error) {
// 忽略 AbortError通常由热重载或快速导航引起
if (error.name === 'AbortError') {
console.log('⚠️ PostHog 初始化请求被中断(可能是热重载),这是正常的');
return;
}
console.error('❌ PostHog initialization failed:', error);
} finally {
isInitializing = false;
}
@@ -142,8 +136,6 @@ export const identifyUser = (userId, userProperties = {}) => {
last_login: new Date().toISOString(),
...userProperties,
});
// console.log('👤 User identified:', userId); // 已关闭:减少日志
} catch (error) {
console.error('❌ User identification failed:', error);
}
@@ -158,7 +150,6 @@ export const identifyUser = (userId, userProperties = {}) => {
export const setUserProperties = (properties) => {
try {
posthog.people.set(properties);
// console.log('📝 User properties updated'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Failed to update user properties:', error);
}
@@ -176,10 +167,6 @@ export const trackEvent = (eventName, properties = {}) => {
...properties,
timestamp: new Date().toISOString(),
});
// if (process.env.NODE_ENV === 'development') {
// console.log('📍 Event tracked:', eventName, properties);
// } // 已关闭:减少日志
} catch (error) {
console.error('❌ Event tracking failed:', error);
}
@@ -225,9 +212,6 @@ export const trackPageView = (pagePath, properties = {}) => {
...properties,
});
// if (process.env.NODE_ENV === 'development') {
// console.log('📄 Page view tracked:', pagePath);
// } // 已关闭:减少日志
} catch (error) {
console.error('❌ Page view tracking failed:', error);
}
@@ -240,7 +224,6 @@ export const trackPageView = (pagePath, properties = {}) => {
export const resetUser = () => {
try {
posthog.reset();
// console.log('🔄 User session reset'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Session reset failed:', error);
}
@@ -252,7 +235,6 @@ export const resetUser = () => {
export const optOut = () => {
try {
posthog.opt_out_capturing();
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Opt-out failed:', error);
}
@@ -264,7 +246,6 @@ export const optOut = () => {
export const optIn = () => {
try {
posthog.opt_in_capturing();
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
} catch (error) {
console.error('❌ Opt-in failed:', error);
}

View File

@@ -102,6 +102,17 @@ export const homeRoutes = [
}
},
// 数据浏览器 - /home/data-browser
{
path: 'data-browser',
component: lazyComponents.DataBrowser,
protection: PROTECTION_MODES.MODAL,
meta: {
title: '数据浏览器',
description: '化工商品数据分类树浏览器'
}
},
// 回退路由 - 匹配任何未定义的 /home/* 路径
{
path: '*',

View File

@@ -42,6 +42,9 @@ export const lazyComponents = {
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
// 数据浏览器模块
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
};
/**
@@ -69,4 +72,5 @@ export const {
AgentChat,
ValueForum,
ForumPostDetail,
DataBrowser,
} = lazyComponents;

View File

@@ -0,0 +1,280 @@
/**
* 商品分类树数据服务
* 对接化工商品数据分类树API
* API文档: category_tree_openapi.json
*/
import { getApiBase } from '@utils/apiConfig';
// 类型定义
export interface TreeMetric {
metric_id: string;
metric_name: string;
source: 'SMM' | 'Mysteel';
frequency: string;
unit: string;
description?: string;
}
export interface TreeNode {
name: string;
path: string;
level: number;
children?: TreeNode[];
metrics?: TreeMetric[];
}
export interface CategoryTreeResponse {
source: 'SMM' | 'Mysteel';
total_metrics: number;
tree: TreeNode[];
}
export interface ErrorResponse {
detail: string;
}
export interface MetricDataPoint {
date: string;
value: number | null;
}
export interface MetricDataResponse {
metric_id: string;
metric_name: string;
source: string;
frequency: string;
unit: string;
data: MetricDataPoint[];
total_count: number;
}
/**
* 获取分类树(支持深度控制)
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @param maxDepth 返回的最大层级深度默认1层推荐懒加载
* @returns 分类树数据
*/
export const fetchCategoryTree = async (
source: 'SMM' | 'Mysteel',
maxDepth: number = 1
): Promise<CategoryTreeResponse> => {
try {
const response = await fetch(
`/category-api/api/category-tree?source=${source}&max_depth=${maxDepth}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: CategoryTreeResponse = await response.json();
return data;
} catch (error) {
console.error('fetchCategoryTree error:', error);
throw error;
}
};
/**
* 获取特定节点及其子树
* @param path 节点完整路径(用 | 分隔)
* @param source 数据源类型 ('SMM' | 'Mysteel')
* @returns 节点数据及其子树
*/
export const fetchCategoryNode = async (
path: string,
source: 'SMM' | 'Mysteel'
): Promise<TreeNode> => {
try {
const encodedPath = encodeURIComponent(path);
const response = await fetch(
`/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: TreeNode = await response.json();
return data;
} catch (error) {
console.error('fetchCategoryNode error:', error);
throw error;
}
};
export interface MetricSearchResult {
source: string;
metric_id: string;
metric_name: string;
unit: string;
frequency: string;
category_path: string;
description?: string;
score?: number;
}
export interface SearchResponse {
total: number;
results: MetricSearchResult[];
query: string;
}
/**
* 搜索指标
* @param keywords 搜索关键词(支持空格分隔多个词)
* @param source 数据源过滤(可选)
* @param frequency 频率过滤(可选)
* @param size 返回结果数量默认100
* @returns 搜索结果
*/
export const searchMetrics = async (
keywords: string,
source?: 'SMM' | 'Mysteel',
frequency?: string,
size: number = 100
): Promise<SearchResponse> => {
try {
const params = new URLSearchParams({
keywords,
size: size.toString(),
});
if (source) params.append('source', source);
if (frequency) params.append('frequency', frequency);
const response = await fetch(`/category-api/api/search?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: SearchResponse = await response.json();
return data;
} catch (error) {
console.error('searchMetrics error:', error);
throw error;
}
};
/**
* 从树中提取所有指标(用于前端搜索)
* @param nodes 树节点数组
* @returns 所有指标的扁平化数组
*/
export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
const metrics: TreeMetric[] = [];
const traverse = (node: TreeNode) => {
if (node.metrics && node.metrics.length > 0) {
metrics.push(...node.metrics);
}
if (node.children && node.children.length > 0) {
node.children.forEach(traverse);
}
};
nodes.forEach(traverse);
return metrics;
};
/**
* 在树中查找节点
* @param nodes 树节点数组
* @param path 节点路径
* @returns 找到的节点或 null
*/
export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === path) {
return node;
}
if (node.children) {
const found = findNodeByPath(node.children, path);
if (found) {
return found;
}
}
}
return null;
};
/**
* 获取节点的所有父节点路径
* @param path 节点路径(用 | 分隔)
* @returns 父节点路径数组
*/
export const getParentPaths = (path: string): string[] => {
const parts = path.split('|');
const parentPaths: string[] = [];
for (let i = 1; i < parts.length; i++) {
parentPaths.push(parts.slice(0, i).join('|'));
}
return parentPaths;
};
/**
* 获取指标数据详情
* @param metricId 指标ID
* @param startDate 开始日期可选格式YYYY-MM-DD
* @param endDate 结束日期可选格式YYYY-MM-DD
* @param limit 返回数据条数可选默认100
* @returns 指标数据
*/
export const fetchMetricData = async (
metricId: string,
startDate?: string,
endDate?: string,
limit: number = 100
): Promise<MetricDataResponse> => {
try {
const params = new URLSearchParams({
metric_id: metricId,
limit: limit.toString(),
});
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const response = await fetch(`/category-api/api/metric-data?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData: ErrorResponse = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data: MetricDataResponse = await response.json();
return data;
} catch (error) {
console.error('fetchMetricData error:', error);
throw error;
}
};

View File

@@ -1,283 +0,0 @@
// src/views/Community/components/EventDetailModal.js
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
import dayjs from 'dayjs';
const EventDetailModal = ({ visible, event, onClose }) => {
const [loading, setLoading] = useState(false);
const [eventDetail, setEventDetail] = useState(null);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [comments, setComments] = useState([]);
const [commentsLoading, setCommentsLoading] = useState(false);
const loadEventDetail = async () => {
if (!event) return;
setLoading(true);
try {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setEventDetail(response.data);
}
} catch (error) {
logger.error('EventDetailModal', 'loadEventDetail', error, {
eventId: event?.id
});
} finally {
setLoading(false);
}
};
const loadComments = async () => {
if (!event) return;
setCommentsLoading(true);
try {
// 使用统一的posts API获取评论
const result = await eventService.getPosts(event.id);
if (result.success) {
setComments(result.data || []);
}
} catch (error) {
logger.error('EventDetailModal', 'loadComments', error, {
eventId: event?.id
});
} finally {
setCommentsLoading(false);
}
};
useEffect(() => {
if (visible && event) {
loadEventDetail();
loadComments();
}
}, [visible, event]);
const getImportanceColor = (importance) => {
const colors = {
S: 'red',
A: 'orange',
B: 'blue',
C: 'green'
};
return colors[importance] || 'default';
};
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
const renderPriceTag = (value, label) => {
if (value === null || value === undefined) return `${label}: --`;
const color = value > 0 ? '#ff4d4f' : '#52c41a';
const prefix = value > 0 ? '+' : '';
return (
<span>
{label}: <span style={{ color }}>{prefix}{value.toFixed(2)}%</span>
</span>
);
};
const handleSubmitComment = async () => {
if (!commentText.trim()) {
message.warning('请输入评论内容');
return;
}
setSubmitting(true);
try {
// 使用统一的createPost API
const result = await eventService.createPost(event.id, {
content: commentText.trim(),
content_type: 'text'
});
if (result.success) {
message.success('评论发布成功');
setCommentText('');
// 重新加载评论列表
loadComments();
} else {
throw new Error(result.message || '评论失败');
}
} catch (e) {
message.error(e.message || '评论失败');
} finally {
setSubmitting(false);
}
};
return (
<Modal
title={eventDetail?.title || '事件详情'}
open={visible}
onCancel={onClose}
width={800}
footer={null}
>
<Spin spinning={loading}>
{eventDetail && (
<>
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="创建时间">
{dayjs(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="创建者">
{eventDetail.creator?.username || 'Anonymous'}
</Descriptions.Item>
<Descriptions.Item label="重要性">
<Badge color={getImportanceColor(eventDetail.importance)} text={`${eventDetail.importance}`} />
</Descriptions.Item>
<Descriptions.Item label="浏览数">
{eventDetail.view_count || 0}
</Descriptions.Item>
<Descriptions.Item label="涨幅统计" span={2}>
<Tag>{renderPriceTag(eventDetail.related_avg_chg, '平均涨幅')}</Tag>
<Tag>{renderPriceTag(eventDetail.related_max_chg, '最大涨幅')}</Tag>
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
</Descriptions.Item>
<Descriptions.Item label="事件描述" span={2}>
{eventDetail.description}AI合成
</Descriptions.Item>
</Descriptions>
{eventDetail.keywords && eventDetail.keywords.length > 0 && (
<div style={{ marginBottom: 24 }}>
<h4>相关概念</h4>
{eventDetail.keywords.map((keyword, index) => (
<Tag key={index} color="blue" style={{ marginBottom: 8 }}>
{keyword}
</Tag>
))}
</div>
)}
{eventDetail.related_stocks && eventDetail.related_stocks.length > 0 && (
<div>
<h4>相关股票</h4>
<List
size="small"
dataSource={eventDetail.related_stocks}
renderItem={stock => (
<List.Item
actions={[
<Button
type="primary"
size="small"
onClick={() => {
const stockCode = stock.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
]}
>
<List.Item.Meta
title={`${stock.stock_name} (${stock.stock_code})`}
description={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}AI合成` : ''}
/>
{stock.change !== null && (
<Tag color={stock.change > 0 ? 'red' : 'green'}>
{stock.change > 0 ? '+' : ''}{stock.change.toFixed(2)}%
</Tag>
)}
</List.Item>
)}
/>
</div>
)}
{/* 讨论区 */}
<div style={{ marginTop: 24 }}>
<h4>讨论区</h4>
{/* 评论列表 */}
<div style={{ marginBottom: 24 }}>
<Spin spinning={commentsLoading}>
{comments.length === 0 ? (
<Empty
description="暂无评论"
style={{ padding: '20px 0' }}
/>
) : (
<List
itemLayout="vertical"
dataSource={comments}
renderItem={comment => (
<List.Item key={comment.id}>
<List.Item.Meta
title={
<div style={{ fontSize: '14px' }}>
<strong>{comment.author?.username || 'Anonymous'}</strong>
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
{dayjs(comment.created_at).format('MM-DD HH:mm')}
</span>
</div>
}
description={
<div style={{
fontSize: '14px',
lineHeight: '1.6',
marginTop: 8
}}>
{comment.content}
</div>
}
/>
</List.Item>
)}
/>
)}
</Spin>
</div>
{/* 评论输入框登录后可用未登录后端会返回401 */}
<div>
<h4>发表评论</h4>
<Input.TextArea
placeholder="说点什么..."
rows={3}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
maxLength={500}
showCount
/>
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
发布
</Button>
</div>
</div>
</div>
</>
)}
</Spin>
</Modal>
);
};
export default EventDetailModal;

View File

@@ -1,387 +0,0 @@
/* src/views/Community/components/EventList.css */
/* 时间轴容器样式 */
.event-timeline {
padding: 0 0 0 24px;
}
/* 时间轴圆点样式 */
.timeline-dot {
border: none !important;
cursor: pointer;
transition: all 0.3s;
}
/* 时间轴事件卡片 */
.timeline-event-card {
padding: 20px;
margin-bottom: 16px;
background: #fff;
border-radius: 12px;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.timeline-event-card:hover {
transform: translateX(8px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
border-color: #d9d9d9;
}
/* 重要性标记线 */
.importance-marker {
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
transition: width 0.3s;
}
.timeline-event-card:hover .importance-marker {
width: 6px;
}
/* 事件标题 */
.event-title {
margin-bottom: 8px;
}
.event-title a {
color: #1890ff;
font-size: 16px;
font-weight: 500;
text-decoration: none;
transition: color 0.3s;
}
.event-title a:hover {
color: #40a9ff;
text-decoration: underline;
}
/* 事件元信息 */
.event-meta {
font-size: 12px;
color: #999;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.event-meta .anticon {
margin-right: 4px;
}
.event-meta .separator {
margin: 0;
color: #e8e8e8;
}
/* 事件描述 */
.event-description {
margin: 0 0 12px;
color: #666;
line-height: 1.6;
font-size: 14px;
}
/* 事件统计标签 */
.event-stats {
margin-bottom: 16px;
}
.event-stats .ant-tag {
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
}
/* 事件操作区域 */
.event-actions {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #999;
}
.event-actions > span {
margin-right: 0;
}
.event-actions .anticon {
margin-right: 4px;
}
/* 事件按钮 */
.event-buttons {
margin-left: auto;
}
.event-buttons .ant-btn {
font-size: 13px;
}
.event-buttons .ant-btn-sm {
height: 28px;
padding: 0 12px;
}
/* 重要性指示器 */
.importance-indicator {
text-align: right;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.importance-indicator .ant-badge {
display: block;
}
.importance-indicator .ant-avatar {
transition: all 0.3s;
}
.timeline-event-card:hover .importance-indicator .ant-avatar {
transform: scale(1.1);
}
.importance-label {
font-size: 12px;
color: #666;
font-weight: 500;
}
/* 分页容器 */
.pagination-container {
margin-top: 32px;
text-align: center;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 0;
color: #999;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.event-timeline {
padding-left: 0;
}
.timeline-event-card {
padding: 16px;
}
.event-title a {
font-size: 15px;
}
.event-description {
font-size: 13px;
}
.event-actions {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.event-buttons {
margin-left: 0;
width: 100%;
}
.event-buttons .ant-space {
width: 100%;
}
.event-buttons .ant-btn {
flex: 1;
}
.importance-indicator {
position: absolute;
top: 16px;
right: 16px;
}
.importance-label {
display: none;
}
}
/* 深色主题支持(可选) */
@media (prefers-color-scheme: dark) {
.timeline-event-card {
background: #1f1f1f;
border-color: #303030;
}
.timeline-event-card:hover {
border-color: #434343;
}
.event-title a {
color: #4096ff;
}
.event-description {
color: #bfbfbf;
}
.event-meta {
color: #8c8c8c;
}
}
/* 动画效果 */
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 时间轴项目动画 */
.ant-timeline-item {
animation: fadeInLeft 0.5s ease-out forwards;
opacity: 0;
}
.ant-timeline-item:nth-child(1) { animation-delay: 0.1s; }
.ant-timeline-item:nth-child(2) { animation-delay: 0.2s; }
.ant-timeline-item:nth-child(3) { animation-delay: 0.3s; }
.ant-timeline-item:nth-child(4) { animation-delay: 0.4s; }
.ant-timeline-item:nth-child(5) { animation-delay: 0.5s; }
.ant-timeline-item:nth-child(6) { animation-delay: 0.6s; }
.ant-timeline-item:nth-child(7) { animation-delay: 0.7s; }
.ant-timeline-item:nth-child(8) { animation-delay: 0.8s; }
.ant-timeline-item:nth-child(9) { animation-delay: 0.9s; }
.ant-timeline-item:nth-child(10) { animation-delay: 1s; }
/* 时间轴连接线样式 */
.ant-timeline-item-tail {
border-left-style: dashed;
border-left-width: 2px;
}
/* 涨跌幅标签特殊样式 */
.event-stats .ant-tag[color="#ff4d4f"] {
background-color: #fff1f0;
border-color: #ffccc7;
}
.event-stats .ant-tag[color="#52c41a"] {
background-color: #f6ffed;
border-color: #b7eb8f;
}
/* 快速查看和详细信息按钮悬停效果 */
.event-buttons .ant-btn-default:hover {
color: #40a9ff;
border-color: #40a9ff;
}
.event-buttons .ant-btn-primary {
background: #1890ff;
border-color: #1890ff;
}
.event-buttons .ant-btn-primary:hover {
background: #40a9ff;
border-color: #40a9ff;
}
/* 工具提示样式 */
.ant-tooltip-inner {
font-size: 12px;
}
/* 徽章计数样式 */
.importance-indicator .ant-badge-count {
font-size: 12px;
font-weight: bold;
min-width: 20px;
height: 20px;
line-height: 20px;
border-radius: 10px;
padding: 0 6px;
}
/* 加载状态动画 */
.timeline-event-card.loading {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 特殊重要性等级样式增强 */
.timeline-event-card[data-importance="S"] {
border-left: 4px solid #722ed1;
}
.timeline-event-card[data-importance="A"] {
border-left: 4px solid #ff4d4f;
}
.timeline-event-card[data-importance="B"] {
border-left: 4px solid #faad14;
}
.timeline-event-card[data-importance="C"] {
border-left: 4px solid #52c41a;
}
/* 时间轴左侧内容区域优化 */
.ant-timeline-item-content {
padding-bottom: 0;
min-height: auto;
}
/* 确保最后一个时间轴项目没有连接线 */
.ant-timeline-item:last-child .ant-timeline-item-tail {
display: none;
}
/* 打印样式优化 */
@media print {
.timeline-event-card {
page-break-inside: avoid;
border: 1px solid #000;
}
.event-buttons {
display: none;
}
.importance-marker {
width: 2px !important;
background: #000 !important;
}
}

View File

@@ -1,490 +0,0 @@
// src/views/Community/components/EventList.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Badge,
Flex,
Container,
useColorModeValue,
Switch,
FormControl,
FormLabel,
useToast,
Center,
Tooltip,
} from '@chakra-ui/react';
import { InfoIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
// 导入工具函数和常量
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import { useEventNotifications } from '../../../hooks/useEventNotifications';
import { browserNotificationService } from '../../../services/browserNotificationService';
import { useNotification } from '../../../contexts/NotificationContext';
import { getImportanceConfig } from '../../../constants/importanceLevels';
// 导入子组件
import EventCard from './EventCard';
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
const toast = useToast();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
// 从 NotificationContext 获取推送权限相关状态和方法
const { browserPermission, requestBrowserPermission } = useNotification();
// 实时事件推送集成
const { isConnected } = useEventNotifications({
eventType: 'all',
importance: 'all',
enabled: true,
onNewEvent: (event) => {
console.log('\n[EventList DEBUG] ========== EventList 收到新事件 ==========');
console.log('[EventList DEBUG] 事件数据:', event);
console.log('[EventList DEBUG] 事件 ID:', event?.id);
console.log('[EventList DEBUG] 事件标题:', event?.title);
logger.info('EventList', '收到新事件推送', event);
// 发送浏览器原生通知
console.log('[EventList DEBUG] 准备发送浏览器原生通知');
console.log('[EventList DEBUG] 通知权限状态:', browserPermission);
if (browserPermission === 'granted') {
const importance = getImportanceConfig(event.importance);
const notification = browserNotificationService.sendNotification({
title: `🔔 ${importance.label}级事件`,
body: event.title,
tag: `event_${event.id}`,
data: {
link: `/event-detail/${event.id}`,
eventId: event.id,
},
autoClose: 10000, // 10秒后自动关闭
});
if (notification) {
browserNotificationService.setupClickHandler(notification, navigate);
console.log('[EventList DEBUG] ✓ 浏览器原生通知已发送');
} else {
console.log('[EventList DEBUG] ⚠️ 浏览器原生通知发送失败');
}
} else {
console.log('[EventList DEBUG] ⚠️ 浏览器通知权限未授予,跳过原生通知');
}
console.log('[EventList DEBUG] 准备更新事件列表');
// 将新事件添加到列表顶部(防止重复)
setLocalEvents((prevEvents) => {
console.log('[EventList DEBUG] 当前事件列表数量:', prevEvents.length);
const exists = prevEvents.some(e => e.id === event.id);
console.log('[EventList DEBUG] 事件是否已存在:', exists);
if (exists) {
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
console.log('[EventList DEBUG] ⚠️ 事件已存在,跳过添加');
return prevEvents;
}
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
console.log('[EventList DEBUG] ✓ 新事件添加到列表顶部');
// 添加到顶部,最多保留 100 个
const updatedEvents = [event, ...prevEvents].slice(0, 100);
console.log('[EventList DEBUG] 更新后事件列表数量:', updatedEvents.length);
return updatedEvents;
});
console.log('[EventList DEBUG] ✓ 事件列表更新完成');
console.log('[EventList DEBUG] ========== EventList 处理完成 ==========\n');
}
});
// 同步外部 events 到 localEvents
useEffect(() => {
setLocalEvents(events);
}, [events]);
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
localEvents.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
const loadFollowing = async () => {
try {
const base = getApiBase();
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
const map = {};
(data.data || []).forEach(ev => { map[ev.id] = true; });
setFollowingMap(map);
logger.debug('EventList', '关注状态加载成功', {
followingCount: Object.keys(map).length
});
}
} catch (e) {
logger.warn('EventList', '加载关注状态失败', { error: e.message });
}
};
loadFollowing();
// 仅在 localEvents 更新时重跑
}, [localEvents]);
const toggleFollow = async (eventId) => {
try {
const base = getApiBase();
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
const isFollowing = data.data?.is_following;
const count = data.data?.follower_count ?? 0;
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
logger.debug('EventList', '关注状态切换成功', {
eventId,
isFollowing,
followerCount: count
});
} catch (e) {
logger.warn('EventList', '关注操作失败', {
eventId,
error: e.message
});
}
};
// 处理推送开关切换
const handlePushToggle = async (e) => {
const isChecked = e.target.checked;
if (isChecked) {
// 用户想开启推送
logger.info('EventList', '用户请求开启推送');
const permission = await requestBrowserPermission();
if (permission === 'denied') {
// 权限被拒绝,显示设置指引
logger.warn('EventList', '用户拒绝了推送权限');
toast({
title: '推送权限被拒绝',
description: '如需开启推送,请在浏览器设置中允许通知权限',
status: 'warning',
duration: 5000,
isClosable: true,
position: 'top',
});
} else if (permission === 'granted') {
logger.info('EventList', '推送权限已授予');
}
} else {
// 用户想关闭推送 - 提示需在浏览器设置中操作
logger.info('EventList', '用户尝试关闭推送');
toast({
title: '关闭推送通知',
description: '如需关闭,请在浏览器设置中撤销通知权限',
status: 'info',
duration: 5000,
isClosable: true,
position: 'top',
});
}
};
// 专业的金融配色方案
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.200');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const handleTitleClick = (e, event) => {
e.preventDefault();
e.stopPropagation();
onEventClick(event);
};
const handleViewDetailClick = (e, eventId) => {
e.stopPropagation();
navigate(`/event-detail/${eventId}`);
};
// 时间轴样式配置(固定使用轻量卡片样式)
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
// 分页组件
const Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
// 计算要显示的页码数组(智能分页)
const getPageNumbers = () => {
const delta = 2; // 当前页左右各显示2个页码
const range = [];
const rangeWithDots = [];
// 始终显示第1页
range.push(1);
// 显示当前页附近的页码
for (let i = current - delta; i <= current + delta; i++) {
if (i > 1 && i < totalPages) {
range.push(i);
}
}
// 始终显示最后一页(如果总页数>1
if (totalPages > 1) {
range.push(totalPages);
}
// 去重并排序
const uniqueRange = [...new Set(range)].sort((a, b) => a - b);
// 添加省略号
let prev = 0;
for (const page of uniqueRange) {
if (page - prev === 2) {
// 如果只差一个页码,直接显示
rangeWithDots.push(prev + 1);
} else if (page - prev > 2) {
// 如果差距大于2显示省略号
rangeWithDots.push('...');
}
rangeWithDots.push(page);
prev = page;
}
return rangeWithDots;
};
const pageNumbers = getPageNumbers();
return (
<Flex justify="center" align="center" mt={8} gap={2}>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current - 1)}
isDisabled={current === 1}
>
上一页
</Button>
<HStack spacing={1}>
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<Text key={`ellipsis-${index}`} px={2} color="gray.500">
...
</Text>
);
}
return (
<Button
key={page}
size="sm"
variant={current === page ? 'solid' : 'ghost'}
colorScheme={current === page ? 'blue' : 'gray'}
onClick={() => onChange(page)}
>
{page}
</Button>
);
})}
</HStack>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current + 1)}
isDisabled={current === totalPages}
>
下一页
</Button>
<Text fontSize="sm" color={mutedColor} ml={4}>
{total}
</Text>
</Flex>
);
};
return (
<Box bg={bgColor} minH="100vh" pb={8}>
{/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制固定sticky - 铺满全宽 */}
<Box
position="sticky"
top={0}
zIndex={10}
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(26, 32, 44, 0.9)')}
backdropFilter="blur(10px)"
boxShadow="sm"
mb={4}
py={2}
w="100%"
>
<Container maxW="container.xl">
<Flex justify="space-between" align="center">
{/* 左侧占位 */}
<Box key="left-spacer" flex="1" />
{/* 中间:分页器 */}
{pagination.total > 0 && localEvents.length > 0 ? (
<Flex key="pagination-controls" align="center" gap={2}>
<Button
key="prev-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current - 1)}
isDisabled={pagination.current === 1}
>
上一页
</Button>
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
{pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)}
</Text>
<Button
key="next-page"
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current + 1)}
isDisabled={pagination.current === Math.ceil(pagination.total / pagination.pageSize)}
>
下一页
</Button>
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
{pagination.total}
</Text>
</Flex>
) : (
<Box key="center-spacer" flex="1" />
)}
{/* 右侧:控制按钮 */}
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
{/* WebSocket 连接状态 */}
<Badge
key="websocket-status"
colorScheme={isConnected ? 'green' : 'red'}
fontSize="xs"
px={2}
py={1}
borderRadius="full"
>
{isConnected ? '🟢 实时' : '🔴 离线'}
</Badge>
{/* 桌面推送开关 */}
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
推送
</FormLabel>
<Tooltip
label={
browserPermission === 'granted'
? '桌面推送已开启'
: browserPermission === 'denied'
? '推送权限被拒绝,请在浏览器设置中允许通知权限'
: '点击开启桌面推送通知'
}
placement="top"
>
<Switch
id="push-notification"
size="sm"
isChecked={browserPermission === 'granted'}
onChange={handlePushToggle}
colorScheme="green"
/>
</Tooltip>
</FormControl>
{/* 视图切换控制 */}
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
精简
</FormLabel>
<Switch
id="compact-mode"
size="sm"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
</Flex>
</Container>
</Box>
{/* 事件列表内容 */}
<Container maxW="container.xl">
{localEvents.length > 0 ? (
<VStack key="event-list" align="stretch" spacing={0}>
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
<EventCard
event={event}
index={index}
isCompactMode={isCompactMode}
isFollowing={!!followingMap[event.id]}
followerCount={followCountMap[event.id] ?? (event.follower_count || 0)}
onEventClick={onEventClick}
onTitleClick={handleTitleClick}
onViewDetail={handleViewDetailClick}
onToggleFollow={toggleFollow}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</VStack>
) : (
<Center key="empty-state" h="300px">
<VStack spacing={4}>
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
<Text key="empty-text" color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
</Center>
)}
{pagination.total > 0 && (
<Pagination
key="bottom-pagination"
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
)}
</Container>
</Box>
);
};
export default EventList;

View File

@@ -1,818 +0,0 @@
// src/views/Community/components/EventList.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Badge,
Tag,
TagLabel,
TagLeftIcon,
Flex,
Avatar,
Tooltip,
IconButton,
Divider,
Container,
useColorModeValue,
Circle,
Stat,
StatNumber,
StatHelpText,
StatArrow,
ButtonGroup,
Heading,
SimpleGrid,
Card,
CardBody,
Center,
Link,
Spacer,
Switch,
FormControl,
FormLabel,
} from '@chakra-ui/react';
import {
ViewIcon,
ChatIcon,
StarIcon,
TimeIcon,
InfoIcon,
WarningIcon,
WarningTwoIcon,
CheckCircleIcon,
TriangleUpIcon,
TriangleDownIcon,
ArrowForwardIcon,
ExternalLinkIcon,
ViewOffIcon,
} from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import { logger } from '../../../utils/logger';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
const getPriceChangeColor = (value) => {
if (value === null || value === undefined) return 'gray.500';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨用红色,根据涨幅大小使用不同深浅
if (absValue >= 3) return 'red.600'; // 深红色3%以上
if (absValue >= 1) return 'red.500'; // 中红色1-3%
return 'red.400'; // 浅红色0-1%
} else if (value < 0) {
// 下跌用绿色,根据跌幅大小使用不同深浅
if (absValue >= 3) return 'green.600'; // 深绿色3%以上
if (absValue >= 1) return 'green.500'; // 中绿色1-3%
return 'green.400'; // 浅绿色0-1%
}
return 'gray.500';
};
const getPriceChangeBg = (value) => {
if (value === null || value === undefined) return 'gray.50';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨背景色
if (absValue >= 3) return 'red.100'; // 深色背景3%以上
if (absValue >= 1) return 'red.50'; // 中色背景1-3%
return 'red.50'; // 浅色背景0-1%
} else if (value < 0) {
// 下跌背景色
if (absValue >= 3) return 'green.100'; // 深色背景3%以上
if (absValue >= 1) return 'green.50'; // 中色背景1-3%
return 'green.50'; // 浅色背景0-1%
}
return 'gray.50';
};
const getPriceChangeBorderColor = (value) => {
if (value === null || value === undefined) return 'gray.300';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨边框色
if (absValue >= 3) return 'red.500'; // 深边框3%以上
if (absValue >= 1) return 'red.400'; // 中边框1-3%
return 'red.300'; // 浅边框0-1%
} else if (value < 0) {
// 下跌边框色
if (absValue >= 3) return 'green.500'; // 深边框3%以上
if (absValue >= 1) return 'green.400'; // 中边框1-3%
return 'green.300'; // 浅边框0-1%
}
return 'gray.300';
};
// 重要性等级配置 - 金融配色方案
const importanceLevels = {
'S': {
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
},
'A': {
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
},
'B': {
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
},
'C': {
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
}
};
const getImportanceConfig = (importance) => {
return importanceLevels[importance] || importanceLevels['C'];
};
// 自定义的涨跌箭头组件(修复颜色问题)
const PriceArrow = ({ value }) => {
if (value === null || value === undefined) return null;
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
const color = value > 0 ? 'red.500' : 'green.500';
return <Icon color={color} boxSize="16px" />;
};
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
events.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
const loadFollowing = async () => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
const map = {};
(data.data || []).forEach(ev => { map[ev.id] = true; });
setFollowingMap(map);
logger.debug('EventList', '关注状态加载成功', {
followingCount: Object.keys(map).length
});
}
} catch (e) {
logger.warn('EventList', '加载关注状态失败', { error: e.message });
}
};
loadFollowing();
// 仅在 events 更新时重跑
}, [events]);
const toggleFollow = async (eventId) => {
try {
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
const isFollowing = data.data?.is_following;
const count = data.data?.follower_count ?? 0;
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
logger.debug('EventList', '关注状态切换成功', {
eventId,
isFollowing,
followerCount: count
});
} catch (e) {
logger.warn('EventList', '关注操作失败', {
eventId,
error: e.message
});
}
};
// 专业的金融配色方案
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.200');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const renderPriceChange = (value, label) => {
if (value === null || value === undefined) {
return (
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
</Tag>
);
}
const absValue = Math.abs(value);
const isPositive = value > 0;
// 根据涨跌幅大小选择不同的颜色深浅
let colorScheme = 'gray';
let variant = 'solid';
if (isPositive) {
// 上涨用红色系
if (absValue >= 3) {
colorScheme = 'red';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'red';
variant = 'subtle'; // 中等
} else {
colorScheme = 'red';
variant = 'outline'; // 浅色
}
} else {
// 下跌用绿色系
if (absValue >= 3) {
colorScheme = 'green';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'green';
variant = 'subtle'; // 中等
} else {
colorScheme = 'green';
variant = 'outline'; // 浅色
}
}
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
return (
<Tag
size="lg"
colorScheme={colorScheme}
borderRadius="full"
variant={variant}
boxShadow="sm"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<TagLeftIcon as={Icon} boxSize="16px" />
<TagLabel fontSize="sm" fontWeight="bold">
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
</TagLabel>
</Tag>
);
};
const handleTitleClick = (e, event) => {
e.preventDefault();
e.stopPropagation();
onEventClick(event);
};
const handleViewDetailClick = (e, eventId) => {
e.stopPropagation();
navigate(`/event-detail/${eventId}`);
};
// 精简模式的事件渲染
const renderCompactEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="32px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="sm"
boxShadow="sm"
border="2px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="60px"
/>
</VStack>
{/* 精简事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-1px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={3}
>
<CardBody p={4}>
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
{/* 左侧:标题和时间 */}
<VStack align="start" spacing={2} flex="1" minW="200px">
<Heading
size="sm"
color={linkColor}
_hover={{ textDecoration: 'underline' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
noOfLines={1}
>
{event.title}
</Heading>
<HStack spacing={2} fontSize="xs" color={mutedColor}>
<TimeIcon />
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
<Text></Text>
<Text>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
</VStack>
{/* 右侧:涨跌幅指标 */}
<HStack spacing={3}>
<Tooltip label="平均涨幅" placement="top">
<Box
px={3}
py={1}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="1px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
>
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text
fontSize="sm"
fontWeight="bold"
color={getPriceChangeColor(event.related_avg_chg)}
>
{event.related_avg_chg != null
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Box>
</Tooltip>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详情
</Button>
<Button
size="sm"
variant={isFollowing ? 'solid' : 'outline'}
colorScheme="yellow"
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
</Button>
</HStack>
</Flex>
</CardBody>
</Card>
</HStack>
);
};
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
const renderDetailedEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="40px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="lg"
boxShadow="md"
border="3px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="100px"
/>
</VStack>
{/* 事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-2px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={4}
>
<CardBody p={5}>
<VStack align="stretch" spacing={3}>
{/* 标题和重要性标签 */}
<Flex align="center" justify="space-between">
<Tooltip
label="点击查看事件详情"
placement="top"
hasArrow
openDelay={500}
>
<Heading
size="md"
color={linkColor}
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
>
{event.title}
</Heading>
</Tooltip>
<Badge
colorScheme={importance.color.split('.')[0]}
px={3}
py={1}
borderRadius="full"
fontSize="sm"
>
{importance.label}优先级
</Badge>
</Flex>
{/* 元信息 */}
<HStack spacing={4} fontSize="sm">
<HStack
bg="blue.50"
px={3}
py={1}
borderRadius="full"
color="blue.700"
fontWeight="medium"
>
<TimeIcon />
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
</HStack>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
{/* 描述 */}
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
{event.description}
</Text>
{/* 价格变化指标 */}
<Box
bg={useColorModeValue('gradient.subtle', 'gray.700')}
bgGradient="linear(to-r, gray.50, white)"
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
平均涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
{event.related_avg_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text fontWeight="bold">
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_max_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
最大涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
{event.related_max_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_max_chg} />
<Text fontWeight="bold">
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_week_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
周涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
{event.related_week_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_week_chg} />
<Text fontWeight="bold">
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
</SimpleGrid>
</Box>
<Divider />
{/* 统计信息和操作按钮 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
<HStack spacing={6}>
<Tooltip label="浏览量" placement="top">
<HStack spacing={1} color={mutedColor}>
<ViewIcon />
<Text fontSize="sm">{event.view_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="帖子数" placement="top">
<HStack spacing={1} color={mutedColor}>
<ChatIcon />
<Text fontSize="sm">{event.post_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="关注数" placement="top">
<HStack spacing={1} color={mutedColor}>
<StarIcon />
<Text fontSize="sm">{followerCount}</Text>
</HStack>
</Tooltip>
</HStack>
<ButtonGroup size="sm" spacing={2}>
<Button
variant="outline"
colorScheme="gray"
leftIcon={<ViewIcon />}
onClick={(e) => {
e.stopPropagation();
onEventClick(event);
}}
>
快速查看
</Button>
<Button
colorScheme="blue"
leftIcon={<ExternalLinkIcon />}
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详细信息
</Button>
<Button
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
</Button>
</ButtonGroup>
</Flex>
</VStack>
</CardBody>
</Card>
</HStack>
);
};
// 分页组件
const Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
return (
<Flex justify="center" align="center" mt={8} gap={2}>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current - 1)}
isDisabled={current === 1}
>
上一页
</Button>
<HStack spacing={1}>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
size="sm"
variant={current === pageNum ? 'solid' : 'ghost'}
colorScheme={current === pageNum ? 'blue' : 'gray'}
onClick={() => onChange(pageNum)}
>
{pageNum}
</Button>
);
})}
{totalPages > 5 && <Text>...</Text>}
{totalPages > 5 && (
<Button
size="sm"
variant={current === totalPages ? 'solid' : 'ghost'}
colorScheme={current === totalPages ? 'blue' : 'gray'}
onClick={() => onChange(totalPages)}
>
{totalPages}
</Button>
)}
</HStack>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current + 1)}
isDisabled={current === totalPages}
>
下一页
</Button>
<Text fontSize="sm" color={mutedColor} ml={4}>
{total}
</Text>
</Flex>
);
};
return (
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="container.xl">
{/* 视图切换控制 */}
<Flex justify="flex-end" mb={6}>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
精简模式
</FormLabel>
<Switch
id="compact-mode"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
{events.length > 0 ? (
<VStack align="stretch" spacing={0}>
{events.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
? renderCompactEvent(event)
: renderDetailedEvent(event)
}
</Box>
))}
</VStack>
) : (
<Center h="300px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
</Center>
)}
{pagination.total > 0 && (
<Pagination
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
)}
</Container>
</Box>
);
};
export default EventList;

View File

@@ -1,75 +0,0 @@
// src/views/Community/components/EventListSection.js
// 事件列表区域组件包含Loading、Empty、List三种状态
import React from 'react';
import {
Box,
Center,
VStack,
Spinner,
Text
} from '@chakra-ui/react';
import EventList from './EventList';
/**
* 事件列表区域组件
* @param {boolean} loading - 加载状态
* @param {Array} events - 事件列表
* @param {Object} pagination - 分页信息
* @param {Function} onPageChange - 分页变化回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
*/
const EventListSection = ({
loading,
events,
pagination,
onPageChange,
onEventClick,
onViewDetail
}) => {
// ✅ 最小高度,避免加载后高度突变
const minHeight = '600px';
// Loading 状态
if (loading) {
return (
<Box minH={minHeight}>
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载最新事件...</Text>
</VStack>
</Center>
</Box>
);
}
// Empty 状态
if (!events || events.length === 0) {
return (
<Box minH={minHeight}>
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
</VStack>
</Center>
</Box>
);
}
// List 状态
return (
<Box minH={minHeight}>
<EventList
events={events}
pagination={pagination}
onPageChange={onPageChange}
onEventClick={onEventClick}
onViewDetail={onViewDetail}
/>
</Box>
);
};
export default EventListSection;

View File

@@ -1,42 +0,0 @@
// src/views/Community/components/EventTimelineHeader.js
// 事件时间轴标题组件
import React from 'react';
import {
Flex,
VStack,
HStack,
Heading,
Text,
Badge
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
/**
* 事件时间轴标题组件
* @param {Date} lastUpdateTime - 最后更新时间
*/
const EventTimelineHeader = ({ lastUpdateTime }) => {
return (
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>实时事件</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="green">全网监控</Badge>
<Badge colorScheme="orange">智能捕获</Badge>
<Badge colorScheme="purple">深度分析</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime.toLocaleTimeString()}
</Text>
</Flex>
);
};
export default EventTimelineHeader;

View File

@@ -1,34 +0,0 @@
// src/views/Community/components/ImportanceLegend.js
import React from 'react';
import { Card, Space, Badge } from 'antd';
const ImportanceLegend = () => {
const levels = [
{ level: 'S', color: '#ff4d4f', description: '重大事件,市场影响深远' },
{ level: 'A', color: '#faad14', description: '重要事件,影响较大' },
{ level: 'B', color: '#1890ff', description: '普通事件,有一定影响' },
{ level: 'C', color: '#52c41a', description: '参考事件,影响有限' }
];
return (
<Card title="重要性等级说明" className="importance-legend">
<Space direction="vertical" style={{ width: '100%' }}>
{levels.map(item => (
<div key={item.level} style={{ display: 'flex', alignItems: 'center' }}>
<Badge
color={item.color}
text={
<span>
<strong style={{ marginRight: 8 }}>{item.level}</strong>
{item.description}
</span>
}
/>
</div>
))}
</Space>
</Card>
);
};
export default ImportanceLegend;

View File

@@ -1,78 +0,0 @@
// src/views/Community/components/IndustryCascader.js
import React, { useState, useCallback } from 'react';
import { Card, Form, Cascader } from 'antd';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { logger } from '../../../utils/logger';
const IndustryCascader = ({ onFilterChange, loading }) => {
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
// 使用 Redux 获取行业数据
const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = useCallback(async () => {
if (!industryData || industryData.length === 0) {
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
await dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// Cascader 选择变化
const handleIndustryCascaderChange = (value, selectedOptions) => {
setIndustryCascaderValue(value);
if (value && value.length > 0) {
// value[0] = 分类体系名称
// value[1...n] = 行业代码(一级~四级)
const industryCode = value[value.length - 1]; // 最后一级的 code
const classification = value[0]; // 分类体系名称
onFilterChange('industry_classification', classification);
onFilterChange('industry_code', industryCode);
logger.debug('IndustryCascader', 'Cascader 选择变化', {
value,
classification,
industryCode,
path: selectedOptions.map(o => o.label).join(' > ')
});
} else {
// 清空
onFilterChange('industry_classification', '');
onFilterChange('industry_code', '');
}
};
return (
<Card className="industry-cascader" title="行业分类" style={{ marginBottom: 16 }}>
<Form layout="vertical">
<Form.Item label="选择行业分类体系和具体行业">
<Cascader
options={industryData || []}
value={industryCascaderValue}
onChange={handleIndustryCascaderChange}
onFocus={handleCascaderFocus}
changeOnSelect
placeholder={industryLoading ? "加载中..." : "请选择行业分类体系和具体行业"}
disabled={loading || industryLoading}
loading={industryLoading}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
showSearch={{
filter: (inputValue, path) =>
path.some(option => option.label.toLowerCase().includes(inputValue.toLowerCase()))
}}
style={{ width: '100%' }}
/>
</Form.Item>
</Form>
</Card>
);
};
export default IndustryCascader;

View File

@@ -1,300 +0,0 @@
// src/views/Community/components/MarketReviewCard.js
// 市场复盘组件(左右布局:事件列表 | 事件详情)
import React, { forwardRef, useState } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
Flex,
VStack,
HStack,
Heading,
Text,
Badge,
Center,
Spinner,
useColorModeValue,
Grid,
GridItem,
} from '@chakra-ui/react';
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
import dayjs from 'dayjs';
import CompactEventCard from './EventCard/CompactEventCard';
import EventHeader from './EventCard/EventHeader';
import EventStats from './EventCard/EventStats';
import EventFollowButton from './EventCard/EventFollowButton';
import EventPriceDisplay from './EventCard/EventPriceDisplay';
import EventDescription from './EventCard/EventDescription';
import { getImportanceConfig } from '../../../constants/importanceLevels';
/**
* 市场复盘 - 左右布局卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Function} onToggleFollow - 切换关注回调
* @param {Object} ref - 用于滚动的ref
*/
const MarketReviewCard = forwardRef(({
events,
loading,
lastUpdateTime,
onEventClick,
onViewDetail,
onToggleFollow,
...rest
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const textColor = useColorModeValue('gray.700', 'gray.200');
const selectedBg = useColorModeValue('blue.50', 'blue.900');
// 选中的事件
const [selectedEvent, setSelectedEvent] = useState(null);
// 时间轴样式配置
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
// 处理事件点击
const handleEventClick = (event) => {
setSelectedEvent(event);
if (onEventClick) {
onEventClick(event);
}
};
// 渲染右侧事件详情
const renderEventDetail = () => {
if (!selectedEvent) {
return (
<Center h="full" minH="400px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
请从左侧选择事件查看详情
</Text>
</VStack>
</Center>
);
}
const importance = getImportanceConfig(selectedEvent.importance);
return (
<Card
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
boxShadow="md"
h="full"
>
<CardBody p={6}>
<VStack align="stretch" spacing={4}>
{/* 第一行:标题+优先级 | 统计+关注 */}
<Flex align="center" justify="space-between" gap={3}>
{/* 左侧:标题 + 优先级标签 */}
<EventHeader
title={selectedEvent.title}
importance={selectedEvent.importance}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onViewDetail) {
onViewDetail(e, selectedEvent.id);
}
}}
linkColor={linkColor}
compact={false}
size="lg"
/>
{/* 右侧:统计数据 + 关注按钮 */}
<HStack spacing={4} flexShrink={0}>
{/* 统计数据 */}
<EventStats
viewCount={selectedEvent.view_count}
postCount={selectedEvent.post_count}
followerCount={selectedEvent.follower_count}
size="md"
spacing={4}
display="flex"
mutedColor={mutedColor}
/>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={false}
followerCount={selectedEvent.follower_count}
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
size="sm"
showCount={false}
/>
</HStack>
</Flex>
{/* 第二行:价格标签 | 时间+作者 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
{/* 左侧:价格标签 */}
<EventPriceDisplay
avgChange={selectedEvent.related_avg_chg}
maxChange={selectedEvent.related_max_chg}
weekChange={selectedEvent.related_week_chg}
compact={false}
/>
{/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}>
{dayjs(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
</HStack>
</Flex>
{/* 第三行:描述文字 */}
<EventDescription
description={selectedEvent.description}
textColor={textColor}
minLength={200}
noOfLines={10}
/>
</VStack>
</CardBody>
</Card>
);
};
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Heading size="md">
<HStack>
<TimeIcon />
<Text>市场复盘</Text>
</HStack>
</Heading>
<HStack fontSize="sm" color="gray.500">
<Badge colorScheme="orange">复盘</Badge>
<Badge colorScheme="purple">总结</Badge>
<Badge colorScheme="gray">完整</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</Flex>
</CardHeader>
{/* 主体内容 */}
<CardBody>
{/* Loading 状态 */}
{loading && (
<Center py={10}>
<VStack>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">正在加载复盘数据...</Text>
</VStack>
</Center>
)}
{/* Empty 状态 */}
{!loading && (!events || events.length === 0) && (
<Center py={10}>
<VStack>
<Text fontSize="lg" color="gray.500">暂无复盘数据</Text>
</VStack>
</Center>
)}
{/* 左右布局:事件列表 | 事件详情 */}
{!loading && events && events.length > 0 && (
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
{/* 左侧:事件列表 (33.3%) */}
<GridItem>
<Box
overflowY="auto"
maxH="600px"
pr={2}
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: useColorModeValue('#f1f1f1', '#2D3748'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('#888', '#4A5568'),
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('#555', '#718096'),
},
}}
>
<VStack align="stretch" spacing={2}>
{events.map((event, index) => (
<Box
key={event.id}
onClick={() => handleEventClick(event)}
cursor="pointer"
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
borderRadius="md"
transition="all 0.2s"
_hover={{ bg: selectedBg }}
>
<CompactEventCard
event={event}
index={index}
isFollowing={false}
followerCount={event.follower_count || 0}
onEventClick={() => handleEventClick(event)}
onTitleClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEventClick(event);
}}
onViewDetail={onViewDetail}
onToggleFollow={() => {}}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</Box>
))}
</VStack>
</Box>
</GridItem>
{/* 右侧:事件详情 (66.7%) */}
<GridItem>
{renderEventDetail()}
</GridItem>
</Grid>
)}
</CardBody>
</Card>
);
});
MarketReviewCard.displayName = 'MarketReviewCard';
export default MarketReviewCard;

View File

@@ -1,898 +0,0 @@
// src/views/Community/components/UnifiedSearchBox.js
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Card, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import PopularKeywords from './PopularKeywords';
import TradingTimeFilter from './TradingTimeFilter';
const { Option } = AntSelect;
const UnifiedSearchBox = ({
onSearch,
onSearchFocus,
popularKeywords = [],
filters = {},
mode, // 显示模式vertical, horizontal 等)
pageSize, // 每页显示数量
trackingFunctions = {} // PostHog 追踪函数集合
}) => {
// 其他状态
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
const [industryValue, setIndustryValue] = useState([]);
// 筛选条件状态
const [sort, setSort] = useState('new'); // 排序方式
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
const [tradingTimeRange, setTradingTimeRange] = useState(null); // 交易时段筛选
// ✅ 本地输入状态 - 管理用户的实时输入
const [inputValue, setInputValue] = useState('');
// 使用 Redux 获取行业数据
const dispatch = useDispatch();
const industryData = useSelector(selectIndustryData);
const industryLoading = useSelector(selectIndustryLoading);
// 加载行业数据函数
const loadIndustryData = useCallback(() => {
if (!industryData) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 搜索触发函数
const triggerSearch = useCallback((params) => {
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
params: params,
timestamp: new Date().toISOString()
});
onSearch(params);
}, [onSearch]);
// ✅ 创建防抖的搜索函数300ms 延迟)
const debouncedSearchRef = useRef(null);
useEffect(() => {
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
debouncedSearchRef.current = debounce((params) => {
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
params: params,
delayMs: 300
});
triggerSearch(params);
}, 300);
// 清理函数
return () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
};
}, [triggerSearch]);
// 加载所有股票数据
useEffect(() => {
const loadStocks = async () => {
const response = await stockService.getAllStocks();
if (response.success && response.data) {
setAllStocks(response.data);
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
count: response.data.length
});
}
};
loadStocks();
}, []);
// Cascader 获得焦点时加载数据
const handleCascaderFocus = async () => {
if (!industryData || industryData.length === 0) {
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
await loadIndustryData();
}
};
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
// 辅助函数:递归查找行业代码的完整路径
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
}, []);
// ✅ 从 props.filters 初始化筛选条件和输入框值
useEffect(() => {
if (!filters) return;
// 初始化排序
if (filters.sort) setSort(filters.sort);
// 初始化重要性(字符串解析为数组)
if (filters.importance) {
const importanceArray = filters.importance === 'all'
? [] // 'all' 对应空数组(不显示任何选中)
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
setImportance(importanceArray);
logger.debug('UnifiedSearchBox', '初始化重要性', {
filters_importance: filters.importance,
importanceArray
});
} else {
setImportance([]);
}
// ✅ 初始化行业分类(需要 industryData 加载完成)
// ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
logger.debug('UnifiedSearchBox', '初始化行业分类', {
industry_code: filters.industry_code,
path
});
}
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
// 如果 filters 中没有行业代码,但本地有值,清空本地值
setIndustryValue([]);
logger.debug('UnifiedSearchBox', '清空行业分类filters中无值');
}
// ✅ 同步 filters.q 到输入框显示值
if (filters.q) {
setInputValue(filters.q);
} else if (!filters.q) {
// 如果 filters 中没有搜索关键词,清空输入框
setInputValue('');
}
// ✅ 初始化时间筛选(从 filters 中恢复)
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
// 根据参数推断按钮 key
let inferredKey = 'custom';
let inferredLabel = '';
if (filters.recent_days) {
// 推断是否是预设按钮
if (filters.recent_days === '7') {
inferredKey = 'week';
inferredLabel = '近一周';
} else if (filters.recent_days === '30') {
inferredKey = 'month';
inferredLabel = '近一月';
} else {
inferredLabel = `${filters.recent_days}`;
}
} else if (filters.start_date && filters.end_date) {
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
}
// 从 filters 重建 tradingTimeRange 状态
const timeRange = {
start_date: filters.start_date || '',
end_date: filters.end_date || '',
recent_days: filters.recent_days || '',
label: inferredLabel,
key: inferredKey
};
setTradingTimeRange(timeRange);
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
filters_time: {
start_date: filters.start_date,
end_date: filters.end_date,
recent_days: filters.recent_days
},
tradingTimeRange: timeRange
});
} else if (!hasTimeInFilters && tradingTimeRange) {
// 如果 filters 中没有时间参数,但本地有值,清空本地值
setTradingTimeRange(null);
logger.debug('UnifiedSearchBox', '清空时间筛选filters中无值');
}
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
// AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
return;
}
// 使用 stockService 进行模糊搜索
const results = stockService.fuzzySearch(value, allStocks, 10);
// 转换为 AutoComplete 选项格式
const options = results.map(stock => ({
value: stock.code,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockOutlined style={{ color: '#1890ff' }} />
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
<span style={{ color: '#666' }}>{stock.name}</span>
</div>
),
// 保存完整的股票信息,用于选中后显示
stockInfo: stock
}));
setStockOptions(options);
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
query: value,
resultCount: options.length
});
};
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
const handleStockSelect = (_value, option) => {
const stockInfo = option.stockInfo;
if (stockInfo) {
logger.debug('UnifiedSearchBox', '选中股票', {
code: stockInfo.code,
name: stockInfo.name
});
// 🎯 追踪股票点击
if (trackingFunctions.trackRelatedStockClicked) {
trackingFunctions.trackRelatedStockClicked({
stockCode: stockInfo.code,
stockName: stockInfo.name,
source: 'search_box_autocomplete',
timestamp: new Date().toISOString(),
});
}
// 更新输入框显示
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
const params = buildFilterParams({
q: stockInfo.code, // 使用股票代码作为搜索关键词
industry_code: ''
});
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
triggerSearch(params);
}
};
// ✅ 重要性变化(立即执行)- 支持多选
const handleImportanceChange = (value) => {
logger.debug('UnifiedSearchBox', '重要性值改变', {
oldValue: importance,
newValue: value
});
setImportance(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 转换为逗号分隔字符串传给后端(空数组表示"全部"
const importanceStr = value.length === 0 ? 'all' : value.join(',');
// 🎯 追踪筛选操作
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'importance',
filterValue: importanceStr,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({ importance: importanceStr });
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 排序变化(立即触发搜索)
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '排序值改变', {
oldValue: sort,
newValue: value
});
setSort(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 🎯 追踪排序操作
if (trackingFunctions.trackNewsSorted) {
trackingFunctions.trackNewsSorted({
sortBy: value,
previousSortBy: sort,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 行业分类变化(立即触发搜索)
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '行业分类值改变', {
oldValue: industryValue,
newValue: value
});
setIndustryValue(value);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 🎯 追踪行业筛选
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'industry',
filterValue: value?.[value.length - 1] || '',
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
});
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
const handleKeywordClick = (keyword) => {
// 更新输入框显示
setInputValue(keyword);
// 立即触发搜索(取消之前的防抖)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 🎯 追踪热门关键词点击
if (trackingFunctions.trackNewsSearched) {
trackingFunctions.trackNewsSearched({
searchQuery: keyword,
searchType: 'popular_keyword',
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
q: keyword,
industry_code: ''
});
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
keyword,
params
});
triggerSearch(params);
};
// ✅ 交易时段筛选变化(立即触发搜索)
const handleTradingTimeChange = (timeConfig) => {
if (!timeConfig) {
// 清空筛选
setTradingTimeRange(null);
// 🎯 追踪时间筛选清空
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: 'cleared',
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
return;
}
const { range, type, label, key } = timeConfig;
let params = {};
if (type === 'recent_days') {
// 近一周/近一月使用 recent_days
params.recent_days = range;
params.start_date = '';
params.end_date = '';
} else {
// 其他使用 start_date + end_date
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
params.recent_days = '';
}
setTradingTimeRange({ ...params, label, key });
// 🎯 追踪时间筛选
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: label,
timeRangeType: type,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const searchParams = buildFilterParams({ ...params, mode });
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
timeConfig,
params: searchParams
});
triggerSearch(searchParams);
};
// 主搜索(点击搜索按钮或回车)
const handleMainSearch = () => {
// 取消之前的防抖
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
const params = buildFilterParams({
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
industry_code: ''
});
// 🎯 追踪搜索操作
if (trackingFunctions.trackNewsSearched && inputValue) {
trackingFunctions.trackNewsSearched({
searchQuery: inputValue,
searchType: 'main_search',
filters: params,
timestamp: new Date().toISOString(),
});
}
logger.debug('UnifiedSearchBox', '主搜索触发', {
inputValue,
params
});
triggerSearch(params);
};
// ✅ 处理输入变化 - 更新本地输入状态
const handleInputChange = (value) => {
logger.debug('UnifiedSearchBox', '输入变化', { value });
setInputValue(value);
};
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
const buildFilterParams = useCallback((overrides = {}) => {
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
overrides: overrides,
currentState: {
sort,
importance,
industryValue,
'filters.q': filters.q,
mode,
pageSize
}
});
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
const sortValue = overrides.sort ?? sort;
let actualSort = sortValue;
let returnType;
if (sortValue === 'returns_avg') {
actualSort = 'returns';
returnType = 'avg';
} else if (sortValue === 'returns_week') {
actualSort = 'returns';
returnType = 'week';
}
// 处理重要性参数:数组转换为逗号分隔字符串
let importanceValue = overrides.importance ?? importance;
if (Array.isArray(importanceValue)) {
importanceValue = importanceValue.length === 0
? 'all'
: importanceValue.join(',');
}
const result = {
// 基础参数overrides 优先级高于本地状态)
sort: actualSort,
importance: importanceValue,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '',
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
// 交易时段筛选参数
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
// 最终 overrides 具有最高优先级
...overrides,
page: 1,
per_page: overrides.mode === 'four-row' ? 30: 10
};
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
delete result.per_page;
// 添加 return_type 参数(如果需要)
if (returnType) {
result.return_type = returnType;
}
// 添加 mode 和 per_page 参数(如果提供了的话)
if (mode !== undefined && mode !== null) {
result.mode = mode;
}
if (pageSize !== undefined && pageSize !== null) {
result.per_page = pageSize; // 后端实际使用的参数
}
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
console.log('%c🔄 [重置] 开始重置筛选条件', 'color: #FF4D4F; font-weight: bold;');
// 重置所有筛选器状态
setInputValue(''); // 清空输入框
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setImportance([]); // 改为空数组
setTradingTimeRange(null); // 清空交易时段筛选
// 🎯 追踪筛选重置
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'reset',
filterValue: 'all_filters_cleared',
timestamp: new Date().toISOString(),
});
}
// 输出重置后的完整参数
const resetParams = {
q: '',
industry_code: '',
sort: 'new',
importance: 'all', // 传给后端时转为'all'
start_date: '',
end_date: '',
recent_days: '',
page: 1,
_forceRefresh: Date.now() // 添加强制刷新标志,确保每次重置都触发更新
};
console.log('%c🔄 [重置] 重置参数', 'color: #FF4D4F;', resetParams);
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
console.log('%c🔄 [重置] 调用 onSearch', 'color: #FF4D4F;', typeof onSearch);
onSearch(resetParams);
console.log('%c✅ [重置] 重置完成', 'color: #52C41A; font-weight: bold;');
};
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
const filterTags = useMemo(() => {
const tags = [];
// 搜索关键词标签 - 从 filters.q 读取
if (filters.q) {
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
}
// 行业标签
if (industryValue && industryValue.length > 0 && industryData) {
// 递归查找每个层级的 label
const findLabel = (code, data) => {
for (const item of data) {
if (code.startsWith(item.value)) {
if (item.value === code) {
return item.label;
} else {
return findLabel(code, item.children);
}
}
}
return null;
};
// 只显示最后一级的 label
const lastLevelCode = industryValue[industryValue.length - 1];
const lastLevelLabel = findLabel(lastLevelCode, industryData);
tags.push({
key: 'industry',
label: `行业: ${lastLevelLabel}`
});
}
// 交易时段筛选标签
if (tradingTimeRange?.label) {
tags.push({
key: 'trading_time',
label: `时间: ${tradingTimeRange.label}`
});
}
// 重要性标签(多选合并显示为单个标签)
if (importance && importance.length > 0) {
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
}
// 排序标签(排除默认值 'new'
if (sort && sort !== 'new') {
let sortLabel;
if (sort === 'hot') sortLabel = '最热';
else if (sort === 'importance') sortLabel = '重要性';
else if (sort === 'returns_avg') sortLabel = '平均收益率';
else if (sort === 'returns_week') sortLabel = '周收益率';
else sortLabel = sort;
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
}
return tags;
}, [filters.q, industryValue, importance, sort, tradingTimeRange]);
// ✅ 移除单个标签 - 构建新参数并触发搜索
const handleRemoveTag = (key) => {
logger.debug('UnifiedSearchBox', '移除标签', { key });
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
if (key === 'search') {
// 清除搜索关键词和输入框,立即触发搜索
setInputValue(''); // 清空输入框
const params = buildFilterParams({ q: '' });
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
triggerSearch(params);
} else if (key === 'industry') {
// 清除行业选择
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
} else if (key === 'trading_time') {
// 清除交易时段筛选
setTradingTimeRange(null);
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
} else if (key === 'importance') {
// 重置重要性为空数组(传给后端为'all'
setImportance([]);
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
} else if (key === 'sort') {
// 重置排序为默认值
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}
};
return (
<div style={{padding: '8px'}}>
{/* 第三行:行业 + 重要性 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="small" wrap>
<span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业分类"
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 160 }}
size="small"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ width: 120 }}
size="small"
placeholder="全部"
maxTagCount={3}
>
<Option value="S">极高</Option>
<Option value="A"></Option>
<Option value="B"></Option>
<Option value="C"></Option>
</AntSelect>
</Space>
{/* 搜索图标(可点击) + 搜索框 */}
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
<SearchOutlined
onClick={handleMainSearch}
style={{
fontSize: 14,
padding: '5px 8px',
background: '#e6f7ff',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#1890ff',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#096dd9';
e.currentTarget.style.background = '#bae7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
/>
<AutoComplete
value={inputValue}
onChange={handleInputChange}
onSearch={handleSearch}
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
placeholder="请输入股票代码/股票名称/相关话题"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMainSearch();
}
}}
style={{ flex: 1 }}
size="small"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
</Space.Compact>
{/* 重置按钮 - 现代化设计 */}
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="small"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff4d4f';
e.currentTarget.style.color = '#ff4d4f';
e.currentTarget.style.backgroundColor = '#fff1f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = '#666';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
重置
</Button>
</Space>
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 12, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 100 }}
size="small"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
<Option value="importance">重要性</Option>
<Option value="returns_avg">平均收益率</Option>
<Option value="returns_week">周收益率</Option>
</AntSelect>
</Space>
</Space>
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
<Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
{/* 交易时段筛选 */}
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
/>
</Space>
{/* 第二行:热门概念 */}
<div style={{ marginTop: 2 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div>
</div>
);
};
export default UnifiedSearchBox;

View File

@@ -0,0 +1,376 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Box,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Text,
HStack,
VStack,
Badge,
Spinner,
Flex,
Icon,
Button,
Input,
useToast,
} from '@chakra-ui/react';
import { FaTable, FaChartLine, FaCalendar, FaDownload } from 'react-icons/fa';
import { fetchMetricData, MetricDataResponse, TreeMetric } from '@services/categoryService';
import TradingViewChart from './TradingViewChart';
// 黑金主题配色
const themeColors = {
bg: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
card: '#1e1e1e',
cardHover: '#252525',
},
text: {
primary: '#ffffff',
secondary: '#b8b8b8',
muted: '#808080',
gold: '#D4AF37',
},
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
goldGlow: 'rgba(212, 175, 55, 0.5)',
},
primary: {
gold: '#D4AF37',
goldLight: '#F4E3A7',
goldDark: '#B8941F',
},
};
interface MetricDataModalProps {
isOpen: boolean;
onClose: () => void;
metric: TreeMetric;
}
const MetricDataModal: React.FC<MetricDataModalProps> = ({ isOpen, onClose, metric }) => {
const [loading, setLoading] = useState(false);
const [metricData, setMetricData] = useState<MetricDataResponse | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [limit, setLimit] = useState(100);
const toast = useToast();
// 加载数据
useEffect(() => {
if (isOpen && metric) {
loadMetricData();
}
}, [isOpen, metric]);
const loadMetricData = async () => {
setLoading(true);
try {
const data = await fetchMetricData(metric.metric_id, startDate, endDate, limit);
setMetricData(data);
} catch (error) {
toast({
title: '加载失败',
description: '无法加载指标数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
}
};
// 数据已经在 metricData 中,直接传递给 TradingViewChart
// 导出CSV
const handleExportCSV = () => {
if (!metricData || !metricData.data) return;
const csvContent = [
['日期', '数值', '单位'].join(','),
...metricData.data.map((item) => [item.date, item.value ?? '', metricData.unit || ''].join(',')),
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${metricData.metric_name}_${Date.now()}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast({
title: '导出成功',
description: 'CSV 文件已下载',
status: 'success',
duration: 2000,
});
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
maxH="90vh"
>
<ModalHeader
bg={themeColors.bg.secondary}
borderBottomWidth="1px"
borderBottomColor={themeColors.border.gold}
>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
{metric.metric_name}
</Text>
<HStack spacing={2}>
<Badge bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'} color="white">
{metric.source}
</Badge>
<Badge bg={themeColors.border.gold} color={themeColors.primary.gold}>
{metric.frequency}
</Badge>
</HStack>
</HStack>
<HStack spacing={4} fontSize="sm" color={themeColors.text.secondary}>
<Text>ID: {metric.metric_id}</Text>
{metric.unit && <Text>: {metric.unit}</Text>}
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color={themeColors.text.secondary} />
<ModalBody p={0}>
{loading ? (
<Flex justify="center" align="center" py={20}>
<VStack spacing={4}>
<Spinner size="xl" color={themeColors.primary.gold} thickness="4px" />
<Text color={themeColors.text.secondary}>...</Text>
</VStack>
</Flex>
) : (
<>
{/* 筛选工具栏 */}
<Box
p={4}
bg={themeColors.bg.secondary}
borderBottomWidth="1px"
borderBottomColor={themeColors.border.default}
>
<HStack spacing={4} wrap="wrap">
<HStack flex="1" minW="200px">
<Icon as={FaCalendar} color={themeColors.text.muted} />
<Input
type="date"
size="sm"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
bg={themeColors.bg.card}
borderColor={themeColors.border.default}
color={themeColors.text.primary}
_focus={{ borderColor: themeColors.primary.gold }}
/>
<Text color={themeColors.text.muted}></Text>
<Input
type="date"
size="sm"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
bg={themeColors.bg.card}
borderColor={themeColors.border.default}
color={themeColors.text.primary}
_focus={{ borderColor: themeColors.primary.gold }}
/>
</HStack>
<HStack>
<Text color={themeColors.text.muted} fontSize="sm">
:
</Text>
<Input
type="number"
size="sm"
w="100px"
value={limit}
onChange={(e) => setLimit(parseInt(e.target.value) || 100)}
bg={themeColors.bg.card}
borderColor={themeColors.border.default}
color={themeColors.text.primary}
_focus={{ borderColor: themeColors.primary.gold }}
/>
</HStack>
<Button
size="sm"
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
_hover={{ bg: themeColors.primary.goldLight }}
onClick={loadMetricData}
>
</Button>
<Button
size="sm"
variant="outline"
borderColor={themeColors.border.gold}
color={themeColors.text.gold}
leftIcon={<FaDownload />}
onClick={handleExportCSV}
isDisabled={!metricData || !metricData.data || metricData.data.length === 0}
>
CSV
</Button>
</HStack>
</Box>
{/* 数据展示 */}
{metricData && (
<Tabs
colorScheme="yellow"
variant="enclosed"
bg={themeColors.bg.primary}
>
<TabList borderBottomColor={themeColors.border.default}>
<Tab
color={themeColors.text.secondary}
_selected={{
color: themeColors.text.gold,
borderColor: themeColors.border.gold,
bg: themeColors.bg.card,
}}
>
<Icon as={FaChartLine} mr={2} />
线
</Tab>
<Tab
color={themeColors.text.secondary}
_selected={{
color: themeColors.text.gold,
borderColor: themeColors.border.gold,
bg: themeColors.bg.card,
}}
>
<Icon as={FaTable} mr={2} />
</Tab>
</TabList>
<TabPanels>
{/* 折线图 - 使用 TradingView Lightweight Charts */}
<TabPanel p={4}>
{metricData && metricData.data.length > 0 ? (
<TradingViewChart
data={metricData.data}
metricName={metricData.metric_name}
unit={metricData.unit}
frequency={metricData.frequency}
/>
) : (
<Flex justify="center" align="center" py={20}>
<Text color={themeColors.text.muted}></Text>
</Flex>
)}
</TabPanel>
{/* 数据表格 */}
<TabPanel p={0}>
<Box maxH="500px" overflowY="auto">
<Table variant="simple" size="sm">
<Thead
position="sticky"
top={0}
bg={themeColors.bg.secondary}
zIndex={1}
>
<Tr>
<Th
color={themeColors.text.gold}
borderColor={themeColors.border.default}
>
</Th>
<Th
color={themeColors.text.gold}
borderColor={themeColors.border.default}
>
</Th>
<Th
color={themeColors.text.gold}
borderColor={themeColors.border.default}
isNumeric
>
{metricData.unit && `(${metricData.unit})`}
</Th>
</Tr>
</Thead>
<Tbody>
{metricData.data.map((item, index) => (
<Tr
key={index}
_hover={{ bg: themeColors.bg.cardHover }}
>
<Td
color={themeColors.text.muted}
borderColor={themeColors.border.default}
>
{index + 1}
</Td>
<Td
color={themeColors.text.secondary}
borderColor={themeColors.border.default}
>
{item.date}
</Td>
<Td
color={themeColors.text.primary}
borderColor={themeColors.border.default}
isNumeric
fontWeight="bold"
>
{item.value !== null ? item.value.toLocaleString() : '-'}
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{metricData.data.length === 0 && (
<Flex justify="center" align="center" py={20}>
<Text color={themeColors.text.muted}></Text>
</Flex>
)}
</TabPanel>
</TabPanels>
</Tabs>
)}
</>
)}
</ModalBody>
</ModalContent>
</Modal>
);
};
export default MetricDataModal;

View File

@@ -0,0 +1,495 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
ButtonGroup,
Flex,
Icon,
useColorMode,
Tooltip,
} from '@chakra-ui/react';
import { createChart, LineSeries } from 'lightweight-charts';
import type { IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts';
import {
FaExpand,
FaCompress,
FaCamera,
FaRedo,
FaCog,
} from 'react-icons/fa';
import { MetricDataPoint } from '@services/categoryService';
// 黑金主题配色
const themeColors = {
bg: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
card: '#1e1e1e',
},
text: {
primary: '#ffffff',
secondary: '#b8b8b8',
muted: '#808080',
gold: '#D4AF37',
},
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
},
primary: {
gold: '#D4AF37',
goldLight: '#F4E3A7',
},
};
interface TradingViewChartProps {
data: MetricDataPoint[];
metricName: string;
unit: string;
frequency: string;
}
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
const TradingViewChart: React.FC<TradingViewChartProps> = ({
data,
metricName,
unit,
frequency,
}) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
const { colorMode } = useColorMode();
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current || data.length === 0) return;
try {
// 创建图表 (lightweight-charts 5.0 标准 API)
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 500,
layout: {
background: { type: 'solid', color: themeColors.bg.card },
textColor: themeColors.text.secondary,
},
grid: {
vertLines: {
color: 'rgba(255, 255, 255, 0.05)',
},
horzLines: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
crosshair: {
vertLine: {
color: themeColors.primary.gold,
width: 1,
style: 3, // 虚线
labelBackgroundColor: themeColors.primary.gold,
},
horzLine: {
color: themeColors.primary.gold,
width: 1,
style: 3,
labelBackgroundColor: themeColors.primary.gold,
},
},
rightPriceScale: {
borderColor: themeColors.border.default,
},
timeScale: {
borderColor: themeColors.border.default,
timeVisible: true,
secondsVisible: false,
rightOffset: 12,
barSpacing: 3,
fixLeftEdge: false,
lockVisibleTimeRangeOnResize: true,
rightBarStaysOnScroll: true,
borderVisible: true,
visible: true,
},
localization: {
timeFormatter: (time: any) => {
// 格式化时间显示
const date = new Date(time * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
axisPressedMouseMove: true,
mouseWheel: true,
pinch: true,
},
});
// 创建折线系列 (lightweight-charts 5.0 使用 addSeries 方法)
// 第一个参数是 series 类本身(不是实例)
const lineSeries = chart.addSeries(LineSeries, {
color: themeColors.primary.gold,
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 6,
crosshairMarkerBorderColor: themeColors.primary.goldLight,
crosshairMarkerBackgroundColor: themeColors.primary.gold,
lastValueVisible: true,
priceLineVisible: true,
priceLineColor: themeColors.primary.gold,
priceLineWidth: 1,
priceLineStyle: 3, // 虚线
title: metricName,
});
// 转换数据格式
// lightweight-charts 5.0 需要 YYYY-MM-DD 格式的字符串作为 time
const chartData: LineData[] = data
.filter((item) => item.value !== null)
.map((item) => {
// 确保日期格式为 YYYY-MM-DD
const dateStr = item.date.trim();
return {
time: dateStr as Time,
value: item.value as number,
};
})
.sort((a, b) => {
// 确保时间从左到右递增
const timeA = new Date(a.time as string).getTime();
const timeB = new Date(b.time as string).getTime();
return timeA - timeB;
});
console.log('📊 转换后的图表数据前3条:', chartData.slice(0, 3));
console.log('📊 数据总数:', chartData.length);
// 设置数据
lineSeries.setData(chartData);
// 自动缩放到合适的视图
chart.timeScale().fitContent();
chartRef.current = chart;
lineSeriesRef.current = lineSeries;
// 响应式调整
const handleResize = () => {
if (chartContainerRef.current && chart) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
} catch (error) {
console.error('❌ TradingView Chart 初始化失败:', error);
console.error('Error details:', {
message: error.message,
stack: error.stack,
createChartType: typeof createChart,
LineSeriesType: typeof LineSeries,
});
// 重新抛出错误让 ErrorBoundary 捕获
throw error;
}
}, [data, metricName]);
// 时间范围筛选
const handleTimeRangeChange = (range: TimeRange) => {
setSelectedRange(range);
if (!chartRef.current || data.length === 0) return;
const now = new Date();
let startDate: Date;
switch (range) {
case '1M':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
break;
case '3M':
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
break;
case '6M':
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
break;
case '1Y':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
case 'YTD':
startDate = new Date(now.getFullYear(), 0, 1); // 当年1月1日
break;
case 'ALL':
default:
chartRef.current.timeScale().fitContent();
return;
}
// 设置可见范围
const startTimestamp = startDate.getTime() / 1000;
const endTimestamp = now.getTime() / 1000;
chartRef.current.timeScale().setVisibleRange({
from: startTimestamp as Time,
to: endTimestamp as Time,
});
};
// 重置缩放
const handleReset = () => {
if (chartRef.current) {
chartRef.current.timeScale().fitContent();
setSelectedRange('ALL');
}
};
// 截图功能
const handleScreenshot = () => {
if (!chartRef.current) return;
const canvas = chartContainerRef.current?.querySelector('canvas');
if (!canvas) return;
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
link.click();
URL.revokeObjectURL(url);
});
};
// 全屏切换
const toggleFullscreen = () => {
if (!chartContainerRef.current) return;
if (!isFullscreen) {
if (chartContainerRef.current.requestFullscreen) {
chartContainerRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
setIsFullscreen(!isFullscreen);
};
// 计算统计数据
const stats = React.useMemo(() => {
const values = data.filter((item) => item.value !== null).map((item) => item.value as number);
if (values.length === 0) {
return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 };
}
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((sum, val) => sum + val, 0) / values.length;
const latest = values[values.length - 1];
const first = values[0];
const change = latest - first;
const changePercent = first !== 0 ? (change / first) * 100 : 0;
return { min, max, avg, latest, change, changePercent };
}, [data]);
// 格式化数字
const formatNumber = (num: number) => {
if (Math.abs(num) >= 1e9) {
return (num / 1e9).toFixed(2) + 'B';
}
if (Math.abs(num) >= 1e6) {
return (num / 1e6).toFixed(2) + 'M';
}
if (Math.abs(num) >= 1e3) {
return (num / 1e3).toFixed(2) + 'K';
}
return num.toFixed(2);
};
return (
<VStack align="stretch" spacing={4} w="100%">
{/* 工具栏 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
{/* 时间范围选择 */}
<ButtonGroup size="sm" isAttached variant="outline">
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
<Button
key={range}
onClick={() => handleTimeRangeChange(range)}
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
color={
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
}
borderColor={themeColors.border.gold}
_hover={{
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
}}
>
{range}
</Button>
))}
</ButtonGroup>
{/* 图表操作 */}
<HStack spacing={2}>
<Tooltip label="重置视图">
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={handleReset}
>
<Icon as={FaRedo} />
</Button>
</Tooltip>
<Tooltip label="截图">
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={handleScreenshot}
>
<Icon as={FaCamera} />
</Button>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={toggleFullscreen}
>
<Icon as={isFullscreen ? FaCompress : FaExpand} />
</Button>
</Tooltip>
</HStack>
</Flex>
{/* 统计数据 */}
<Flex
justify="space-around"
align="center"
bg={themeColors.bg.secondary}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={themeColors.border.default}
wrap="wrap"
gap={4}
>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
{formatNumber(stats.latest)} {unit}
</Text>
<Text
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
fontSize="xs"
fontWeight="bold"
>
{stats.change >= 0 ? '+' : ''}
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{formatNumber(stats.avg)} {unit}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{formatNumber(stats.max)} {unit}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{formatNumber(stats.min)} {unit}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{data.filter((item) => item.value !== null).length}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{frequency}
</Text>
</VStack>
</Flex>
{/* 图表容器 */}
<Box
ref={chartContainerRef}
w="100%"
h="500px"
borderRadius="md"
borderWidth="1px"
borderColor={themeColors.border.gold}
overflow="hidden"
position="relative"
bg={themeColors.bg.card}
/>
{/* 提示信息 */}
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
<HStack spacing={4}>
<Text>💡 </Text>
</HStack>
<Text>: {metricName}</Text>
</Flex>
</VStack>
);
};
export default TradingViewChart;

View File

@@ -0,0 +1,868 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Container,
Flex,
Text,
Input,
Button,
VStack,
HStack,
Badge,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Icon,
Spinner,
useToast,
Card,
CardBody,
Divider,
SimpleGrid,
useDisclosure,
} from '@chakra-ui/react';
import {
FaDatabase,
FaFolder,
FaFolderOpen,
FaFile,
FaSearch,
FaHome,
FaChevronRight,
FaChevronDown,
FaTimes,
FaEye,
} from 'react-icons/fa';
import { motion } from 'framer-motion';
import {
fetchCategoryTree,
fetchCategoryNode,
searchMetrics,
TreeNode,
TreeMetric,
CategoryTreeResponse,
MetricSearchResult,
SearchResponse
} from '@services/categoryService';
import MetricDataModal from './MetricDataModal';
// 黑金主题配色
const themeColors = {
bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
primary: {
gold: '#D4AF37',
goldLight: '#F4E3A7',
goldDark: '#B8941F',
},
bg: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
card: '#1e1e1e',
cardHover: '#252525',
},
text: {
primary: '#ffffff',
secondary: '#b8b8b8',
muted: '#808080',
gold: '#D4AF37',
},
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
goldGlow: 'rgba(212, 175, 55, 0.5)',
},
};
const MotionBox = motion(Box);
const MotionCard = motion(Card);
// 树节点组件(支持懒加载)
const TreeNodeComponent: React.FC<{
node: TreeNode;
source: 'SMM' | 'Mysteel';
onNodeClick: (node: TreeNode) => void;
expandedNodes: Set<string>;
onToggleExpand: (node: TreeNode) => Promise<void>;
searchQuery: string;
loadingNodes: Set<string>;
}> = ({ node, source, onNodeClick, expandedNodes, onToggleExpand, searchQuery, loadingNodes }) => {
const isExpanded = expandedNodes.has(node.path);
const isLoading = loadingNodes.has(node.path);
const hasChildren = node.children && node.children.length > 0;
const hasMetrics = node.metrics && node.metrics.length > 0;
// 高亮搜索关键词
const highlightText = (text: string) => {
if (!searchQuery) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === searchQuery.toLowerCase() ? (
<Text as="span" key={index} color={themeColors.primary.gold} fontWeight="bold">
{part}
</Text>
) : (
part
)
);
};
return (
<Box>
<Flex
align="center"
p={2}
pl={node.level * 4}
cursor="pointer"
bg={isExpanded ? themeColors.bg.cardHover : 'transparent'}
_hover={{ bg: themeColors.bg.cardHover }}
borderRadius="md"
transition="all 0.2s"
onClick={() => {
onToggleExpand(node);
onNodeClick(node);
}}
>
{isLoading ? (
<Spinner size="xs" color={themeColors.primary.gold} mr={2} />
) : hasChildren || !hasMetrics ? (
<Icon
as={isExpanded ? FaChevronDown : FaChevronRight}
color={themeColors.text.muted}
mr={2}
fontSize="xs"
/>
) : (
<Box w="16px" mr={2} />
)}
<Icon
as={hasChildren || !hasMetrics ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
color={hasChildren || !hasMetrics ? themeColors.primary.gold : themeColors.text.secondary}
mr={2}
/>
<Text color={themeColors.text.primary} fontSize="sm">
{highlightText(node.name)}
</Text>
{hasMetrics && (
<Badge
ml={2}
bg={themeColors.border.gold}
color={themeColors.primary.gold}
fontSize="xs"
>
{node.metrics.length}
</Badge>
)}
</Flex>
{isExpanded && hasChildren && (
<Box>
{node.children!.map((child) => (
<TreeNodeComponent
key={child.path}
node={child}
source={source}
onNodeClick={onNodeClick}
expandedNodes={expandedNodes}
onToggleExpand={onToggleExpand}
searchQuery={searchQuery}
loadingNodes={loadingNodes}
/>
))}
</Box>
)}
</Box>
);
};
// 指标卡片组件(可点击查看详情)
const MetricCard: React.FC<{ metric: TreeMetric; onClick: () => void }> = ({ metric, onClick }) => {
return (
<MotionCard
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.default}
borderRadius="lg"
overflow="hidden"
cursor="pointer"
onClick={onClick}
whileHover={{
borderColor: themeColors.border.goldGlow,
scale: 1.02,
}}
transition={{ duration: 0.2 }}
>
<CardBody>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text color={themeColors.text.primary} fontWeight="bold" fontSize="sm" flex="1">
{metric.metric_name}
</Text>
<Badge
bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'}
color="white"
fontSize="xs"
>
{metric.source}
</Badge>
</HStack>
<Divider borderColor={themeColors.border.default} />
<SimpleGrid columns={2} spacing={2}>
<Box>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
{metric.frequency}
</Text>
</Box>
<Box>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
{metric.unit || '-'}
</Text>
</Box>
</SimpleGrid>
{metric.description && (
<Text color={themeColors.text.muted} fontSize="xs" noOfLines={2}>
{metric.description}
</Text>
)}
<HStack justify="space-between">
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
ID: {metric.metric_id}
</Text>
<Button
size="xs"
variant="ghost"
color={themeColors.primary.gold}
leftIcon={<FaEye />}
_hover={{ bg: themeColors.bg.cardHover }}
>
</Button>
</HStack>
</VStack>
</CardBody>
</MotionCard>
);
};
const DataBrowser: React.FC = () => {
const [selectedSource, setSelectedSource] = useState<'SMM' | 'Mysteel'>('SMM');
const [treeData, setTreeData] = useState<CategoryTreeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResponse | null>(null);
const [searching, setSearching] = useState(false);
const [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set());
const [selectedMetric, setSelectedMetric] = useState<TreeMetric | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// 加载分类树(只加载第一层)
useEffect(() => {
loadCategoryTree();
}, [selectedSource]);
const loadCategoryTree = async () => {
setLoading(true);
try {
const data = await fetchCategoryTree(selectedSource, 1); // 只加载第一层
setTreeData(data);
setCurrentNode(null);
setBreadcrumbs([]);
setExpandedNodes(new Set());
setSearchResults(null); // 清空搜索结果
} catch (error) {
toast({
title: '加载失败',
description: '无法加载分类树数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
}
};
// 执行搜索
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
}
setSearching(true);
try {
const results = await searchMetrics(searchQuery, selectedSource, undefined, 100);
setSearchResults(results);
} catch (error) {
toast({
title: '搜索失败',
description: '无法搜索指标数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setSearching(false);
}
};
// 当搜索关键词变化时,自动搜索
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.trim()) {
handleSearch();
} else {
setSearchResults(null);
}
}, 500); // 防抖 500ms
return () => clearTimeout(timer);
}, [searchQuery, selectedSource]);
// 切换节点展开状态(懒加载子节点)
const toggleNodeExpand = async (node: TreeNode) => {
const isCurrentlyExpanded = expandedNodes.has(node.path);
if (isCurrentlyExpanded) {
// 收起节点
setExpandedNodes((prev) => {
const newSet = new Set(prev);
newSet.delete(node.path);
return newSet;
});
} else {
// 展开节点 - 检查是否需要加载子节点
const needsLoading = !node.children || node.children.length === 0;
if (needsLoading) {
// 添加加载状态
setLoadingNodes((prev) => new Set(prev).add(node.path));
try {
// 从服务器加载子节点
const nodeData = await fetchCategoryNode(node.path, selectedSource);
// 更新树数据
setTreeData((prevData) => {
if (!prevData) return prevData;
const updateNode = (nodes: TreeNode[]): TreeNode[] => {
return nodes.map((n) => {
if (n.path === node.path) {
return { ...n, children: nodeData.children, metrics: nodeData.metrics };
}
if (n.children) {
return { ...n, children: updateNode(n.children) };
}
return n;
});
};
return {
...prevData,
tree: updateNode(prevData.tree),
};
});
// 更新当前节点(如果是当前选中的节点)
if (currentNode && currentNode.path === node.path) {
setCurrentNode(nodeData);
}
} catch (error) {
toast({
title: '加载失败',
description: '无法加载子节点数据',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoadingNodes((prev) => {
const newSet = new Set(prev);
newSet.delete(node.path);
return newSet;
});
}
}
// 展开节点
setExpandedNodes((prev) => new Set(prev).add(node.path));
}
};
// 处理节点点击
const handleNodeClick = (node: TreeNode) => {
setCurrentNode(node);
const pathParts = node.path.split('|');
setBreadcrumbs(pathParts);
};
// 处理面包屑导航
const handleBreadcrumbClick = (index: number) => {
if (index === -1) {
setCurrentNode(null);
setBreadcrumbs([]);
return;
}
const targetPath = breadcrumbs.slice(0, index + 1).join('|');
// 在树中查找对应节点
const findNode = (nodes: TreeNode[], path: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === path) return node;
if (node.children) {
const found = findNode(node.children, path);
if (found) return found;
}
}
return null;
};
if (treeData) {
const node = findNode(treeData.tree, targetPath);
if (node) {
handleNodeClick(node);
}
}
};
// 处理指标点击
const handleMetricClick = (metric: TreeMetric) => {
setSelectedMetric(metric);
onOpen();
};
// 显示的树节点(搜索时不显示树)
const displayTree = useMemo(() => {
if (searchQuery.trim()) {
return []; // 搜索时不显示树
}
return treeData?.tree || [];
}, [treeData, searchQuery]);
return (
<Box
minH="100vh"
bg={themeColors.bg.primary}
bgGradient={themeColors.bgGradient}
position="relative"
pt={{ base: '120px', md: '75px' }}
>
{/* 金色光晕背景 */}
<Box
position="absolute"
top="0"
left="50%"
transform="translateX(-50%)"
width="100%"
height="400px"
bgGradient={themeColors.bgRadialGold}
opacity={0.3}
pointerEvents="none"
/>
<Container maxW="container.xl" position="relative" zIndex={1}>
{/* 标题区域 */}
<MotionBox
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<VStack spacing={4} align="stretch" mb={8}>
<HStack spacing={4}>
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={8} />
<VStack align="start" spacing={0}>
<Text
fontSize="3xl"
fontWeight="bold"
color={themeColors.text.primary}
textShadow={`0 0 20px ${themeColors.primary.gold}40`}
>
</Text>
<Text color={themeColors.text.secondary} fontSize="sm">
-
</Text>
</VStack>
</HStack>
{/* 数据源切换 */}
<HStack spacing={4}>
<Button
size="sm"
bg={selectedSource === 'SMM' ? themeColors.primary.gold : 'transparent'}
color={selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.text.secondary}
borderWidth="1px"
borderColor={selectedSource === 'SMM' ? themeColors.primary.gold : themeColors.border.default}
_hover={{
borderColor: themeColors.primary.gold,
color: selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.primary.gold,
}}
onClick={() => setSelectedSource('SMM')}
>
SMM {treeData && selectedSource === 'SMM' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
</Button>
<Button
size="sm"
bg={selectedSource === 'Mysteel' ? themeColors.primary.gold : 'transparent'}
color={selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.text.secondary}
borderWidth="1px"
borderColor={selectedSource === 'Mysteel' ? themeColors.primary.gold : themeColors.border.default}
_hover={{
borderColor: themeColors.primary.gold,
color: selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.primary.gold,
}}
onClick={() => setSelectedSource('Mysteel')}
>
Mysteel {treeData && selectedSource === 'Mysteel' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
</Button>
</HStack>
</VStack>
</MotionBox>
{/* 搜索和过滤 */}
<MotionBox
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
mb={6}
>
<CardBody>
<VStack spacing={3} align="stretch">
<HStack spacing={4}>
<Input
placeholder="搜索分类或指标名称..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg={themeColors.bg.secondary}
borderColor={themeColors.border.default}
color={themeColors.text.primary}
_placeholder={{ color: themeColors.text.muted }}
_focus={{
borderColor: themeColors.primary.gold,
boxShadow: `0 0 0 1px ${themeColors.primary.gold}`,
}}
/>
<Button
leftIcon={<FaSearch />}
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
_hover={{ bg: themeColors.primary.goldLight }}
onClick={handleSearch}
isLoading={searching}
>
</Button>
{searchQuery && (
<Button
leftIcon={<FaTimes />}
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.text.primary }}
onClick={() => setSearchQuery('')}
>
</Button>
)}
</HStack>
{/* 搜索结果提示 */}
{searchResults && (
<Flex align="center" justify="space-between" py={2}>
<Text color={themeColors.text.secondary} fontSize="sm">
<Text as="span" color={themeColors.primary.gold} fontWeight="bold">{searchResults.total}</Text>
</Text>
<Text color={themeColors.text.muted} fontSize="xs">
: "{searchResults.query}"
</Text>
</Flex>
)}
{searching && (
<Flex align="center" justify="center" py={2}>
<Spinner size="sm" color={themeColors.primary.gold} mr={2} />
<Text color={themeColors.text.secondary} fontSize="sm">
...
</Text>
</Flex>
)}
</VStack>
</CardBody>
</Card>
</MotionBox>
{/* 面包屑导航 */}
{breadcrumbs.length > 0 && (
<MotionBox
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
mb={4}
>
<Card bg={themeColors.bg.card} borderWidth="1px" borderColor={themeColors.border.default}>
<CardBody py={2}>
<Breadcrumb
spacing={2}
separator={<Icon as={FaChevronRight} color={themeColors.text.muted} />}
>
<BreadcrumbItem>
<BreadcrumbLink
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={() => handleBreadcrumbClick(-1)}
>
<Icon as={FaHome} />
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb, index) => (
<BreadcrumbItem key={index} isCurrentPage={index === breadcrumbs.length - 1}>
<BreadcrumbLink
color={index === breadcrumbs.length - 1 ? themeColors.primary.gold : themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={() => handleBreadcrumbClick(index)}
>
{crumb}
</BreadcrumbLink>
</BreadcrumbItem>
))}
</Breadcrumb>
</CardBody>
</Card>
</MotionBox>
)}
{/* 主内容区域 */}
<Flex gap={6} direction={{ base: 'column', lg: 'row' }}>
{/* 左侧:分类树 */}
<MotionBox
flex={{ base: '1', lg: '0 0 400px' }}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
maxH="calc(100vh - 400px)"
overflowY="auto"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: themeColors.bg.secondary,
},
'&::-webkit-scrollbar-thumb': {
background: themeColors.primary.gold,
borderRadius: '4px',
},
}}
>
<CardBody>
{loading ? (
<Flex justify="center" align="center" py={10}>
<Spinner color={themeColors.primary.gold} size="xl" />
</Flex>
) : searchQuery.trim() ? (
// 搜索模式:显示搜索结果列表
<VStack align="stretch" spacing={1}>
{searchResults && searchResults.results.length > 0 ? (
searchResults.results.map((result) => (
<Box
key={result.metric_id}
p={3}
cursor="pointer"
bg="transparent"
_hover={{ bg: themeColors.bg.cardHover }}
borderRadius="md"
borderLeftWidth="3px"
borderLeftColor="transparent"
_hover={{ borderLeftColor: themeColors.primary.gold }}
transition="all 0.2s"
onClick={() => {
// 转换搜索结果为 TreeMetric 格式
const metric: TreeMetric = {
metric_id: result.metric_id,
metric_name: result.metric_name,
source: result.source,
frequency: result.frequency,
unit: result.unit,
description: result.description,
};
handleMetricClick(metric);
}}
>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text color={themeColors.text.primary} fontSize="sm" fontWeight="bold" flex="1">
{result.metric_name}
</Text>
<Badge
bg={result.source === 'SMM' ? 'blue.500' : 'green.500'}
color="white"
fontSize="xs"
>
{result.source}
</Badge>
</HStack>
<HStack spacing={4} fontSize="xs" color={themeColors.text.muted}>
<Text>: {result.category_path}</Text>
<Text>: {result.frequency}</Text>
<Text>: {result.unit || '-'}</Text>
</HStack>
{result.score && (
<Text fontSize="xs" color={themeColors.text.muted}>
: {(result.score * 100).toFixed(0)}%
</Text>
)}
</VStack>
</Box>
))
) : searchResults ? (
<Flex justify="center" align="center" py={10}>
<VStack spacing={3}>
<Icon as={FaSearch} color={themeColors.text.muted} boxSize={12} />
<Text color={themeColors.text.muted}></Text>
<Text color={themeColors.text.muted} fontSize="sm">
使
</Text>
</VStack>
</Flex>
) : null}
</VStack>
) : (
// 正常模式:显示分类树
<VStack align="stretch" spacing={1}>
{displayTree.map((node) => (
<TreeNodeComponent
key={node.path}
node={node}
source={selectedSource}
onNodeClick={handleNodeClick}
expandedNodes={expandedNodes}
onToggleExpand={toggleNodeExpand}
searchQuery=""
loadingNodes={loadingNodes}
/>
))}
</VStack>
)}
</CardBody>
</Card>
</MotionBox>
{/* 右侧:指标详情 */}
<MotionBox
flex="1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
>
<Card
bg={themeColors.bg.card}
borderWidth="1px"
borderColor={themeColors.border.gold}
minH="400px"
>
<CardBody>
{currentNode ? (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text color={themeColors.text.primary} fontSize="2xl" fontWeight="bold">
{currentNode.name}
</Text>
<Text color={themeColors.text.muted} fontSize="sm">
{currentNode.level} | : {currentNode.path}
</Text>
</VStack>
{currentNode.metrics && currentNode.metrics.length > 0 && (
<Badge
bg={themeColors.primary.gold}
color={themeColors.bg.primary}
fontSize="md"
px={3}
py={1}
>
{currentNode.metrics.length}
</Badge>
)}
</HStack>
<Divider borderColor={themeColors.border.gold} />
{currentNode.metrics && currentNode.metrics.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
{currentNode.metrics.map((metric) => (
<MetricCard
key={metric.metric_id}
metric={metric}
onClick={() => handleMetricClick(metric)}
/>
))}
</SimpleGrid>
) : (
<Flex justify="center" align="center" py={10}>
<VStack spacing={3}>
<Icon as={FaFolder} color={themeColors.text.muted} boxSize={12} />
<Text color={themeColors.text.muted}>
{currentNode.children && currentNode.children.length > 0
? '该节点包含子分类,请展开查看'
: '该节点暂无指标数据'}
</Text>
</VStack>
</Flex>
)}
</VStack>
) : (
<Flex justify="center" align="center" py={20}>
<VStack spacing={4}>
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={16} />
<Text color={themeColors.text.secondary} fontSize="lg" textAlign="center">
</Text>
{treeData && (
<Text color={themeColors.text.muted} fontSize="sm">
{treeData.total_metrics.toLocaleString()}
</Text>
)}
</VStack>
</Flex>
)}
</CardBody>
</Card>
</MotionBox>
</Flex>
</Container>
{/* 指标数据详情模态框 */}
{selectedMetric && (
<MetricDataModal isOpen={isOpen} onClose={onClose} metric={selectedMetric} />
)}
</Box>
);
};
export default DataBrowser;

View File

@@ -1,40 +1,9 @@
import {
Flex,
Container,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Text
} from '@chakra-ui/react';
import { ChevronRightIcon } from '@chakra-ui/icons';
import React from 'react';
import SubscriptionContent from 'components/Subscription/SubscriptionContent';
import SubscriptionContentNew from 'components/Subscription/SubscriptionContentNew';
function Subscription() {
return (
<Flex direction='column'>
<Container maxW="container.xl" px={{ base: 4, md: 6 }} py={{ base: 4, md: 6 }}>
{/* 面包屑导航 */}
<Breadcrumb
spacing='8px'
separator={<ChevronRightIcon color='gray.500' />}
mb={6}
fontSize='sm'
>
<BreadcrumbItem>
<BreadcrumbLink href='/home'>首页</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink href='/home/pages/account'>个人中心</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<Text color='gray.500'>订阅管理</Text>
</BreadcrumbItem>
</Breadcrumb>
<SubscriptionContent />
</Container>
</Flex>
<SubscriptionContentNew />
);
}

View File

@@ -0,0 +1,225 @@
export const subscriptionConfig = {
plans: [
{
name: 'free',
displayName: '基础版',
description: '免费体验核心功能,7项实用工具',
icon: 'star',
price: 0,
badge: '免费',
badgeColor: 'gray',
cardBorder: 'gray',
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true, limit: 'TOP3' },
{ name: '事件传导链分析(AI)', enabled: true, limit: '有限体验' },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true, limit: '限制预览' },
{ name: '个股深度分析(AI)', enabled: true, limit: '10家/月' },
{ name: '概念中心(548大概念)', enabled: true, limit: 'TOP5' },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
{ name: '事件-相关标的分析', enabled: false },
{ name: '相关概念展示', enabled: false },
{ name: '高效数据筛选工具', enabled: false },
],
},
{
name: 'pro',
displayName: 'Pro 专业版',
description: '为专业投资者打造,解锁高级分析功能',
icon: 'gem',
badge: '推荐',
badgeColor: 'gold',
cardBorder: 'gold',
highlight: false,
pricingOptions: [
{
cycleKey: 'monthly',
label: '月付',
months: 1,
price: 299,
originalPrice: null,
discountPercent: 0,
},
{
cycleKey: 'quarterly',
label: '季付',
months: 3,
price: 799,
originalPrice: 897,
discountPercent: 11,
},
{
cycleKey: 'semiannual',
label: '半年付',
months: 6,
price: 1499,
originalPrice: 1794,
discountPercent: 16,
},
{
cycleKey: 'yearly',
label: '年付',
months: 12,
price: 2699,
originalPrice: 3588,
discountPercent: 25,
},
],
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true },
{ name: '事件传导链分析(AI)', enabled: true },
{ name: '事件-相关标的分析', enabled: true },
{ name: '相关概念展示', enabled: true },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true },
{ name: '个股深度分析(AI)', enabled: true, limit: '50家/月' },
{ name: '高效数据筛选工具', enabled: true },
{ name: '概念中心(548大概念)', enabled: true },
{ name: '历史时间轴查询', enabled: true, limit: '100天' },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
{ name: '板块深度分析(AI)', enabled: false },
{ name: '概念高频更新', enabled: false },
],
},
{
name: 'max',
displayName: 'Max 旗舰版',
description: '旗舰级体验,无限制使用所有功能',
icon: 'crown',
badge: '最受欢迎',
badgeColor: 'gold',
cardBorder: 'gold',
highlight: true,
pricingOptions: [
{
cycleKey: 'monthly',
label: '月付',
months: 1,
price: 599,
originalPrice: null,
discountPercent: 0,
},
{
cycleKey: 'quarterly',
label: '季付',
months: 3,
price: 1599,
originalPrice: 1797,
discountPercent: 11,
},
{
cycleKey: 'semiannual',
label: '半年付',
months: 6,
price: 2999,
originalPrice: 3594,
discountPercent: 17,
},
{
cycleKey: 'yearly',
label: '年付',
months: 12,
price: 5399,
originalPrice: 7188,
discountPercent: 25,
},
],
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true },
{ name: '事件传导链分析(AI)', enabled: true },
{ name: '事件-相关标的分析', enabled: true },
{ name: '相关概念展示', enabled: true },
{ name: '板块深度分析(AI)', enabled: true },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true },
{ name: '个股深度分析(AI)', enabled: true, limit: '无限制' },
{ name: '高效数据筛选工具', enabled: true },
{ name: '概念中心(548大概念)', enabled: true },
{ name: '历史时间轴查询', enabled: true, limit: '无限制' },
{ name: '概念高频更新', enabled: true },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
],
},
],
faqs: [
{
question: '如何取消订阅?',
answer: '您可以随时在账户设置中取消订阅。取消后,您的订阅将在当前计费周期结束时到期,期间您仍可继续使用付费功能。取消后不会立即扣款,也不会自动续费。',
},
{
question: '支持哪些支付方式?',
answer: '我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。',
},
{
question: '同级续费如何计费?',
answer: '如果您是Pro用户续费Pro版本或Max用户续费Max版本支付后将在当前订阅到期日基础上延长相应时长。例如您的Max年付版本还有30天到期续费Max年付后新的到期时间将延长至395天后30天+365天。',
},
{
question: 'Pro用户如何升级到Max',
answer: '从Pro升级到Max需要补差价升级后立即生效。系统会根据您Pro订阅的剩余价值计算需要补缴的费用。支付成功后您将立即获得Max版本的所有功能。\n\n特别说明如果您的Pro订阅剩余价值超过或等于Max套餐的价格系统将自动为您免费升级到Max版本无需支付额外费用。升级后的有效期将根据剩余价值按比例计算。例如您的Pro年付版本剩余价值为1200元选择Max月付版本998元/月系统将为您提供约36天的Max版本使用时长1200÷998×30天。',
},
{
question: 'Max用户可以切换到Pro吗',
answer: '可以。Max用户购买Pro套餐后系统会在当前Max订阅到期后自动切换到Pro版本并从到期日开始计算Pro的订阅时长。在Max到期前您仍可继续使用Max的全部功能。',
},
{
question: '是否支持退款?',
answer: '为了保障服务质量和维护公平的商业环境,我们不支持退款。\n\n建议您在订阅前\n• 充分了解各套餐的功能差异\n• 使用免费版体验基础功能\n• 根据实际需求选择合适的计费周期\n• 如有疑问可联系客服咨询\n\n提示选择长期套餐如半年付、年付可享受更大折扣,性价比更高。',
},
{
question: 'Pro版和Max版有什么区别',
answer: 'Pro版适合个人专业用户,提供高级图表、历史数据分析等功能,有一定的使用限制。Max版则是为重度用户设计,提供无限制的数据查询、板块深度分析、概念高频更新等独家功能,并享有优先技术支持。',
},
],
};
// 主题颜色配置 - 黑金配色
export const themeColors = {
// 背景渐变
bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
// 主色调
primary: {
gold: '#D4AF37', // 金色
goldLight: '#F4E3A7', // 浅金色
goldDark: '#B8941F', // 深金色
},
// 背景色
bg: {
primary: '#0a0a0a', // 主背景(纯黑)
secondary: '#1a1a1a', // 次级背景(深黑)
card: '#1e1e1e', // 卡片背景
cardHover: '#252525', // 卡片悬停
},
// 文字颜色
text: {
primary: '#ffffff', // 主文字(纯白)
secondary: '#b8b8b8', // 次级文字(灰白)
muted: '#808080', // 弱化文字(灰)
gold: '#D4AF37', // 金色文字
},
// 边框颜色
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
goldGlow: 'rgba(212, 175, 55, 0.5)',
},
// 状态颜色
status: {
active: '#00ff88', // 激活(绿色)
inactive: '#ff4444', // 未激活(红色)
warning: '#ff9900', // 警告(橙色)
},
};

View File

@@ -0,0 +1,35 @@
export const pricing = [
{
title: "STARTER",
price: 99,
features: [
"1 Active Bot",
"1,000 Conversations per month",
"Web & WhatsApp Integration",
"Basic Dashboard & Chat Reports",
"Email Support",
],
},
{
title: "PRO",
price: 149,
features: [
"Up to 5 Active Bots",
"10,000 Conversations per month",
"Multi-Channel (Web, WhatsApp, IG, Telegram)",
"Custom Workflows & Automation",
"Real-Time Reports & Zapier Integration",
],
},
{
title: "ENTERPRISE",
price: 199,
features: [
"Unlimited Bots & Chats",
"Role-Based Access & Team Management",
"Integration to CRM & Custom APIs",
"Advanced AI Training (LLM/NLP)",
"Dedicated Onboarding Team",
],
},
];

129
src/views/Pricing/index.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { motion } from "framer-motion";
import Button from "@/components/Button2";
import { pricing } from "./content";
const Pricing = () => (
<div
id="pricing"
className="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15"
>
<div className="center">
<motion.div
className="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.7 }}
viewport={{ amount: 0.7 }}
>
<div className="label mb-3 max-md:mb-1.5">Pricing</div>
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">
Start Automation Today
</div>
</motion.div>
<motion.div
className="flex gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.7 }}
viewport={{ amount: 0.35 }}
>
{pricing.map((item, index) => (
<div
className={`relative flex flex-col flex-1 rounded-[1.25rem] overflow-hidden after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84 ${
item.title === "PRO"
? "shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-green/10 before:rounded-full before:blur-[3.375rem]"
: "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset]"
}`}
key={index}
>
{item.title === "PRO" && (
<div className="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
<video
className="w-full"
src="/videos/video-1.mp4"
autoPlay
loop
muted
playsInline
/>
</div>
)}
<div
className={`relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 ${
item.title === "PRO"
? "bg-[#175673]/20 rounded-t-[1.25rem] text-green"
: "text-white"
}`}
>
{item.title}
</div>
<div
className={`relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none ${
item.title === "PRO"
? "backdrop-blur-[2rem] shadow-2 bg-white/7"
: "backdrop-blur-[1.25rem] bg-white/1"
}`}
>
<div
className={`relative mb-8 p-5 rounded-[0.8125rem] backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none ${
item.title === "PRO"
? "bg-line"
: "bg-white/2"
}`}
>
<div className="flex items-end gap-3 mb-4">
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">
${item.price}
</div>
<div className="text-title-5">/Month</div>
</div>
<Button
className={`w-full bg-line ${
item.title !== "PRO"
? "!text-description hover:!text-white"
: ""
}`}
isPrimary={item.title === "PRO"}
isSecondary={item.title !== "PRO"}
>
{item.title === "STARTER"
? "Start with Beginner"
: item.title === "PRO"
? "Choose Pro Plan"
: "Contact for Enterprise"}
</Button>
</div>
<div className="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
{item.features.map((feature, index) => (
<div
className="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile"
key={index}
>
<div className="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
<svg
className="size-5 fill-black"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
</svg>
</div>
{feature}
</div>
))}
</div>
</div>
</div>
))}
</motion.div>
<div className="mt-13.5 text-center max-md:mt-8 max-md:text-title-3-mobile">
Free 7 Day Trial
</div>
</div>
</div>
);
export default Pricing;

View File

@@ -35,6 +35,28 @@ server {
ssl_certificate /etc/letsencrypt/live/valuefrontier.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/valuefrontier.cn/privkey.pem;
# ============================================
# SEO 文件配置robots.txt + sitemap.xml
# 优先级最高,放在最前面
# ============================================
# robots.txt - 精确匹配(优先级最高)
location = /robots.txt {
root /var/www/valuefrontier;
add_header Content-Type "text/plain; charset=utf-8";
add_header Cache-Control "public, max-age=3600"; # 缓存 1 小时
access_log off; # 减少日志记录
}
# sitemap.xml - 精确匹配
location = /sitemap.xml {
root /var/www/valuefrontier;
add_header Content-Type "application/xml; charset=utf-8";
add_header Cache-Control "public, max-age=3600"; # 缓存 1 小时
access_log off;
}
# --- 为React应用提供静态资源 ---
location /static/ {
alias /var/www/valuefrontier.cn/static/;
@@ -404,6 +426,37 @@ server {
proxy_read_timeout 120s;
}
# 商品分类树数据API代理数据浏览器
location /category-api/ {
proxy_pass http://222.128.1.157:18827/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 配置
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置(数据量大,需要较长超时时间)
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# 缓冲配置(支持大响应体)
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 8 256k;
proxy_busy_buffers_size 512k;
}
# --- 新的静态官网静态资源(优先级最高) ---
# 使用 ^~ 前缀确保优先匹配,不被后面的规则覆盖
location ^~ /css/ {