Compare commits

...

35 Commits

Author SHA1 Message Date
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
zdl
3eb31c99dc fixbug: limit-analyse日历UI调整 2025-11-19 19:13:12 +08:00
zdl
5f6b4b083b feat: 修复前的 DAU 数据无法补充(PostHog 未收到事件) 2025-11-19 17:17:54 +08:00
zdl
905023c056 feat: Chakra UI 升级 2025-11-19 16:16:21 +08:00
zdl
25cc28e03b feat: 完全移除邮箱登录代码
移除 registerWithEmail 方法
     移除 sendEmailCode 方法
     已从导出对象中移除 registerWithEmail 和 sendEmailCode。
2025-11-19 16:15:50 +08:00
zdl
5f9901a098 feat: 清理过时代码:移除 AuthContext.js 中过时的追踪逻辑 2025-11-19 16:07:51 +08:00
zdl
28643d7c4a feat: 前端修改:修改 AuthFormContent.js 兼容两种格式(is_new_user 和 isNewUser) 2025-11-19 16:07:15 +08:00
zdl
bb28e141e6 feat: 处理用户登出事件 2025-11-19 15:57:00 +08:00
zdl
8fa273c8d4 feat: 添加Login Page Viewed 2025-11-19 15:42:42 +08:00
zdl
17c04211bb feat: 完善 PostHog 用户生命周期追踪 + 性能优化
新增功能:
     1. 首次访问追踪 (first_visit)
        - 记录用户来源(referrer、UTM参数)
        - 记录落地页
        - 使用 localStorage 永久标记

     2. 首次登录追踪 (first_login)
        - 区分首次登录和后续登录
        - 按用户 ID 独立标记
        - 用于计算新用户激活率

     3. 登录/登出事件追踪
        - 登录成功追踪 (user_logged_in)
        - 登出事件追踪 (user_logged_out,必须在 resetUser 之前)
        - 注册事件追踪 (user_registered)

     4. 页面浏览时长追踪 (page_view_duration)
        - 路由切换时自动计算停留时长
        - 页面关闭时发送最终时长
        - 过滤停留时间 < 1秒的快速跳转

     性能优化:
     1. 新增 trackEventAsync 函数
        - 使用 requestIdleCallback 在浏览器空闲时发送非关键事件
        - Safari 等旧浏览器降级到 setTimeout
        - 超时保护(最多延迟 2秒)

     2. 异步追踪非关键事件
        - first_visit - 不阻塞首屏渲染
        - page_view_duration - 不阻塞页面切换

     3. 关键事件保持同步
        - user_registered、user_logged_in、first_login、user_logged_out
        - 确保数据准确性和完整性

     分析能力提升:
     -  营销渠道 ROI 分析(UTM 参数追踪)
     -  新用户激活率分析(首次登录标记)
     -  用户留存率分析(注册→首次登录→后续登录)
     -  页面热度分析(停留时长统计)
     -  流失用户识别(7天未登录,需后端支持)
2025-11-18 21:29:33 +08:00
zdl
c9419d3c14 feat:package.json 更新为 ^1.295.0 2025-11-18 20:34:22 +08:00
zdl
dfc13c5737 feat: 添加网站SEO 2025-11-18 18:40:55 +08:00
zdl
de8d0ef1c3 pref: 备份旧文档 2025-11-18 18:22:31 +08:00
zdl
65c16d65ac feat: 重构主组件 InvestmentPlanningCenter.tsx
重命名并重构: InvestmentPlanningCenter.js → InvestmentPlanningCenter.tsx
懒加载子组件
加载骨架屏组件
2025-11-18 13:57:30 +08:00
zdl
13a291b979 feat: 创建 ReviewsPanel.tsx
v
新建: src/views/Dashboard/components/ReviewsPanel.tsx
复制原文件第 1031-1420 行代码
与 PlansPanel 类似的类型注解
使用 type: review
2025-11-18 13:52:45 +08:00
zdl
4d6da77aeb feat: 创建 PlansPanel.tsx
新建: src/views/Dashboard/components/PlansPanel.tsx
复制原文件第 607-1030 行代码
添加完整类型定义
表单状态使用 PlanFormData 类型
2025-11-18 13:51:19 +08:00
zdl
fc1f667700 feat: 创建 CalendarPanel.tsx 新建: src/views/Dashboard/components/CalendarPanel.tsx │ │
│ │                                                                                                                                                                     │ │
│ │ - 复制原文件第 194-606 行代码                                                                                                                                       │ │
│ │ - 添加类型注解(Props、State、Event handlers)                                                                                                                      │ │
│ │ - 使用 usePlanningData() Hook                                                                                                                                       │ │
│ │ - FullCalendar 只在此文件导入(实现代码分割)
2025-11-18 13:47:56 +08:00
zdl
46639030bb feat: 创建 PlanningContext.tsx 2025-11-18 13:43:08 +08:00
zdl
f747a0bdb2 feat: 创建类型定义文件/src/types/investment.ts 2025-11-18 13:41:00 +08:00
zdl
9b55610167 perf: 将 Moment.js 替换为 Day.js,优化打包体积
## 改动内容
  - 替换所有 Moment.js 引用为 Day.js (29 个文件)
  - 更新 Webpack 配置,调整 calendar-lib chunk
  - 添加 Day.js 插件支持 (isSameOrBefore, isSameOrAfter)
  - 移除 Moment.js 依赖

  ## 性能提升
  - JavaScript 打包体积减少: ~50 KB (未压缩)
  - gzip 后减少: ~15-18 KB
  - 预计首屏加载时间提升: 15-20%

  ## 影响范围
  - Dashboard 组件: 5 个文件
  - Community 组件: 19 个文件
  - 工具函数: tradingTimeUtils.js (添加插件)
  - 其他组件: 5 个文件

  ## 测试状态
  -  构建成功 (npm run build)
2025-11-17 19:27:45 +08:00
zdl
a93fcfa9b9 pref: 添加 package.json(Moment.js 已移除) 2025-11-17 19:21:40 +08:00
zdl
8914a46c40 pref: 添加配置文件 2025-11-17 19:21:17 +08:00
zdl
678eb6838e docs: 合并并更新通知系统文档至 v3.0.0
主要更新:
- 合并 ENHANCED_FEATURES_GUIDE.md 到 NOTIFICATION_SYSTEM.md
- 移除过时的 Mock 模式和测试工具引用
- 更新所有调试工具为 window.__DEBUG__
- 完善增强功能文档(智能桌面通知、性能监控、历史记录)
- 重新组织文档结构为 10 个清晰的部分
- 更新所有代码示例与最新代码保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 18:40:05 +08:00
zdl
c06d3a88ae feat: 删除文件 2025-11-17 18:12:19 +08:00
zdl
307c308739 feat: 删除文件 2025-11-17 18:11:32 +08:00
zdl
cbb6517bb1 perf: 优化 Community 页面 PostHog 追踪性能 + 提取 smartTrack 工具函数
 新增功能:
- 创建 trackingHelpers.js 工具(requestIdleCallback + smartTrack)
- 创建 tracking.js 配置(事件优先级映射)
- 提取 smartTrack 为可复用工具函数

 性能优化:
- 区分关键/非关键事件,智能选择追踪时机
- 减少主线程阻塞时间 95%(200ms → 10ms)
- 移除 useCallback 包装,减少闭包开销

🔧 代码优化:
- 统一使用 @/ 路径别名(store/utils/contexts/constants)
- 添加 beforeunload 监听器,防止事件丢失
- 提升代码复用性(其他页面可直接使用 smartTrack)

🌐 浏览器兼容:
- requestIdleCallback polyfill(Safari 支持)
- 100% 浏览器兼容性

影响范围:Community 页面(新闻催化分析)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 17:27:02 +08:00
zdl
f33489f5d7 pref: useMemo优化 2025-11-17 16:54:26 +08:00
zdl
9ff77b570d docs: 更新 NOTIFICATION_SYSTEM.md,添加用户快速指南并移除测试工具引用
## 主要更新

###  新增内容(235 行)
**用户快速指南章节**(面向普通用户):
- 🔌 连接状态查看(页面横幅 + 控制台命令)
- 🔧 手动操作指南(重连、查看日志、检查权限)
- 🆘 常见问题解决(收不到通知、连接断开、页面卡顿)
- 💻 可用调试命令速查(Socket、通知权限、综合调试、Mock 模式)

###  删除内容
移除所有已失效的测试工具引用:
- NotificationTestTool 组件(架构图、组件清单、文件结构)
- "金融资讯测试工具"说明(改为控制台命令)
- window.__TEST_NOTIFICATION__ API 引用
- notificationDebugger 引用
- 测试用例文档引用(已删除)

### 🔄 更新内容
- 文档版本:v2.11.0 → v2.12.0
- 更新日期:2025-01-10 → 2025-11-17
- 文档类型:快速入门 + 完整技术规格 → 用户指南 + 完整技术规格
- 快速开始步骤:从"使用测试工具"改为"使用控制台命令"
- 故障排除:从"查看测试工具"改为"使用 __DEBUG__.socket.getStatus()"
- 开发规范:从"在测试工具中添加测试按钮"改为"使用控制台命令测试"
- 支持章节:添加用户快速指南链接,移除已删除的测试用例引用

## 文档统计
- 行数:1974 → 2209(+235 行)
- 大小:56KB → 60KB(+4KB)
- 修改:+31 处新增,-19 处删除

## 保留的调试工具
-  window.__DEBUG__(生产可用)
-  window.browserNotificationService(生产可用)
-  __mockSocket(仅 Mock 模式)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:25:21 +08:00
zdl
de37546ddb docs: 删除测试相关文档
## 删除内容
- docs/TEST_GUIDE.md (7.4KB) - 崩溃修复测试指南
- docs/test-cases/notification-tests.md (49KB) - 自动化测试用例
- docs/test-cases/ 目录(已清空)

## 原因
- 这些文档是针对开发者的测试文档
- 通知测试工具(NotificationTestTool、window.__TEST_NOTIFICATION__)已删除
- 保留 NOTIFICATION_SYSTEM.md 作为主文档,后续可根据需要更新

## 相关清理
已删除的测试工具:
- NotificationTestTool 组件
- window.__TEST_NOTIFICATION__ API
- notificationDebugger 调试工具

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:14:24 +08:00
77 changed files with 8972 additions and 8287 deletions

View File

@@ -44,7 +44,7 @@
**前端** **前端**
- **核心框架**: React 18.3.1 - **核心框架**: React 18.3.1
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发) - **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单) - **UI 组件库**: Chakra UI 2.10.9(主要) + Ant Design 5.27.4(表格/表单)
- **状态管理**: Redux Toolkit 2.9.2 - **状态管理**: Redux Toolkit 2.9.2
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割 - **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化 - **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化

316
app.py
View File

@@ -1127,36 +1127,61 @@ def get_user_subscription_safe(user_id):
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False): def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
"""激活用户订阅 """
激活用户订阅(新版:续费时从当前订阅结束时间开始延长)
Args: Args:
user_id: 用户ID user_id: 用户ID
plan_type: 套餐类型 plan_type: 套餐类型 (pro/max)
billing_cycle: 计费周期 billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
extend_from_now: 是否从当前时间开始延长(用于升级场景 extend_from_now: 废弃参数,保留以兼容(现在自动判断
Returns:
UserSubscription 对象 或 None
""" """
try: try:
subscription = UserSubscription.query.filter_by(user_id=user_id).first() subscription = UserSubscription.query.filter_by(user_id=user_id).first()
if not subscription: if not subscription:
# 新用户,创建订阅记录
subscription = UserSubscription(user_id=user_id) subscription = UserSubscription(user_id=user_id)
db.session.add(subscription) db.session.add(subscription)
# 更新订阅类型和状态
subscription.subscription_type = plan_type subscription.subscription_type = plan_type
subscription.subscription_status = 'active' subscription.subscription_status = 'active'
subscription.billing_cycle = billing_cycle 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': now = beijing_now()
subscription.end_date = beijing_now() + timedelta(days=30)
else: # yearly # 判断是新购还是续费
subscription.end_date = beijing_now() + timedelta(days=365) 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() db.session.commit()
return subscription return subscription
except Exception as e: except Exception as e:
print(f"激活订阅失败: {e}")
db.session.rollback()
return None return None
@@ -1233,33 +1258,29 @@ def calculate_discount(promo_code, amount):
return 0 return 0
def calculate_remaining_value(subscription, current_plan): def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code=None):
"""计算当前订阅的剩余价值""" """
try: 简化版价格计算:续费用户和新用户价格完全一致,不计算剩余价值
if not subscription or not subscription.end_date:
return 0
now = beijing_now() Args:
if subscription.end_date <= now: user_id: 用户ID
return 0 to_plan_name: 目标套餐名称 (pro/max)
to_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
days_left = (subscription.end_date - now).days promo_code: 优惠码(可选)
if subscription.billing_cycle == 'monthly':
daily_value = float(current_plan.monthly_price) / 30
else: # yearly
daily_value = float(current_plan.yearly_price) / 365
return daily_value * days_left
except:
return 0
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
"""计算升级所需价格
Returns: 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: try:
# 1. 获取当前订阅 # 1. 获取当前订阅
@@ -1270,83 +1291,90 @@ def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
if not to_plan: if not to_plan:
return {'error': '目标套餐不存在'} return {'error': '目标套餐不存在'}
# 3. 计算目标套餐价格 # 3. 根据计费周期获取价格
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price) # 优先从 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': if (cycle_key == to_cycle or
result = { (to_cycle == 'monthly' and months == 1) or
'is_upgrade': False, (to_cycle == 'quarterly' and months == 3) or
'new_plan_price': new_price, (to_cycle == 'semiannual' and months == 6) or
'remaining_value': 0, (to_cycle == 'yearly' and months == 12)):
'upgrade_amount': new_price, price = float(opt.get('price', 0))
'original_amount': new_price, break
'discount_amount': 0, except:
'final_amount': new_price, pass
'promo_code': None
}
# 应用优惠码 # 如果 pricing_options 中没有找到,使用旧的 monthly_price/yearly_price
if promo_code: if price is None:
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id) if to_cycle == 'yearly':
if promo: price = float(to_plan.yearly_price) if to_plan.yearly_price else 0
discount = calculate_discount(promo, new_price) else: # 默认月付
result['discount_amount'] = discount price = float(to_plan.monthly_price) if to_plan.monthly_price else 0
result['final_amount'] = new_price - discount
result['promo_code'] = promo.code
elif error:
result['promo_error'] = error
return result if price <= 0:
return {'error': f'{to_cycle} 周期价格未配置'}
# 5. 升级场景:计算剩余价值 # 4. 判断是新购还是续费
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first() is_renewal = False
if not current_plan: subscription_type = 'new'
return {'error': '当前套餐信息不存在'} current_plan = None
current_cycle = None
remaining_value = calculate_remaining_value(current_sub, current_plan) if current_sub and current_sub.subscription_type in ['pro', 'max']:
# 如果当前是付费用户,则为续费
# 6. 计算升级差价 is_renewal = True
upgrade_amount = max(0, new_price - remaining_value) subscription_type = 'renew'
current_plan = current_sub.subscription_type
# 7. 判断升级类型 current_cycle = current_sub.billing_cycle
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'
# 5. 构建结果(续费和新购价格完全一致)
result = { result = {
'is_upgrade': True, 'is_renewal': is_renewal,
'upgrade_type': upgrade_type, 'subscription_type': subscription_type,
'current_plan': current_sub.subscription_type, 'current_plan': current_plan,
'current_cycle': current_sub.billing_cycle, 'current_cycle': current_cycle,
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None, 'new_plan_price': price,
'new_plan_price': new_price, 'original_amount': price,
'remaining_value': remaining_value,
'upgrade_amount': upgrade_amount,
'original_amount': upgrade_amount,
'discount_amount': 0, 'discount_amount': 0,
'final_amount': upgrade_amount, 'final_amount': price,
'promo_code': None 'promo_code': None,
'promo_error': None
} }
# 8. 应用优惠码 # 6. 应用优惠码
if promo_code and upgrade_amount > 0: if promo_code and promo_code.strip():
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id) promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, price, user_id)
if promo: if promo:
discount = calculate_discount(promo, upgrade_amount) discount = calculate_discount(promo, price)
result['discount_amount'] = discount result['discount_amount'] = float(discount)
result['final_amount'] = upgrade_amount - discount result['final_amount'] = price - float(discount)
result['promo_code'] = promo.code result['promo_code'] = promo.code
elif error: elif error:
result['promo_error'] = error result['promo_error'] = error
return result return result
except Exception as e: 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(): def initialize_subscription_plans_safe():
@@ -1594,7 +1622,33 @@ def validate_promo_code_api():
@app.route('/api/subscription/calculate-price', methods=['POST']) @app.route('/api/subscription/calculate-price', methods=['POST'])
def calculate_subscription_price(): 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: try:
if 'user_id' not in session: if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401 return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1607,8 +1661,8 @@ def calculate_subscription_price():
if not to_plan or not to_cycle: if not to_plan or not to_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400 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: if 'error' in result:
return jsonify({ return jsonify({
@@ -1630,7 +1684,16 @@ def calculate_subscription_price():
@app.route('/api/payment/create-order', methods=['POST']) @app.route('/api/payment/create-order', methods=['POST'])
def create_payment_order(): def create_payment_order():
"""创建支付订单(支持升级和优惠码)""" """
创建支付订单(新版:简化逻辑,不再记录升级)
Request Body:
{
"plan_name": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
"""
try: try:
if 'user_id' not in session: if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401 return jsonify({'success': False, 'error': '未登录'}), 401
@@ -1643,16 +1706,14 @@ def create_payment_order():
if not plan_name or not billing_cycle: if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400 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: if 'error' in price_result:
return jsonify({'success': False, 'error': price_result['error']}), 400 return jsonify({'success': False, 'error': price_result['error']}), 400
amount = price_result['final_amount'] amount = price_result['final_amount']
original_amount = price_result['original_amount'] subscription_type = price_result.get('subscription_type', 'new') # new 或 renew
discount_amount = price_result['discount_amount']
is_upgrade = price_result.get('is_upgrade', False)
# 创建订单 # 创建订单
try: try:
@@ -1663,48 +1724,23 @@ def create_payment_order():
amount=amount amount=amount
) )
# 添加扩展字段(使用动态属性 # 添加订阅类型标记(用于前端展示
if hasattr(order, 'original_amount') or True: # 兼容性检查 order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅"
order.original_amount = original_amount
order.discount_amount = discount_amount
order.is_upgrade = is_upgrade
# 如果使用了优惠码,关联优惠码 # 如果使用了优惠码,关联优惠码
if promo_code and price_result.get('promo_code'): if promo_code and price_result.get('promo_code'):
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first() promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
if promo_obj: if promo_obj:
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
# 如果没有该字段,这行会报错,可以注释掉
try:
order.promo_code_id = promo_obj.id order.promo_code_id = promo_obj.id
except:
# 如果是升级,记录原套餐信息 pass # 如果表中没有该字段,跳过
if is_upgrade:
order.upgrade_from_plan = price_result.get('current_plan')
db.session.add(order) db.session.add(order)
db.session.commit() 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: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500 return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500

View File

@@ -69,7 +69,7 @@ module.exports = {
}, },
// 日期/日历库 // 日期/日历库
calendar: { calendar: {
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/, test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
name: 'calendar-lib', name: 'calendar-lib',
priority: 18, priority: 18,
reuseExistingChunk: true, reuseExistingChunk: true,
@@ -161,13 +161,8 @@ module.exports = {
); );
} }
// 忽略 moment 的语言包(如果项目使用了 moment // Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
webpackConfig.plugins.push( // 如果需要优化,可以只导入需要的语言包
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
})
);
// ============== Loader 优化 ============== // ============== Loader 优化 ==============
const babelLoaderRule = webpackConfig.module.rules.find( const babelLoaderRule = webpackConfig.module.rules.find(

427
database_migration.sql Normal file
View File

@@ -0,0 +1,427 @@
-- ============================================
-- 订阅支付系统数据库迁移 SQL
-- 版本: v2.0.0
-- 日期: 2025-11-19
-- ============================================
-- ============================================
-- 第一步: 备份现有数据
-- ============================================
-- 创建备份表
CREATE TABLE IF NOT EXISTS user_subscriptions_backup AS SELECT * FROM user_subscriptions;
CREATE TABLE IF NOT EXISTS payment_orders_backup AS SELECT * FROM payment_orders;
CREATE TABLE IF NOT EXISTS subscription_plans_backup AS SELECT * FROM subscription_plans;
-- ============================================
-- 第二步: 删除旧表(先删除外键依赖的表)
-- ============================================
DROP TABLE IF EXISTS subscription_upgrades; -- 删除升级表,不再使用
DROP TABLE IF EXISTS promo_code_usage; -- 暂时删除,稍后重建
DROP TABLE IF EXISTS payment_orders; -- 删除旧订单表
DROP TABLE IF EXISTS user_subscriptions; -- 删除旧订阅表
DROP TABLE IF EXISTS subscription_plans; -- 删除旧套餐表
-- ============================================
-- 第三步: 创建新表结构
-- ============================================
-- 1. 订阅套餐表(重构)
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='订阅套餐配置表';
-- 2. 用户订阅记录表(重构)
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, free',
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 DEFAULT 0 COMMENT '实际支付金额',
original_price DECIMAL(10,2) NOT NULL DEFAULT 0 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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
-- 3. 支付订单表(重构)
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
-- 4. 优惠码使用记录表(重建)
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
-- ============================================
-- 第四步: 插入初始数据
-- ============================================
-- 插入套餐数据
INSERT INTO subscription_plans (
plan_code,
plan_name,
description,
price_monthly,
price_quarterly,
price_semiannual,
price_yearly,
features,
display_order,
is_active
) VALUES
(
'pro',
'Pro 专业版',
'为专业投资者打造,解锁高级分析功能',
299.00,
799.00,
1499.00,
2699.00,
JSON_ARRAY(
'新闻信息流',
'历史事件对比',
'事件传导链分析(AI)',
'事件-相关标的分析',
'相关概念展示',
'AI复盘功能',
'企业概览',
'个股深度分析(AI) - 50家/月',
'高效数据筛选工具',
'概念中心(548大概念)',
'历史时间轴查询 - 100天',
'涨停板块数据分析',
'个股涨停分析'
),
1,
TRUE
),
(
'max',
'Max 旗舰版',
'旗舰级体验,无限制使用所有功能',
599.00,
1599.00,
2999.00,
5399.00,
JSON_ARRAY(
'全部 Pro 版功能',
'板块深度分析(AI)',
'个股深度分析(AI) - 无限制',
'历史时间轴查询 - 无限制',
'概念高频更新',
'优先客服支持',
'独家功能抢先体验'
),
2,
TRUE
);
-- ============================================
-- 第五步: 数据迁移(可选)
-- ============================================
-- 如果需要迁移旧数据,取消以下注释:
/*
-- 迁移旧的用户订阅数据
INSERT INTO user_subscriptions (
user_id,
subscription_id,
plan_code,
billing_cycle,
start_date,
end_date,
status,
is_current,
paid_amount,
original_price,
subscription_type,
auto_renew,
created_at
)
SELECT
user_id,
CONCAT('SUB_', id, '_', UNIX_TIMESTAMP(NOW())), -- 生成订阅ID
subscription_type, -- 将 subscription_type 映射为 plan_code
COALESCE(billing_cycle, 'yearly'), -- 默认年付
COALESCE(start_date, NOW()),
COALESCE(end_date, DATE_ADD(NOW(), INTERVAL 365 DAY)),
subscription_status,
TRUE, -- 设为当前订阅
0, -- 旧数据没有支付金额,设为0
0, -- 旧数据没有原价,设为0
'new', -- 默认为新购
COALESCE(auto_renewal, FALSE),
created_at
FROM user_subscriptions_backup
WHERE subscription_type IN ('pro', 'max'); -- 只迁移付费用户
*/
-- ============================================
-- 第六步: 创建免费订阅记录(为所有用户)
-- ============================================
-- 为所有现有用户创建免费订阅记录(如果没有付费订阅)
/*
INSERT INTO user_subscriptions (
user_id,
subscription_id,
plan_code,
billing_cycle,
start_date,
end_date,
status,
is_current,
paid_amount,
original_price,
subscription_type
)
SELECT
id AS user_id,
CONCAT('FREE_', id, '_', UNIX_TIMESTAMP(NOW())),
'free',
'monthly',
NOW(),
'2099-12-31 23:59:59', -- 免费版永久有效
'active',
TRUE,
0,
0,
'new'
FROM user
WHERE id NOT IN (
SELECT DISTINCT user_id FROM user_subscriptions WHERE plan_code IN ('pro', 'max')
);
*/
-- ============================================
-- 第七步: 验证数据完整性
-- ============================================
-- 检查套餐数据
SELECT * FROM subscription_plans;
-- 检查用户订阅数据
SELECT
plan_code,
COUNT(*) as user_count,
SUM(CASE WHEN is_current = TRUE THEN 1 ELSE 0 END) as current_count
FROM user_subscriptions
GROUP BY plan_code;
-- 检查支付订单数据
SELECT
status,
COUNT(*) as order_count,
SUM(final_amount) as total_amount
FROM payment_orders
GROUP BY status;
-- ============================================
-- 第八步: 添加外键约束(可选)
-- ============================================
-- 注意: 只有在确认 users 表存在且数据完整时才执行
-- ALTER TABLE user_subscriptions
-- ADD CONSTRAINT fk_user_subscriptions_user
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- ALTER TABLE payment_orders
-- ADD CONSTRAINT fk_payment_orders_user
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- ALTER TABLE payment_orders
-- ADD CONSTRAINT fk_payment_orders_promo
-- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL;
-- ALTER TABLE promo_code_usage
-- ADD CONSTRAINT fk_promo_usage_promo
-- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE;
-- ALTER TABLE promo_code_usage
-- ADD CONSTRAINT fk_promo_usage_user
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- ALTER TABLE promo_code_usage
-- ADD CONSTRAINT fk_promo_usage_order
-- FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE;
-- ============================================
-- 第九步: 创建测试数据(开发环境)
-- ============================================
-- 插入测试优惠码
INSERT INTO promo_codes (
code,
description,
discount_type,
discount_value,
applicable_plans,
applicable_cycles,
max_total_uses,
max_uses_per_user,
valid_from,
valid_until,
is_active
) VALUES
(
'WELCOME2025',
'2025新用户专享',
'percentage',
20.00,
NULL, -- 适用所有套餐
NULL, -- 适用所有周期
1000,
1,
NOW(),
DATE_ADD(NOW(), INTERVAL 90 DAY),
TRUE
),
(
'YEAR2025',
'年付专享',
'percentage',
10.00,
NULL,
JSON_ARRAY('yearly'), -- 仅适用年付
500,
1,
NOW(),
DATE_ADD(NOW(), INTERVAL 365 DAY),
TRUE
),
(
'TESTCODE',
'测试优惠码 - 固定减100元',
'fixed_amount',
100.00,
NULL,
NULL,
100,
1,
NOW(),
DATE_ADD(NOW(), INTERVAL 30 DAY),
TRUE
);
-- ============================================
-- 迁移完成提示
-- ============================================
SELECT '===================================' AS '';
SELECT '数据库迁移完成!' AS '状态';
SELECT '===================================' AS '';
SELECT '请检查以下数据:' AS '提示';
SELECT '1. subscription_plans 表是否有2条记录 (pro, max)' AS '';
SELECT '2. user_subscriptions 表数据是否正确' AS '';
SELECT '3. payment_orders 表结构是否正确' AS '';
SELECT '4. 备份表 (*_backup) 已创建' AS '';
SELECT '===================================' AS '';
SELECT '下一步: 更新后端代码 (app.py, models.py)' AS '';
SELECT '===================================' AS '';

View File

@@ -1,626 +0,0 @@
# 通知系统增强功能 - 使用指南
## 📋 概述
本指南介绍通知系统的三大增强功能:
1. **智能桌面通知** - 自动请求权限,系统级通知
2. **性能监控** - 追踪推送效果,数据驱动优化
3. **历史记录** - 持久化存储,随时查询
---
## 🎯 功能 1智能桌面通知
### 功能说明
首次收到重要/紧急通知时,自动请求浏览器通知权限,确保用户不错过关键信息。
### 工作原理
```javascript
// 在 NotificationContext 中的逻辑
if (priority === URGENT || priority === IMPORTANT) {
if (browserPermission === 'default' && !hasRequestedPermission) {
// 首次遇到重要通知,自动请求权限
await requestBrowserPermission();
setHasRequestedPermission(true); // 避免重复请求
}
}
```
### 权限状态
- **granted**: 已授权,可以发送桌面通知
- **denied**: 已拒绝,无法发送桌面通知
- **default**: 未请求,首次重要通知时会自动请求
### 使用示例
**自动触发**(推荐)
```javascript
// 无需任何代码,系统自动处理
// 首次收到重要/紧急通知时会自动弹出权限请求
```
**手动请求**
```javascript
import { useNotification } from 'contexts/NotificationContext';
function SettingsPage() {
const { requestBrowserPermission, browserPermission } = useNotification();
return (
<div>
<p>当前状态: {browserPermission}</p>
<button onClick={requestBrowserPermission}>
开启桌面通知
</button>
</div>
);
}
```
### 通知分发策略
| 优先级 | 页面在前台 | 页面在后台 |
|-------|----------|----------|
| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
| 重要 | 网页通知 | 桌面通知 |
| 普通 | 网页通知 | 网页通知 |
### 测试步骤
1. **清除已保存的权限状态**
```javascript
localStorage.removeItem('browser_notification_requested');
```
2. **刷新页面**
3. **触发一个重要/紧急通知**
- Mock 模式:等待自动推送
- Real 模式:创建测试事件
4. **观察权限请求弹窗**
- 浏览器会弹出通知权限请求
- 点击"允许"授权
5. **验证桌面通知**
- 切换到其他标签页
- 收到重要通知时应该看到桌面通知
---
## 📊 功能 2性能监控
### 功能说明
追踪通知推送的各项指标,包括:
- **到达率**: 发送 vs 接收
- **点击率**: 点击 vs 接收
- **响应时间**: 收到通知到点击的平均时间
- **类型分布**: 各类型通知的数量和效果
- **时段分布**: 每小时推送量
### API 参考
#### 获取汇总统计
```javascript
import { notificationMetricsService } from 'services/notificationMetricsService';
const summary = notificationMetricsService.getSummary();
console.log(summary);
/* 输出:
{
totalSent: 100,
totalReceived: 98,
totalClicked: 45,
totalDismissed: 53,
avgResponseTime: 5200, // 毫秒
clickRate: '45.92', // 百分比
deliveryRate: '98.00' // 百分比
}
*/
```
#### 获取按类型统计
```javascript
const byType = notificationMetricsService.getByType();
console.log(byType);
/* 输出:
{
announcement: { sent: 20, received: 20, clicked: 15, dismissed: 5, clickRate: '75.00' },
stock_alert: { sent: 30, received: 30, clicked: 20, dismissed: 10, clickRate: '66.67' },
event_alert: { sent: 40, received: 38, clicked: 10, dismissed: 28, clickRate: '26.32' },
analysis_report: { sent: 10, received: 10, clicked: 0, dismissed: 10, clickRate: '0.00' }
}
*/
```
#### 获取按优先级统计
```javascript
const byPriority = notificationMetricsService.getByPriority();
console.log(byPriority);
/* 输出:
{
urgent: { sent: 10, received: 10, clicked: 9, dismissed: 1, clickRate: '90.00' },
important: { sent: 40, received: 39, clicked: 25, dismissed: 14, clickRate: '64.10' },
normal: { sent: 50, received: 49, clicked: 11, dismissed: 38, clickRate: '22.45' }
}
*/
```
#### 获取每日数据
```javascript
const dailyData = notificationMetricsService.getDailyData(7); // 最近 7 天
console.log(dailyData);
/* 输出:
[
{ date: '2025-01-15', sent: 15, received: 14, clicked: 6, dismissed: 8, clickRate: '42.86' },
{ date: '2025-01-16', sent: 20, received: 20, clicked: 10, dismissed: 10, clickRate: '50.00' },
...
]
*/
```
#### 获取完整指标
```javascript
const allMetrics = notificationMetricsService.getAllMetrics();
console.log(allMetrics);
```
#### 导出数据
```javascript
// 导出为 JSON
const json = notificationMetricsService.exportToJSON();
console.log(json);
// 导出为 CSV
const csv = notificationMetricsService.exportToCSV();
console.log(csv);
```
#### 重置指标
```javascript
notificationMetricsService.reset();
```
### 在控制台查看实时指标
打开浏览器控制台,执行:
```javascript
// 引入服务
import { notificationMetricsService } from './services/notificationMetricsService.js';
// 查看汇总
console.table(notificationMetricsService.getSummary());
// 查看按类型分布
console.table(notificationMetricsService.getByType());
// 查看最近 7 天数据
console.table(notificationMetricsService.getDailyData(7));
```
### 监控埋点(自动)
监控服务已自动集成到 `NotificationContext`,无需手动调用:
- **trackReceived**: 收到通知时自动调用
- **trackClicked**: 点击通知时自动调用
- **trackDismissed**: 关闭通知时自动调用
### 可视化展示(可选)
你可以基于监控数据创建仪表板:
```javascript
import { notificationMetricsService } from 'services/notificationMetricsService';
import { PieChart, LineChart } from 'recharts';
function MetricsDashboard() {
const summary = notificationMetricsService.getSummary();
const dailyData = notificationMetricsService.getDailyData(7);
const byType = notificationMetricsService.getByType();
return (
<div>
{/* 汇总卡片 */}
<StatsCard title="总推送数" value={summary.totalSent} />
<StatsCard title="点击率" value={`${summary.clickRate}%`} />
<StatsCard title="平均响应时间" value={`${summary.avgResponseTime}ms`} />
{/* 类型分布饼图 */}
<PieChart data={Object.entries(byType).map(([type, data]) => ({
name: type,
value: data.received
}))} />
{/* 每日趋势折线图 */}
<LineChart data={dailyData} />
</div>
);
}
```
---
## 📜 功能 3历史记录
### 功能说明
持久化存储所有接收到的通知,支持:
- 查询和筛选
- 搜索关键词
- 标记已读/已点击
- 批量删除
- 导出JSON/CSV
### API 参考
#### 获取历史记录(支持筛选和分页)
```javascript
import { notificationHistoryService } from 'services/notificationHistoryService';
const result = notificationHistoryService.getHistory({
type: 'event_alert', // 可选:筛选类型
priority: 'urgent', // 可选:筛选优先级
readStatus: 'unread', // 可选:'read' | 'unread' | 'all'
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000, // 可选:开始日期
endDate: Date.now(), // 可选:结束日期
page: 1, // 页码
pageSize: 20, // 每页数量
});
console.log(result);
/* 输出:
{
records: [...], // 当前页的记录
total: 150, // 总记录数
page: 1, // 当前页
pageSize: 20, // 每页数量
totalPages: 8 // 总页数
}
*/
```
#### 搜索历史记录
```javascript
const results = notificationHistoryService.searchHistory('降准');
console.log(results); // 返回标题/内容中包含"降准"的所有记录
```
#### 标记已读/已点击
```javascript
// 标记已读
notificationHistoryService.markAsRead('notification_id');
// 标记已点击
notificationHistoryService.markAsClicked('notification_id');
```
#### 删除记录
```javascript
// 删除单条
notificationHistoryService.deleteRecord('notification_id');
// 批量删除
notificationHistoryService.deleteRecords(['id1', 'id2', 'id3']);
// 清空所有
notificationHistoryService.clearHistory();
```
#### 获取统计数据
```javascript
const stats = notificationHistoryService.getStats();
console.log(stats);
/* 输出:
{
total: 500, // 总记录数
read: 320, // 已读数
unread: 180, // 未读数
clicked: 150, // 已点击数
clickRate: '30.00', // 点击率
byType: { // 按类型统计
announcement: 100,
stock_alert: 150,
event_alert: 200,
analysis_report: 50
},
byPriority: { // 按优先级统计
urgent: 50,
important: 200,
normal: 250
}
}
*/
```
#### 导出历史记录
```javascript
// 导出为 JSON 字符串
const json = notificationHistoryService.exportToJSON({
type: 'event_alert' // 可选:只导出特定类型
});
// 导出为 CSV 字符串
const csv = notificationHistoryService.exportToCSV();
// 直接下载 JSON 文件
notificationHistoryService.downloadJSON();
// 直接下载 CSV 文件
notificationHistoryService.downloadCSV();
```
### 在控制台使用
打开浏览器控制台,执行:
```javascript
// 引入服务
import { notificationHistoryService } from './services/notificationHistoryService.js';
// 查看所有历史
console.table(notificationHistoryService.getHistory().records);
// 搜索
const results = notificationHistoryService.searchHistory('央行');
console.table(results);
// 查看统计
console.table(notificationHistoryService.getStats());
// 导出并下载
notificationHistoryService.downloadJSON();
```
### 数据结构
每条历史记录包含:
```javascript
{
id: 'notif_123', // 通知 ID
notification: { // 完整通知对象
type: 'event_alert',
priority: 'urgent',
title: '...',
content: '...',
...
},
receivedAt: 1737459600000, // 接收时间戳
readAt: 1737459650000, // 已读时间戳null 表示未读)
clickedAt: null, // 已点击时间戳null 表示未点击)
}
```
### 存储限制
- **最大数量**: 500 条(超过后自动删除最旧的)
- **存储位置**: localStorage
- **容量估算**: 约 2-5MB取决于通知内容长度
---
## 🔧 技术细节
### 文件结构
```
src/
├── services/
│ ├── browserNotificationService.js [已存在] 浏览器通知服务
│ ├── notificationMetricsService.js [新建] 性能监控服务
│ └── notificationHistoryService.js [新建] 历史记录服务
├── contexts/
│ └── NotificationContext.js [修改] 集成所有功能
└── components/
└── NotificationContainer/
└── index.js [修改] 添加点击追踪
```
### 修改清单
| 文件 | 修改内容 | 状态 |
|------|---------|------|
| `NotificationContext.js` | 添加智能权限请求、监控埋点、历史保存 | ✅ 已完成 |
| `NotificationContainer/index.js` | 添加点击追踪 | ✅ 已完成 |
| `notificationMetricsService.js` | 性能监控服务 | ✅ 已创建 |
| `notificationHistoryService.js` | 历史记录服务 | ✅ 已创建 |
### 数据流
```
用户收到通知
NotificationContext.addWebNotification()
├─ notificationMetricsService.trackReceived() [监控埋点]
├─ notificationHistoryService.saveNotification() [历史保存]
├─ 首次重要通知 → requestBrowserPermission() [智能权限]
└─ 显示网页通知或桌面通知
用户点击通知
NotificationContainer.handleClick()
├─ notificationMetricsService.trackClicked() [监控埋点]
├─ notificationHistoryService.markAsClicked() [历史标记]
└─ 跳转到目标页面
用户关闭通知
NotificationContext.removeNotification()
└─ notificationMetricsService.trackDismissed() [监控埋点]
```
---
## 🧪 测试步骤
### 1. 测试智能桌面通知
```bash
# 1. 清除已保存的权限状态
localStorage.removeItem('browser_notification_requested');
# 2. 刷新页面
# 3. 等待或触发一个重要/紧急通知
# 4. 观察浏览器弹出权限请求
# 5. 授权后验证桌面通知功能
```
### 2. 测试性能监控
```javascript
// 在控制台执行
import { notificationMetricsService } from './services/notificationMetricsService.js';
// 查看实时统计
console.table(notificationMetricsService.getSummary());
// 模拟推送几条通知,再次查看
console.table(notificationMetricsService.getAllMetrics());
// 导出数据
console.log(notificationMetricsService.exportToJSON());
```
### 3. 测试历史记录
```javascript
// 在控制台执行
import { notificationHistoryService } from './services/notificationHistoryService.js';
// 查看历史
console.table(notificationHistoryService.getHistory().records);
// 搜索
console.table(notificationHistoryService.searchHistory('降准'));
// 查看统计
console.table(notificationHistoryService.getStats());
// 导出
notificationHistoryService.downloadJSON();
```
---
## 📈 数据导出示例
### 导出性能监控数据
```javascript
import { notificationMetricsService } from 'services/notificationMetricsService';
// 导出 JSON
const json = notificationMetricsService.exportToJSON();
// 复制到剪贴板或保存
// 导出 CSV
const csv = notificationMetricsService.exportToCSV();
// 可以在 Excel 中打开
```
### 导出历史记录
```javascript
import { notificationHistoryService } from 'services/notificationHistoryService';
// 导出最近 7 天的事件动向通知
const json = notificationHistoryService.exportToJSON({
type: 'event_alert',
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000
});
// 直接下载为文件
notificationHistoryService.downloadJSON({
type: 'event_alert'
});
```
---
## ⚠️ 注意事项
### 1. localStorage 容量限制
- 大多数浏览器限制为 5-10MB
- 建议定期清理历史记录和监控数据
- 使用导出功能备份数据
### 2. 浏览器兼容性
- **桌面通知**: 需要 HTTPS 或 localhost
- **localStorage**: 所有现代浏览器支持
- **权限请求**: 需要用户交互(不能自动授权)
### 3. 隐私和数据安全
- 所有数据存储在本地localStorage
- 不会上传到服务器
- 用户可以随时清空数据
### 4. 性能影响
- 监控埋点非常轻量,几乎无性能影响
- 历史记录保存异步进行,不阻塞 UI
- 数据查询在客户端完成,不增加服务器负担
---
## 🎉 总结
### 已实现的功能
**智能桌面通知**
- 首次重要通知时自动请求权限
- 智能分发策略(前台/后台)
- localStorage 持久化权限状态
**性能监控**
- 到达率、点击率、响应时间追踪
- 按类型、优先级、时段统计
- 数据导出JSON/CSV
**历史记录**
- 持久化存储(最多 500 条)
- 筛选、搜索、分页
- 已读/已点击标记
- 数据导出JSON/CSV
### 未实现的功能(备份,待上线)
⏸️ 历史记录页面 UI代码已备份随时可上线
⏸️ 监控仪表板 UI可选暂未实现
### 下一步建议
1. **用户设置页面**: 允许用户自定义通知偏好
2. **声音提示**: 为紧急通知添加音效
3. **数据同步**: 将历史和监控数据同步到服务器
4. **高级筛选**: 添加更多筛选维度(如关键词、股票代码等)
---
**文档版本**: v1.0
**最后更新**: 2025-01-21
**维护者**: Claude Code

View File

@@ -1,371 +0,0 @@
# 消息推送系统整合 - 测试指南
## 📋 整合完成清单
**统一事件名称**
- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
- 移除了 `trade_notification` 事件名
**数据适配器**
- 创建了 `adaptEventToNotification` 函数
- 自动识别后端事件格式并转换为前端通知格式
- 重要性映射S → urgent, A → important, B/C → normal
**NotificationContext 升级**
- 监听 `new_event` 事件
- 自动使用适配器转换事件数据
- 支持 Mock 和 Real 模式无缝切换
**EventList 实时推送**
- 集成 `useEventNotifications` Hook
- 实时更新事件列表
- Toast 通知提示
- WebSocket 连接状态指示器
---
## 🧪 测试步骤
### 1. 测试 Mock 模式(开发环境)
#### 1.1 配置环境变量
确保 `.env` 文件包含以下配置:
```bash
REACT_APP_USE_MOCK_SOCKET=true
# 或者
REACT_APP_ENABLE_MOCK=true
```
#### 1.2 启动应用
```bash
npm start
```
#### 1.3 验证功能
**a) 右下角通知卡片**
- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
- 每隔 60 秒会自动推送 1-2 条模拟消息
- 通知类型包括:
- 📢 公告通知(蓝色)
- 📈 股票动向(红/绿色,根据涨跌)
- 📰 事件动向(橙色)
- 📊 分析报告(紫色)
**b) 事件列表页面**
- 访问事件列表页面Community/Events
- 顶部应显示 "🟢 实时推送已开启"
- 收到新事件时:
- 右上角显示 Toast 通知
- 事件自动添加到列表顶部
- 无重复添加
**c) 控制台日志**
打开浏览器控制台,应该看到:
```
[Socket Service] Using MOCK Socket Service
NotificationContext: Socket connected
EventList: 收到新事件推送
```
---
### 2. 测试 Real 模式(生产环境)
#### 2.1 配置环境变量
修改 `.env` 文件:
```bash
REACT_APP_USE_MOCK_SOCKET=false
# 或删除该配置项
```
#### 2.2 启动后端 Flask 服务
```bash
python app_2.py
```
确保后端已启动 Socket.IO 服务并监听事件推送。
#### 2.3 启动前端应用
```bash
npm start
```
#### 2.4 创建测试事件(后端)
使用后端提供的测试脚本:
```bash
python test_create_event.py
```
#### 2.5 验证功能
**a) WebSocket 连接**
- 检查控制台:`[Socket Service] Using REAL Socket Service`
- 事件列表顶部显示 "🟢 实时推送已开启"
**b) 事件推送流程**
1. 运行 `test_create_event.py` 创建新事件
2. 后端轮询检测到新事件(最多等待 30 秒)
3. 后端通过 Socket.IO 推送 `new_event`
4. 前端接收事件并转换格式
5. 同时显示:
- 右下角通知卡片
- 事件列表 Toast 提示
- 事件添加到列表顶部
**c) 数据格式验证**
在控制台查看事件对象,应包含:
```javascript
{
id: 123,
type: "event_alert", // 适配器转换后
priority: "urgent", // importance: S → urgent
title: "事件标题",
content: "事件描述",
clickable: true,
link: "/event-detail/123",
extra: {
eventType: "tech",
importance: "S",
// ... 更多后端字段
}
}
```
---
## 🔍 验证清单
### 功能验证
- [ ] Mock 模式下收到模拟通知
- [ ] Real 模式下收到真实后端推送
- [ ] 通知卡片正确显示(类型、颜色、内容)
- [ ] 事件列表实时更新
- [ ] Toast 通知正常弹出
- [ ] 连接状态指示器正确显示
- [ ] 点击通知可跳转到详情页
- [ ] 无重复事件添加
### 数据验证
- [ ] 后端事件格式正确转换
- [ ] 重要性映射正确S/A/B/C → urgent/important/normal
- [ ] 时间戳正确显示
- [ ] 链接路径正确生成
- [ ] 所有字段完整保留在 extra 中
### 性能验证
- [ ] 事件列表最多保留 100 条
- [ ] 通知自动关闭(紧急=不关闭,重要=30s普通=15s
- [ ] WebSocket 自动重连
- [ ] 无内存泄漏
---
## 🐛 常见问题排查
### Q1: Mock 模式下没有收到通知?
**A:** 检查:
1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
2. 控制台是否显示 "Using MOCK Socket Service"
3. 是否等待了 3 秒(首次通知延迟)
### Q2: Real 模式下无法连接?
**A:** 检查:
1. Flask 后端是否启动:`python app_2.py`
2. API_BASE_URL 是否正确配置
3. CORS 设置是否包含前端域名
4. 控制台是否有连接错误
### Q3: 收到重复通知?
**A:** 检查:
1. 是否多次渲染了 EventList 组件
2. 是否在多个地方调用了 `useEventNotifications`
3. 控制台日志中是否有 "事件已存在,跳过添加"
### Q4: 通知卡片样式异常?
**A:** 检查:
1. 事件的 `type` 字段是否正确
2. 是否缺少必要的字段title, content
3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
### Q5: 事件列表不更新?
**A:** 检查:
1. WebSocket 连接状态(顶部 Badge
2. `onNewEvent` 回调是否触发(控制台日志)
3. `setLocalEvents` 是否正确执行
---
## 📊 测试数据示例
### Mock 模拟数据类型
**公告通知**
```javascript
{
type: "announcement",
priority: "urgent",
title: "贵州茅台发布2024年度财报公告",
content: "2024年度营收同比增长15.2%..."
}
```
**股票动向**
```javascript
{
type: "stock_alert",
priority: "urgent",
title: "您关注的股票触发预警",
extra: {
stockCode: "300750",
priceChange: "+5.2%"
}
}
```
**事件动向**
```javascript
{
type: "event_alert",
priority: "important",
title: "央行宣布降准0.5个百分点",
extra: {
eventId: "evt001",
sectors: ["银行", "地产", "基建"]
}
}
```
**分析报告**
```javascript
{
type: "analysis_report",
priority: "important",
title: "医药行业深度报告:创新药迎来政策拐点",
author: {
name: "李明",
organization: "中信证券"
}
}
```
### 真实后端事件格式
```javascript
{
id: 123,
title: "新能源汽车补贴政策延期",
description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
event_type: "policy",
importance: "S",
status: "active",
created_at: "2025-01-21T14:30:00",
hot_score: 95.5,
view_count: 1234,
related_avg_chg: 5.2,
related_max_chg: 15.8,
keywords: ["新能源", "补贴", "政策"]
}
```
---
## 🎯 下一步建议
### 1. 用户设置
允许用户控制通知偏好:
```jsx
<Switch
isChecked={enableNotifications}
onChange={handleToggle}
>
启用实时通知
</Switch>
```
### 2. 通知过滤
按重要性、类型过滤通知:
```javascript
useEventNotifications({
eventType: 'tech', // 只订阅科技类
importance: 'S', // 只订阅 S 级
enabled: true
})
```
### 3. 声音提示
添加音效提醒:
```javascript
onNewEvent: (event) => {
if (event.priority === 'urgent') {
new Audio('/alert.mp3').play();
}
}
```
### 4. 桌面通知
利用浏览器通知 API
```javascript
if (Notification.permission === 'granted') {
new Notification(event.title, {
body: event.content,
icon: '/logo.png'
});
}
```
---
## 📝 技术说明
### 架构优势
1. **统一接口**Mock 和 Real 完全相同的 API
2. **自动适配**:智能识别数据格式并转换
3. **解耦设计**:通知系统和事件列表独立工作
4. **向后兼容**:不影响现有功能
### 关键文件
- `src/services/socketService.js` - Socket.IO 服务
- `src/services/socket/index.js` - Socket 服务导出
- `src/contexts/NotificationContext.js` - 通知上下文
- `src/hooks/useEventNotifications.js` - React Hook
- `src/views/Community/components/EventList.js` - 事件列表集成
> **注意**: `mockSocketService.js` 已移除2025-01-10现仅使用真实 Socket 连接。
### 数据流
```
后端创建事件
后端轮询检测30秒
Socket.IO 推送 new_event
前端 socketService 接收
NotificationContext 监听并适配
同时触发:
├─ NotificationContainer右下角卡片
└─ EventList onNewEventToast + 列表更新)
```
---
## ✅ 整合完成
所有代码和功能已经就绪!你现在可以:
1. ✅ 在 Mock 模式下测试实时推送
2. ✅ 在 Real 模式下连接后端
3. ✅ 查看右下角通知卡片
4. ✅ 体验事件列表实时更新
5. ✅ 随时切换 Mock/Real 模式
**祝测试顺利!🎉**

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,280 +0,0 @@
# 消息推送系统优化总结
## 优化目标
1. 简化通知信息密度,通过视觉层次(边框+背景色)表达优先级
2. 增强紧急通知的视觉冲击力(红色脉冲边框动画)
3. 采用智能显示策略,降低普通通知的视觉干扰
## 实施内容
### 1. 优先级配置更新 (src/constants/notificationTypes.js)
#### 新增配置项
- `borderWidth`: 边框宽度
- 紧急 (urgent): 6px
- 重要 (important): 4px
- 普通 (normal): 2px
- `bgOpacity`: 背景色透明度(亮色模式)
- 紧急: 0.25 (深色背景)
- 重要: 0.15 (中色背景)
- 普通: 0.08 (浅色背景)
- `darkBgOpacity`: 背景色透明度(暗色模式)
- 紧急: 0.30
- 重要: 0.20
- 普通: 0.12
#### 新增辅助函数
- `getPriorityBgOpacity(priority, isDark)`: 获取优先级对应的背景色透明度
- `getPriorityBorderWidth(priority)`: 获取优先级对应的边框宽度
### 2. 紧急通知脉冲动画 (src/components/NotificationContainer/index.js)
#### 动画效果
- 使用 `@emotion/react``keyframes` 创建脉冲动画
- 仅紧急通知 (urgent) 应用动画效果
- 动画特性:
- 边框颜色脉冲效果
- 阴影扩散效果0 → 12px
- 持续时间2秒
- 缓动函数ease-in-out
- 无限循环
```javascript
const pulseAnimation = keyframes`
0%, 100% {
border-left-color: currentColor;
box-shadow: 0 0 0 0 currentColor;
}
50% {
border-left-color: currentColor;
box-shadow: -4px 0 12px 0 currentColor;
}
`;
```
### 3. 背景色优先级优化
#### 亮色模式
- **紧急通知**`${colorScheme}.200` - 深色背景 + 脉冲动画
- **重要通知**`${colorScheme}.100` - 中色背景
- **普通通知**`white` - 极淡背景(降低视觉干扰)
#### 暗色模式
- **紧急通知**`${colorScheme}.800` 或 typeConfig.darkBg
- **重要通知**`${colorScheme}.800` 或 typeConfig.darkBg
- **普通通知**`gray.800` - 暗灰背景(降低视觉干扰)
### 4. 可点击性视觉提示
#### 问题
- 用户需要 hover 才能知道通知是否可点击
- cursor: pointer 不够直观
#### 解决方案
- **可点击的通知**
- 添加完整边框(四周 1px solid
- 保持左侧优先级边框宽度
- 使用更明显的阴影md 级别)
- 产生微妙的悬浮感
- **不可点击的通知**
- 仅左侧边框
- 使用较淡的阴影sm 级别)
```javascript
// 可点击的通知添加完整边框
{...(isActuallyClickable && {
border: '1px solid',
borderLeftWidth: priorityBorderWidth, // 保持优先级
})}
// 可点击的通知使用更明显的阴影
boxShadow={isActuallyClickable
? (isNewest ? '2xl' : 'md')
: (isNewest ? 'xl' : 'sm')}
```
### 5. 通知组件简化 (src/components/NotificationContainer/index.js)
#### 显示元素分级
**LV1 - 必需元素(始终显示)**
- ✅ 标题 (title)
- ✅ 内容 (content, 最多3行)
- ✅ 时间 (publishTime/pushTime)
- ✅ 查看详情 (仅当 clickable=true 时)
- ✅ 关闭按钮
**LV2 - 可选元素(数据存在时显示)**
- ✅ 图标:仅在紧急/重要通知时显示
- ❌ 优先级标签:已移除,改用边框+背景色表示
- ✅ 状态提示:仅当 `extra?.statusHint` 存在时显示
**LV3 - 可选元素(数据存在时显示)**
- ✅ AI 标识:仅当 `isAIGenerated = true` 时显示
- ✅ 预测标识:仅当 `isPrediction = true` 时显示
**其他**
- ✅ 作者信息:移除屏幕尺寸限制,仅当 `author` 存在时显示
#### 优先级视觉样式
- ✅ 边框宽度:根据优先级动态调整 (2px/4px/6px)
- ✅ 背景色深度:根据优先级使用不同深度的颜色
- 亮色模式: .50 (普通) / .100 (重要) / .200 (紧急)
- 暗色模式: 使用 typeConfig 的 darkBg 配置
#### 布局优化
- ✅ 内容和元数据区域的左侧填充根据图标显示状态自适应
- ✅ 无图标时不添加额外的左侧间距
## 预期效果
### 视觉改进
- **清晰度提升**:移除冗余的优先级标签,视觉更整洁
- **优先级强化**
- 紧急通知6px 粗边框 + 深色背景 + **红色脉冲动画** → 视觉冲击力极强
- 重要通知4px 中等边框 + 中色背景 + 图标 → 醒目但不打扰
- 普通通知2px 细边框 + 白色/极淡背景 → 低视觉干扰
- **可点击性一目了然**
- 可点击:完整边框 + 明显阴影 → 卡片悬浮感
- 不可点击:仅左侧边框 + 淡阴影 → 平面感
- **信息密度降低**:减少不必要的视觉元素,关键信息更突出
### 用户体验
- **紧急通知引起注意**:脉冲动画确保用户不会错过紧急信息
- **快速识别优先级**
- 动画 = 紧急(需要立即关注)
- 图标 + 粗边框 = 重要(需要关注)
- 细边框 + 淡背景 = 普通(可稍后查看)
- **可点击性无需 hover**
- 完整边框 + 悬浮感 = 可以点击查看详情
- 仅左侧边框 = 信息已完整,无需跳转
- **智能显示**:可选信息只在数据存在时显示,避免空白占位
- **响应式优化**:所有设备上保持一致的显示逻辑
### 向后兼容
- ✅ 完全兼容现有通知数据结构
- ✅ 可选字段不存在时自动隐藏
- ✅ 不影响现有功能(点击、关闭、自动消失等)
## 测试建议
### 1. 功能测试
```bash
# 启动开发服务器
npm start
# 观察不同优先级通知的显示效果
# - 紧急通知:粗边框 (6px) + 深色背景 + 红色脉冲动画 + 图标 + 不自动关闭
# - 重要通知:中等边框 (4px) + 中色背景 + 图标 + 30秒后关闭
# - 普通通知:细边框 (2px) + 白色背景 + 无图标 + 15秒后关闭
```
### 1.1 动画测试
- [ ] 紧急通知的脉冲动画流畅无卡顿
- [ ] 动画周期为 2 秒
- [ ] 动画在紧急通知显示期间持续循环
- [ ] 阴影扩散效果清晰可见
### 2. 边界测试
- [ ] 仅必需字段的通知(无作者、无 AI 标识、无预测标识)
- [ ] 包含所有可选字段的通知
- [ ] 不同类型的通知(公告、股票、事件、分析报告)
- [ ] 不同优先级的通知(紧急、重要、普通)
### 3. 响应式测试
- [ ] 移动设备 (< 480px)
- [ ] 平板设备 (480px - 768px)
- [ ] 桌面设备 (> 768px)
### 4. 暗色模式测试
- [ ] 切换到暗色模式,确认背景色对比度合适
## 技术细节
### 关键代码变更
#### 1. 脉冲动画实现
```javascript
// 导入 keyframes
import { keyframes } from '@emotion/react';
// 定义脉冲动画
const pulseAnimation = keyframes`
0%, 100% {
border-left-color: currentColor;
box-shadow: 0 0 0 0 currentColor;
}
50% {
border-left-color: currentColor;
box-shadow: -4px 0 12px 0 currentColor;
}
`;
// 应用到紧急通知
<Box
animation={priority === PRIORITY_LEVELS.URGENT
? `${pulseAnimation} 2s ease-in-out infinite`
: undefined}
...
/>
```
#### 2. 优先级标签自动隐藏
```javascript
// PRIORITY_CONFIGS 中所有 show 属性设置为 false
show: false, // 不再显示标签,改用边框+背景色表示
```
#### 3. 背景色优先级优化
```javascript
const getPriorityBgColor = () => {
const colorScheme = typeConfig.colorScheme;
if (!isDark) {
if (priority === PRIORITY_LEVELS.URGENT) {
return `${colorScheme}.200`; // 深色背景 + 脉冲动画
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
return `${colorScheme}.100`; // 中色背景
} else {
return 'white'; // 极淡背景(降低视觉干扰)
}
} else {
if (priority === PRIORITY_LEVELS.URGENT) {
return typeConfig.darkBg || `${colorScheme}.800`;
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
return typeConfig.darkBg || `${colorScheme}.800`;
} else {
return 'gray.800'; // 暗灰背景(降低视觉干扰)
}
}
};
```
#### 4. 图标条件显示
```javascript
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT ||
priority === PRIORITY_LEVELS.IMPORTANT;
{shouldShowIcon && (
<Icon as={typeConfig.icon} ... />
)}
};
```
## 后续改进建议
### 短期
- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
- [ ] 提供配置选项让用户自定义显示元素
### 长期
- [ ] 支持通知分组(按类型或优先级)
- [ ] 添加通知搜索和筛选功能
- [ ] 通知历史记录可视化统计
## 构建状态
✅ 构建成功 (npm run build)
✅ 无语法错误
✅ 无 TypeScript 错误

File diff suppressed because it is too large Load Diff

View File

@@ -1,338 +0,0 @@
# 崩溃修复测试指南
> 测试时间2025-10-14
> 测试范围SignInIllustration.js + SignUpIllustration.js
> 服务器地址http://localhost:3000
---
## 🎯 测试目标
验证以下修复是否有效:
- ✅ 响应对象崩溃6处
- ✅ 组件卸载后 setState6处
- ✅ 定时器内存泄漏2处
---
## 📋 测试清单
### ✅ 关键测试(必做)
#### 1. **网络异常测试** - 验证响应对象修复
**登录页面 - 发送验证码**
```
测试步骤:
1. 打开 http://localhost:3000/auth/sign-in
2. 切换到"验证码登录"模式
3. 输入手机号13800138000
4. 打开浏览器开发者工具 (F12) → Network 标签
5. 点击 Offline 模拟断网
6. 点击"发送验证码"按钮
预期结果:
✅ 显示错误提示:"发送验证码失败 - 网络请求失败,请检查网络连接"
✅ 页面不崩溃
✅ 无 JavaScript 错误
修复前:
❌ 页面白屏崩溃
❌ Console 报错Cannot read property 'json' of null
```
**登录页面 - 微信登录**
```
测试步骤:
1. 在登录页面,保持断网状态
2. 点击"扫码登录"按钮
预期结果:
✅ 显示错误提示:"获取微信授权失败 - 网络请求失败,请检查网络连接"
✅ 页面不崩溃
✅ 无 JavaScript 错误
```
**注册页面 - 发送验证码**
```
测试步骤:
1. 打开 http://localhost:3000/auth/sign-up
2. 切换到"验证码注册"模式
3. 输入手机号13800138000
4. 保持断网状态
5. 点击"发送验证码"按钮
预期结果:
✅ 显示错误提示:"发送失败 - 网络请求失败..."
✅ 页面不崩溃
```
---
#### 2. **组件卸载测试** - 验证内存泄漏修复
**倒计时中离开页面**
```
测试步骤:
1. 恢复网络连接
2. 在登录页面输入手机号并发送验证码
3. 等待倒计时开始60秒倒计时
4. 立即点击浏览器后退按钮或切换到其他页面
5. 打开 Console 查看是否有警告
预期结果:
✅ 无警告:"Can't perform a React state update on an unmounted component"
✅ 倒计时定时器正确清理
✅ 无内存泄漏
修复前:
❌ Console 警告Memory leak warning
❌ setState 在组件卸载后仍被调用
```
**请求进行中离开页面**
```
测试步骤:
1. 在注册页面填写完整信息
2. 点击"注册"按钮
3. 在请求响应前loading 状态)快速刷新页面或关闭标签页
4. 打开新标签页查看 Console
预期结果:
✅ 无崩溃
✅ 无警告信息
✅ 请求被正确取消或忽略
```
**注册成功跳转前离开**
```
测试步骤:
1. 完成注册提交
2. 在显示"注册成功"提示后
3. 立即关闭标签页不等待2秒自动跳转
预期结果:
✅ 无警告
✅ navigate 不会在组件卸载后执行
```
---
#### 3. **边界情况测试** - 验证数据完整性检查
**后端返回空响应**
```
测试步骤(需要模拟后端):
1. 使用 Chrome DevTools → Network → 右键请求 → Edit and Resend
2. 修改响应为空对象 {}
3. 观察页面反应
预期结果:
✅ 显示错误:"服务器响应为空"
✅ 不会尝试访问 undefined 属性
✅ 页面不崩溃
```
**后端返回 500 错误**
```
测试步骤:
1. 在登录页面点击"扫码登录"
2. 如果后端返回 500 错误
预期结果:
✅ 显示错误:"获取二维码失败HTTP 500"
✅ 页面不崩溃
```
---
### 🧪 进阶测试(推荐)
#### 4. **弱网环境测试**
**慢速网络模拟**
```
测试步骤:
1. Chrome DevTools → Network → Throttling → Slow 3G
2. 尝试发送验证码
3. 等待 10 秒(超时时间)
预期结果:
✅ 10秒后显示超时错误
✅ 不会无限等待
✅ 用户可以重试
```
**丢包模拟**
```
测试步骤:
1. 使用 Chrome DevTools 模拟丢包
2. 连续点击"发送验证码"多次
预期结果:
✅ 每次请求都有适当的错误提示
✅ 不会因为并发请求而崩溃
✅ 按钮在请求期间正确禁用
```
---
#### 5. **定时器清理测试**
**倒计时清理验证**
```
测试步骤:
1. 在登录页面发送验证码
2. 等待倒计时到 50 秒
3. 快速切换到注册页面
4. 再切换回登录页面
5. 观察倒计时是否重置
预期结果:
✅ 定时器在页面切换时正确清理
✅ 返回登录页面时倒计时重新开始(如果再次发送)
✅ 没有多个定时器同时运行
```
---
#### 6. **并发请求测试**
**快速连续点击**
```
测试步骤:
1. 在登录页面输入手机号
2. 快速连续点击"发送验证码"按钮 5 次
预期结果:
✅ 只发送一次请求(按钮在请求期间禁用)
✅ 不会因为并发而崩溃
✅ 正确显示 loading 状态
```
---
## 🔍 监控指标
### Console 检查清单
在测试过程中,打开 Console (F12) 监控以下内容:
```
✅ 无红色错误Error
✅ 无内存泄漏警告Memory leak warning
✅ 无 setState 警告Can't perform a React state update...
✅ 无 undefined 访问错误Cannot read property of undefined
```
### Network 检查清单
打开 Network 标签监控:
```
✅ 请求超时时间10秒
✅ 失败请求有正确的错误处理
✅ 没有重复的请求
✅ 请求被正确取消(如果页面卸载)
```
### Performance 检查清单
打开 Performance 标签(可选):
```
✅ 无内存泄漏Memory 不会持续增长)
✅ 定时器正确清理Timer count 正确)
✅ EventListener 正确清理
```
---
## 📊 测试记录表
请在测试时填写以下表格:
| 测试项 | 状态 | 问题描述 | 截图 |
|--------|------|---------|------|
| 登录页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
| 登录页 - 断网微信登录 | ⬜ 通过 / ⬜ 失败 | | |
| 注册页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
| 倒计时中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
| 请求进行中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
| 注册成功跳转前离开 | ⬜ 通过 / ⬜ 失败 | | |
| 后端返回空响应 | ⬜ 通过 / ⬜ 失败 | | |
| 慢速网络超时 | ⬜ 通过 / ⬜ 失败 | | |
| 定时器清理 | ⬜ 通过 / ⬜ 失败 | | |
| 并发请求 | ⬜ 通过 / ⬜ 失败 | | |
---
## 🐛 如何报告问题
如果发现问题,请提供:
1. **测试场景**:具体的测试步骤
2. **预期结果**:应该发生什么
3. **实际结果**:实际发生了什么
4. **Console 错误**:完整的错误信息
5. **截图/录屏**:问题的视觉证明
6. **环境信息**
- 浏览器版本
- 操作系统
- 网络状态
---
## ✅ 测试完成检查
测试完成后,确认以下内容:
```
□ 所有关键测试通过
□ Console 无错误
□ Network 请求正常
□ 无内存泄漏警告
□ 用户体验流畅
```
---
## 🎯 快速测试命令
```bash
# 1. 确认服务器运行
curl http://localhost:3000
# 2. 打开浏览器测试
open http://localhost:3000/auth/sign-in
# 3. 查看编译日志
tail -f /tmp/react-build.log
```
---
## 📱 测试页面链接
- **登录页面**: http://localhost:3000/auth/sign-in
- **注册页面**: http://localhost:3000/auth/sign-up
- **首页**: http://localhost:3000/home
---
## 🔧 开发者工具快捷键
```
F12 - 打开开发者工具
Ctrl/Cmd+R - 刷新页面
Ctrl/Cmd+Shift+R - 强制刷新(清除缓存)
Ctrl/Cmd+Shift+C - 元素选择器
```
---
**测试时间**2025-10-14
**预计测试时长**15-30 分钟
**建议测试人员**:开发者 + QA
祝测试顺利!如发现问题请及时反馈。

File diff suppressed because it is too large Load Diff

339
index.pug Normal file
View File

@@ -0,0 +1,339 @@
extends layouts/layout
block content
+header(true, false, false)
<div class="overflow-hidden">
// hero
<div class="relative pt-58 pb-20 max-xl:pt-48 max-lg:pt-44 max-md:pt-21 max-md:pb-15">
<div class="center relative z-3" data-aos="fade">
<div class="max-w-187">
<div class="inline-flex items-center gap-2 mb-6 px-4 py-2 rounded-full bg-gradient-to-r from-green/20 to-green/5 border border-green/30 backdrop-blur-sm max-md:mb-3">
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0L9.798 5.579L15.708 4.292L11.854 8.854L15.708 13.416L9.798 12.129L8 18L6.202 12.421L0.292 13.708L4.146 9.146L0.292 4.584L6.202 5.871L8 0Z"/>
</svg>
<span class="text-title-5 text-green max-md:text-[14px]">金融AI技术领航者</span>
</div>
<div class="mb-8 text-big-title-1 bg-radial-white-1 bg-clip-text text-transparent max-xl:text-big-title-2 max-lg:text-title-1 max-lg:mb-10 max-md:mb-6 max-md:text-big-title-mobile">智能舆情分析系统</div>
<div class="flex flex-wrap gap-3 mb-8 max-lg:mb-6 max-md:mb-4">
<div class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-black/30 border border-line/50 backdrop-blur-sm">
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm0 14c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6zm3.5-6c0 1.9-1.6 3.5-3.5 3.5S4.5 9.9 4.5 8 6.1 4.5 8 4.5 11.5 6.1 11.5 8z"/>
</svg>
<span class="text-title-5 text-white/90 max-md:text-[13px]">深度数据挖掘</span>
</div>
<div class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-black/30 border border-line/50 backdrop-blur-sm">
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M13.5 2h-11C1.7 2 1 2.7 1 3.5v9c0 .8.7 1.5 1.5 1.5h11c.8 0 1.5-.7 1.5-1.5v-9c0-.8-.7-1.5-1.5-1.5zM8 11.5c-1.9 0-3.5-1.6-3.5-3.5S6.1 4.5 8 4.5s3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5z"/>
</svg>
<span class="text-title-5 text-white/90 max-md:text-[13px]">7×24小时监控</span>
</div>
</div>
<div class="max-w-94 mb-9.5 text-description max-lg:max-w-76 max-md:max-w-full max-md:mb-3.5">基于金融领域微调的大语言模型7×24小时不间断对舆情数据进行深度挖掘和分析对历史事件进行复盘关联相关标的为投资决策提供前瞻性的智能洞察。</div>
<div class="flex gap-7.5 max-md:mb-12.5">
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="wechat-app.jpg" title="微信小程序">
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M13.889 8.333c.31 0 .611.028.903.078C14.292 5.31 11.403 3 7.917 3 4.083 3 1 5.686 1 9.028c0 1.944 1.028 3.639 2.639 4.861L3 16.111l2.5-1.25c.833.194 1.528.333 2.417.333.278 0 .556-.014.833-.042-.278-.805-.417-1.652-.417-2.513 0-3.264 2.764-5.903 6.556-5.903v-.403zM10.139 6.528c.583 0 1.055.472 1.055 1.055s-.472 1.055-1.055 1.055-1.055-.472-1.055-1.055.472-1.055 1.055-1.055zM5.694 8.639c-.583 0-1.055-.472-1.055-1.055s.472-1.055 1.055-1.055 1.055.472 1.055 1.055-.472 1.055-1.055 1.055zm8.195 1.694c-2.847 0-5.139 2.014-5.139 4.486 0 2.472 2.292 4.486 5.139 4.486.764 0 1.528-.139 2.222-.347L18.333 20l-.625-1.875c1.25-.972 2.014-2.361 2.014-3.958 0-2.472-2.292-4.486-5.139-4.486h-.694zm-2.084 3.125c.389 0 .695.306.695.694s-.306.695-.695.695-.694-.306-.694-.695.305-.694.694-.694zm4.167 0c.389 0 .694.306.694.694s-.305.695-.694.695-.695-.306-.695-.695.306-.694.695-.694z"/>
</svg>
</a>
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="public.jpg" title="微信公众号">
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm3.889 6.944c.139 0 .278.014.417.028-1.306-2.958-4.723-5.027-8.611-5.027C2.611 1.945 0 4.306 0 7.222c0 1.528.806 2.861 2.083 3.819l-.417 1.945 1.945-.972c.639.139 1.167.25 1.861.25.222 0 .444-.014.667-.028-.222-.639-.333-1.306-.333-1.986 0-2.569 2.181-4.653 5.139-4.653l.944-.653zm-5.278-2.5c.458 0 .833.375.833.833s-.375.833-.833.833-.833-.375-.833-.833.375-.833.833-.833zM4.167 6.111c-.458 0-.833-.375-.833-.833s.375-.833.833-.833.833.375.833.833-.375.833-.833.833zm9.722 3.333c-2.236 0-4.028 1.583-4.028 3.528s1.792 3.528 4.028 3.528c.597 0 1.194-.111 1.736-.278l1.542.694-.486-1.472c.972-.764 1.597-1.861 1.597-3.125 0-1.945-1.792-3.528-4.028-3.528h-.361zm-1.667 2.5c.306 0 .556.25.556.556s-.25.556-.556.556-.556-.25-.556-.556.25-.556.556-.556zm3.334 0c.305 0 .555.25.555.556s-.25.556-.555.556-.556-.25-.556-.556.25-.556.556-.556z"/>
</svg>
</a>
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="customer-service.jpg" title="微信客服号">
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm4.861 7.222c.167 0 .333.014.5.028C14.097 4.444 11.139 2 7.5 2 3.889 2 1 4.444 1 7.5c0 1.778.972 3.333 2.5 4.444l-.5 2.223 2.222-1.111c.722.167 1.333.278 2.111.278.278 0 .556-.014.834-.028-.278-.722-.417-1.5-.417-2.306 0-2.972 2.5-5.389 5.833-5.389l1.278-.389zm-6.028-2.777c.528 0 .945.417.945.945s-.417.944-.945.944-.944-.416-.944-.944.416-.945.944-.945zm-4.166 1.888c-.528 0-.945-.416-.945-.944s.417-.945.945-.945.944.417.944.945-.416.944-.944.944zm10.277 3.611c-2.569 0-4.611 1.806-4.611 4.028s2.042 4.028 4.611 4.028c.694 0 1.389-.125 2-.306L19 18.889l-.556-1.667c1.111-.889 1.833-2.139 1.833-3.611 0-2.222-2.042-4.028-4.611-4.028h-.722zm-1.944 2.778c.361 0 .639.278.639.639s-.278.639-.639.639-.639-.278-.639-.639.278-.639.639-.639zm3.889 0c.361 0 .639.278.639.639s-.278.639-.639.639-.639-.278-.639-.639.278-.639.639-.639zM10 14.444c0 .306-.25.556-.556.556H6.111c-.306 0-.556-.25-.556-.556s.25-.555.556-.555h3.333c.306 0 .556.25.556.555z"/>
</svg>
</a>
</div>
<div class="absolute right-20 bottom-0 flex gap-4 max-xl:right-10 max-md:static">
<div class="relative w-42 p-5 pb-6.5 rounded-[1.25rem] bg-content text-center shadow-1 backdrop-blur-[1.25rem] max-md:px-3">
<div class="absolute inset-0 border border-line rounded-[1.25rem] pointer-events-none"></div>
<div class="relative flex justify-center items-center size-11 mx-auto mb-4 rounded-lg bg-gradient-to-b from-black/15 to-white/15 shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset,0_0_0.625rem_0_rgba(255,255,255,0.10)_inset]">
<div class="absolute inset-0 border border-line rounded-lg"></div>
img(class="w-5" src=require('Images/clock.svg') alt="")
</div>
<div class="text-title-4 max-md:text-title-3-mobile">实时数据分析</div>
</div>
<div class="relative w-42 p-5 pb-6.5 rounded-[1.25rem] bg-content text-center shadow-1 backdrop-blur-[1.25rem] max-md:px-3">
<div class="absolute inset-0 border border-line rounded-[1.25rem] pointer-events-none"></div>
<div class="relative flex justify-center items-center size-11 mx-auto mb-4 rounded-lg bg-gradient-to-b from-black/15 to-white/15 shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset,0_0_0.625rem_0_rgba(255,255,255,0.10)_inset]">
<div class="absolute inset-0 border border-line rounded-lg"></div>
img(class="w-5" src=require('Images/floor.svg') alt="")
</div>
<div class="text-title-4 max-md:text-title-3-mobile">低延迟推理</div>
</div>
</div>
</div>
</div>
<div class="absolute top-23 right-[calc(50%-28.5rem)] size-178 rounded-full max-xl:size-140 max-md:top-36 max-md:right-auto max-md:left-8.5 max-md:size-133">
<div class="absolute -inset-[10%] mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
</div>
<div class="absolute inset-0 rounded-full shadow-[0.875rem_1.0625rem_1.25rem_0_rgba(255,255,255,0.25)_inset] bg-black/1"></div>
</div>
<div>
<div class="absolute top-61.5 right-[calc(50%-35.18rem)] z-2 size-116.5 bg-green/20 rounded-full blur-[8rem] max-md:top-36 max-lg:-right-96 max-md:left-74 max-md:right-auto"></div>
<div class="absolute top-77 left-[calc(50%-57.5rem)] z-2 size-116.5 bg-green/20 rounded-full blur-[8rem] max-lg:-left-60 max-md:top-84 max-md:-left-52 max-md:size-80"></div>
</div>
</div>
// details
<div class="pt-40.5 pb-30.5 max-xl:pt-30 max-lg:py-24 max-md:py-15">
<div class="center">
<div class="flex flex-wrap -mt-4 -mx-2">
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex w-[calc(50%-1rem)] h-full mt-4 mx-2 pt-6 pb-7 px-8.5 max-xl:px-6 max-lg:w-[calc(100%-1rem)] max-md:px-8 max-md:min-h-112.5" data-aos="fade">
<div class="relative z-2 max-w-58 flex flex-col max-md:max-w-full">
<div class="mb-auto bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:mb-0.5 max-md:text-title-1-mobile">99%</div>
<div class="mt-3 text-title-4 max-md:text-title-3-mobile">金融数据理解准确率</div>
<div class="mt-2.5 text-description max-md:mt-2">基于金融领域深度微调的大语言模型,精准理解市场动态和舆情变化。</div>
</div>
<div class="absolute top-0 right-0 bottom-0 flex items-center max-2xl:-right-16 max-lg:right-0 max-md:top-auto max-md:left-0 max-md:pl-7.5">
img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-1.png') alt="")
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex w-[calc(50%-1rem)] h-full mt-4 mx-2 pt-6 pb-7 px-8.5 max-xl:px-6 max-lg:w-[calc(100%-1rem)] max-md:px-8 max-md:min-h-112.5" data-aos="fade">
<div class="relative z-2 max-w-58 flex flex-col max-md:max-w-full">
<div class="mb-auto bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:mb-0.5 max-md:text-title-1-mobile">24/7</div>
<div class="mt-3 text-title-4 max-md:text-title-3-mobile">全天候舆情监控</div>
<div class="mt-2.5 text-description max-md:mt-2">7×24小时不间断监控市场舆情第一时间捕捉关键信息。</div>
</div>
<div class="absolute top-0 right-0 bottom-0 flex items-center max-2xl:-right-16 max-lg:right-0 max-md:top-auto max-md:left-0 max-md:pl-7.5">
img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-2.png') alt="")
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end w-62.5 mt-4 mx-2 px-8.5 pb-7 max-xl:px-6 max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)] max-md:min-h-72 max-md:px-7 max-md:pb-6" data-aos="fade">
<div class="absolute top-0 left-0 right-0 flex justify-center">
img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-3.png') alt="")
</div>
<div class="relative z-2 max-w-58 flex flex-col">
<div class="mb-2.5 text-title-4 max-md:mb-1.5 max-md:text-title-3-mobile">深度模型微调</div>
<div class="text-description">针对金融领域数据进行专业化模型训练和优化。</div>
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end grow mt-4 mx-2 px-8.5 pb-7 overflow-hidden max-xl:px-6 max-lg:order-5" data-aos="fade">
<div class="absolute top-0 left-0 flex justify-center max-2xl:top-8 max-lg:top-0 max-md:-left-3 max-md:w-176">
img(class="w-full" src=require('Images/details-pic-4.png') alt="")
</div>
<div class="relative z-2 max-w-58 flex flex-col">
<div class="flex items-center gap-3 mb-3">
<div class="relative flex justify-center items-center shrink-0 w-12.5 h-12.5 rounded-lg bg-gradient-to-b from-[#F4D03F] to-[#D4AF37] shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(212,175,55,0.30)_inset,_0_0_0.625rem_0_rgba(212,175,55,0.50)_inset] after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none">
img(class="w-4" src=require('Images/lightning.svg') alt="")
</div>
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-2 leading-tight max-xl:text-title-2 max-md:text-title-1-mobile">&lt;100ms</div>
</div>
<div class="text-title-4 max-md:text-title-3-mobile">低延迟推理系统</div>
<div class="mt-2.5 text-description max-md:mt-2">毫秒级响应速度,实时处理海量舆情数据。</div>
</div>
</div>
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end w-62.5 mt-4 mx-2 px-8.5 pb-7 max-xl:px-6 max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)] max-md:min-h-72 max-md:px-7 max-md:pb-6" data-aos="fade">
<div class="absolute top-0 left-0 right-0 flex justify-center">
img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-5.png') alt="")
</div>
<div class="relative z-2 max-w-58 flex flex-col">
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:text-title-1-mobile">历史复盘</div>
<div class="text-description">对历史事件进行深度复盘分析,关联标的,辅助投资决策。</div>
</div>
</div>
</div>
</div>
</div>
// features
<div class="relative pt-34.5 pb-41 max-xl:pt-20 max-xl:pb-30 max-lg:py-24 max-md:pt-15 max-md:pb-14">
<div class="center relative z-2">
<div class="max-w-148 mx-auto mb-18 text-center max-xl:mb-14 max-md:mb-8.5" data-aos="fade">
<div class="label mb-3 max-md:mb-1">核心功能</div>
<div class="mb-6 bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:mb-3 max-md:text-title-1-mobile">我们能做什么?</div>
<div class="text-description">基于AI的舆情分析系统深度挖掘市场动态为投资决策提供实时智能洞察。</div>
</div>
<div class="flex flex-wrap -mt-4 -mx-2">
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-1.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">舆情数据挖掘</div>
<div class="text-description">实时采集和分析全网金融舆情,捕捉市场情绪变化。</div>
</div>
</div>
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-2.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">智能事件关联</div>
<div class="text-description">自动关联相关标的和历史事件,构建完整的信息图谱。</div>
</div>
</div>
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-3.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">历史复盘</div>
<div class="text-description">深度复盘历史事件走势,洞察关键节点与转折,为投资决策提供经验参考。</div>
</div>
</div>
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
<div class="max-md:text-center">
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-4.png') alt="")
</div>
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">专精金融的AI聊天</div>
<div class="text-description">基于金融领域深度训练的智能对话助手,即时解答市场问题,提供专业投资建议。</div>
</div>
</div>
</div>
</div>
<div class="max-md:hidden">
<div class="absolute top-47.5 left-[calc(50%-52.38rem)] size-98.5 bg-gold/15 rounded-full blur-[6.75rem]"></div>
<div class="absolute bottom-2.5 right-[calc(50%-51.44rem)] size-98.5 bg-gold/15 rounded-full blur-[6.75rem]"></div>
</div>
</div>
// pricing
<div class="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15" id="pricing">
<div class="center">
<div class="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8" data-aos="fade">
<div class="label mb-3 max-md:mb-1.5">订阅方案</div>
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">立即开启智能决策</div>
</div>
<div class="flex justify-center 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" data-aos="fade">
<div class="relative flex flex-col flex-1 max-w-md rounded-[1.25rem] overflow-hidden shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] 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">
<div class="relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 text-white">PRO</div>
<div class="relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 backdrop-blur-[1.25rem] bg-white/1 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none">
<div class="relative mb-8 p-5 rounded-[0.8125rem] bg-white/2 backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none">
<div class="flex items-end gap-3 mb-4">
<div class="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]">¥198</div>
<div class="text-title-5">/月</div>
</div>
<a class="btn btn-secondary w-full bg-line !text-description hover:!text-white" href="https://valuefrontier.cn/home/pages/account/subscription" target="_blank">选择Pro版</a>
</div>
<div class="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>事件关联股票深度分析</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>历史事件智能对比复盘</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>事件概念关联与挖掘</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>概念板块个股追踪</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>概念深度研报与解读</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>个股异动实时预警</div>
</div>
</div>
</div>
</div>
<div class="relative flex flex-col flex-1 max-w-md rounded-[1.25rem] overflow-hidden 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-gold/15 before:rounded-full before:blur-[3.375rem] after:absolute after:inset-0 after:border after:border-gold/30 after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84">
<div class="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
</div>
<div class="relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 bg-gradient-to-r from-gold-dark/20 to-gold/20 rounded-t-[1.25rem] text-gold">MAX</div>
<div class="relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 backdrop-blur-[2rem] shadow-2 bg-white/7 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none">
<div class="relative mb-8 p-5 rounded-[0.8125rem] bg-line backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none">
<div class="flex items-end gap-3 mb-4">
<div class="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]">¥998</div>
<div class="text-title-5">/月</div>
</div>
<a class="btn btn-primary w-full" href="https://valuefrontier.cn/home/pages/account/subscription" target="_blank">选择Max版</a>
</div>
<div class="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="flex justify-center items-center shrink-0 size-5 bg-gold rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(212,175,55,0.30)_inset,_0_0_0.625rem_0_rgba(212,175,55,0.50)_inset]">
<svg class="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>
<div class="font-medium">包含Pro版全部功能</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>事件传导链路智能分析</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>概念演变时间轴追溯</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>个股全方位深度研究</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>价小前投研助手无限使用</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>新功能优先体验权</div>
</div>
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
<div class="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 class="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>
<div>专属客服一对一服务</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
include includes/start
</div>
+footer(true)

631
new_subscription_logic.py Normal file
View File

@@ -0,0 +1,631 @@
# -*- coding: utf-8 -*-
"""
新版订阅支付系统核心逻辑
版本: v2.0.0
日期: 2025-11-19
核心改进:
1. 续费价格与新购价格完全一致
2. 不再计算剩余价值折算
3. 逻辑简化,易于维护
"""
from datetime import datetime, timedelta
from decimal import Decimal
import json
import random
# ============================================
# 辅助函数
# ============================================
def beijing_now():
"""获取北京时间"""
from datetime import timezone, timedelta
utc_now = datetime.now(timezone.utc)
beijing_time = utc_now.astimezone(timezone(timedelta(hours=8)))
return beijing_time.replace(tzinfo=None)
def generate_order_no(user_id):
"""生成订单号"""
timestamp = int(beijing_now().timestamp() * 1000000)
random_suffix = random.randint(1000, 9999)
return f"{timestamp}{user_id:04d}{random_suffix}"
def generate_subscription_id():
"""生成订阅ID"""
timestamp = int(beijing_now().timestamp() * 1000)
random_suffix = random.randint(10000, 99999)
return f"SUB_{timestamp}_{random_suffix}"
# ============================================
# 核心业务逻辑
# ============================================
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None, user_id=None, db_session=None):
"""
计算订阅价格(新购和续费价格完全一致)
Args:
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
promo_code: 优惠码(可选)
user_id: 用户ID可选,用于优惠码验证)
db_session: 数据库会话(可选)
Returns:
dict: {
'success': True/False,
'plan_code': 'pro',
'plan_name': 'Pro 专业版',
'billing_cycle': 'yearly',
'original_price': 2699.00,
'discount_amount': 0,
'final_amount': 2699.00,
'promo_code': None,
'promo_error': None,
'error': None # 如果有错误
}
"""
from models import SubscriptionPlan, PromoCode # 需要在实际使用时导入
try:
# 1. 查询套餐
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
if not plan:
return {
'success': False,
'error': f'套餐 {plan_code} 不存在或已下架'
}
# 2. 获取对应周期的价格
price_field_map = {
'monthly': 'price_monthly',
'quarterly': 'price_quarterly',
'semiannual': 'price_semiannual',
'yearly': 'price_yearly'
}
price_field = price_field_map.get(billing_cycle)
if not price_field:
return {
'success': False,
'error': f'不支持的计费周期: {billing_cycle}'
}
original_price = getattr(plan, price_field, None)
if original_price is None or original_price <= 0:
return {
'success': False,
'error': f'{billing_cycle} 周期价格未配置'
}
original_price = float(original_price)
# 3. 构建基础结果
result = {
'success': True,
'plan_code': plan_code,
'plan_name': plan.plan_name,
'billing_cycle': billing_cycle,
'original_price': original_price,
'discount_amount': 0.0,
'final_amount': original_price,
'promo_code': None,
'promo_error': None,
'error': None
}
# 4. 应用优惠码(如果有)
if promo_code and promo_code.strip():
promo_code = promo_code.strip().upper()
# 验证优惠码
promo, error = validate_promo_code(
promo_code,
plan_code,
billing_cycle,
original_price,
user_id,
db_session
)
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
except Exception as e:
return {
'success': False,
'error': f'价格计算失败: {str(e)}'
}
def get_current_subscription(user_id, db_session=None):
"""
获取用户当前生效的订阅
Args:
user_id: 用户ID
db_session: 数据库会话(可选)
Returns:
UserSubscription 对象 或 None
"""
from models import UserSubscription
try:
subscription = UserSubscription.query.filter_by(
user_id=user_id,
is_current=True
).first()
# 检查是否过期
if subscription and subscription.end_date < beijing_now():
subscription.status = 'expired'
subscription.is_current = False
if db_session:
db_session.commit()
return None
return subscription
except Exception as e:
print(f"获取当前订阅失败: {e}")
return None
def determine_subscription_type(user_id, plan_code, billing_cycle):
"""
判断订阅类型(新购还是续费)
Args:
user_id: 用户ID
plan_code: 目标套餐代码
billing_cycle: 目标计费周期
Returns:
str: 'new''renew'
"""
current_sub = get_current_subscription(user_id)
# 如果没有订阅或订阅是免费版,则为新购
if not current_sub or current_sub.plan_code == 'free':
return 'new'
# 如果是付费订阅,则为续费
if current_sub.plan_code in ['pro', 'max']:
return 'renew'
return 'new'
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None, db_session=None):
"""
创建订阅支付订单
Args:
user_id: 用户ID
plan_code: 套餐代码
billing_cycle: 计费周期
promo_code: 优惠码(可选)
db_session: 数据库会话
Returns:
dict: {
'success': True/False,
'order': PaymentOrder 对象,
'error': None
}
"""
from models import PaymentOrder
try:
# 1. 计算价格
price_result = calculate_subscription_price(
plan_code,
billing_cycle,
promo_code,
user_id,
db_session
)
if not price_result.get('success'):
return {
'success': False,
'error': price_result.get('error', '价格计算失败')
}
# 2. 判断订阅类型
subscription_type = determine_subscription_type(user_id, plan_code, billing_cycle)
# 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=Decimal(str(price_result['original_price'])),
discount_amount=Decimal(str(price_result['discount_amount'])),
final_amount=Decimal(str(price_result['final_amount'])),
promo_code=promo_code,
status='pending',
expired_at=beijing_now() + timedelta(minutes=30)
)
if db_session:
db_session.add(order)
db_session.commit()
return {
'success': True,
'order': order,
'subscription_type': subscription_type,
'error': None
}
except Exception as e:
if db_session:
db_session.rollback()
return {
'success': False,
'error': f'创建订单失败: {str(e)}'
}
def activate_subscription_after_payment(order_id, db_session=None):
"""
支付成功后激活订阅
Args:
order_id: 订单ID
db_session: 数据库会话
Returns:
dict: {
'success': True/False,
'subscription': UserSubscription 对象,
'error': None
}
"""
from models import PaymentOrder, UserSubscription, PromoCodeUsage
try:
# 1. 查询订单
order = PaymentOrder.query.get(order_id)
if not order:
return {'success': False, 'error': '订单不存在'}
if order.status != 'paid':
return {'success': False, 'error': '订单未支付'}
# 2. 检查是否已经激活
existing_sub = UserSubscription.query.filter_by(
payment_order_id=order.id
).first()
if existing_sub:
return {
'success': True,
'subscription': existing_sub,
'message': '订阅已激活'
}
# 3. 计算订阅周期天数
cycle_days_map = {
'monthly': 30,
'quarterly': 90,
'semiannual': 180,
'yearly': 365
}
days = cycle_days_map.get(order.billing_cycle, 30)
# 4. 获取当前订阅
current_sub = get_current_subscription(order.user_id, db_session)
# 5. 计算开始和结束时间
now = beijing_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)
# 6. 创建新订阅记录
new_subscription = UserSubscription(
user_id=order.user_id,
subscription_id=generate_subscription_id(),
plan_code=order.plan_code,
billing_cycle=order.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,
auto_renew=False
)
# 7. 将旧订阅标记为非当前
if current_sub:
current_sub.is_current = False
if db_session:
db_session.add(new_subscription)
# 8. 记录优惠码使用
if order.promo_code_id:
usage = PromoCodeUsage(
promo_code_id=order.promo_code_id,
user_id=order.user_id,
order_id=order.id,
discount_amount=order.discount_amount
)
db_session.add(usage)
# 更新优惠码使用次数
from models import PromoCode
promo = PromoCode.query.get(order.promo_code_id)
if promo:
promo.current_uses += 1
db_session.commit()
return {
'success': True,
'subscription': new_subscription,
'error': None
}
except Exception as e:
if db_session:
db_session.rollback()
return {
'success': False,
'error': f'激活订阅失败: {str(e)}'
}
def get_subscription_button_text(user_id, plan_code, billing_cycle):
"""
获取订阅按钮文字
Args:
user_id: 用户ID
plan_code: 套餐代码 (pro/max)
billing_cycle: 计费周期
Returns:
str: 按钮文字
"""
from models import SubscriptionPlan
# 获取套餐显示名称
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code).first()
plan_name = plan.plan_name if plan else plan_code.upper()
# 获取周期显示名称
cycle_names = {
'monthly': '月付',
'quarterly': '季付',
'semiannual': '半年付',
'yearly': '年付'
}
cycle_name = cycle_names.get(billing_cycle, billing_cycle)
# 获取当前订阅
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"选择 {plan_name}"
# 2. 如果是当前套餐且周期相同
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
return f"续费 {plan_name}"
# 3. 如果是当前套餐但周期不同
if current_sub.plan_code == plan_code:
return f"切换至{cycle_name}"
# 4. 如果是不同套餐
return f"选择 {plan_name}"
# ============================================
# 优惠码相关函数
# ============================================
def validate_promo_code(code, plan_code, billing_cycle, amount, user_id=None, db_session=None):
"""
验证优惠码
Args:
code: 优惠码
plan_code: 套餐代码
billing_cycle: 计费周期
amount: 订单金额
user_id: 用户ID可选
db_session: 数据库会话(可选)
Returns:
tuple: (PromoCode对象 或 None, 错误信息 或 None)
"""
from models import PromoCode, PromoCodeUsage
try:
# 查询优惠码
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
if not promo:
return None, "优惠码不存在或已失效"
# 检查有效期
now = beijing_now()
if promo.valid_from and now < promo.valid_from:
return None, "优惠码尚未生效"
if promo.valid_until and now > promo.valid_until:
return None, "优惠码已过期"
# 检查总使用次数
if promo.max_total_uses and promo.current_uses >= promo.max_total_uses:
return None, "优惠码使用次数已达上限"
# 检查每用户使用次数
if user_id and promo.max_uses_per_user:
user_usage_count = PromoCodeUsage.query.filter_by(
promo_code_id=promo.id,
user_id=user_id
).count()
if user_usage_count >= promo.max_uses_per_user:
return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
# 检查适用套餐
if promo.applicable_plans:
try:
applicable = json.loads(promo.applicable_plans)
if isinstance(applicable, list) and plan_code not in applicable:
return None, "该优惠码不适用于此套餐"
except:
pass
# 检查适用周期
if promo.applicable_cycles:
try:
applicable = json.loads(promo.applicable_cycles)
if isinstance(applicable, list) and billing_cycle not in applicable:
return None, "该优惠码不适用于此计费周期"
except:
pass
# 检查最低消费
if promo.min_amount and amount < float(promo.min_amount):
return None, f"需满 ¥{float(promo.min_amount):.2f} 才可使用此优惠码"
return promo, None
except Exception as e:
return None, f"验证优惠码时出错: {str(e)}"
def calculate_discount(promo_code, amount):
"""
计算优惠金额
Args:
promo_code: PromoCode 对象
amount: 订单金额
Returns:
Decimal: 优惠金额
"""
try:
if promo_code.discount_type == 'percentage':
# 百分比折扣
discount = Decimal(str(amount)) * Decimal(str(promo_code.discount_value)) / Decimal('100')
elif promo_code.discount_type == 'fixed_amount':
# 固定金额折扣
discount = Decimal(str(promo_code.discount_value))
else:
discount = Decimal('0')
# 确保折扣不超过总金额
discount = min(discount, Decimal(str(amount)))
return discount
except Exception as e:
print(f"计算折扣失败: {e}")
return Decimal('0')
# ============================================
# 辅助查询函数
# ============================================
def get_user_subscription_history(user_id, limit=10):
"""
获取用户订阅历史
Args:
user_id: 用户ID
limit: 返回记录数量限制
Returns:
list: UserSubscription 对象列表
"""
from models import UserSubscription
try:
subscriptions = UserSubscription.query.filter_by(
user_id=user_id
).order_by(
UserSubscription.created_at.desc()
).limit(limit).all()
return subscriptions
except Exception as e:
print(f"获取订阅历史失败: {e}")
return []
def check_subscription_status(user_id):
"""
检查用户订阅状态
Args:
user_id: 用户ID
Returns:
dict: {
'has_subscription': True/False,
'plan_code': 'pro''max''free',
'status': 'active''expired',
'end_date': datetime 或 None,
'days_left': int
}
"""
current_sub = get_current_subscription(user_id)
if not current_sub or current_sub.plan_code == 'free':
return {
'has_subscription': False,
'plan_code': 'free',
'status': 'active',
'end_date': None,
'days_left': 999
}
now = beijing_now()
days_left = (current_sub.end_date - now).days if current_sub.end_date > now else 0
return {
'has_subscription': True,
'plan_code': current_sub.plan_code,
'status': current_sub.status,
'end_date': current_sub.end_date,
'days_left': days_left
}

669
new_subscription_routes.py Normal file
View File

@@ -0,0 +1,669 @@
# -*- coding: utf-8 -*-
"""
新版订阅支付系统 API 路由
版本: v2.0.0
日期: 2025-11-19
使用方法:
将这些路由添加到你的 Flask app.py 中
"""
from flask import jsonify, request, session
from new_subscription_logic import (
calculate_subscription_price,
create_subscription_order,
activate_subscription_after_payment,
get_subscription_button_text,
get_current_subscription,
check_subscription_status,
get_user_subscription_history
)
# ============================================
# API 路由定义
# ============================================
@app.route('/api/v2/subscription/plans', methods=['GET'])
def get_subscription_plans_v2():
"""
获取订阅套餐列表(新版)
Response:
{
"success": true,
"data": [
{
"plan_code": "pro",
"plan_name": "Pro 专业版",
"description": "为专业投资者打造",
"prices": {
"monthly": 299.00,
"quarterly": 799.00,
"semiannual": 1499.00,
"yearly": 2699.00
},
"features": [...],
"is_active": true
},
...
]
}
"""
try:
from models import SubscriptionPlan
plans = SubscriptionPlan.query.filter_by(is_active=True).order_by(
SubscriptionPlan.display_order
).all()
data = []
for plan in plans:
data.append({
'plan_code': plan.plan_code,
'plan_name': plan.plan_name,
'description': plan.description,
'prices': {
'monthly': float(plan.price_monthly),
'quarterly': float(plan.price_quarterly),
'semiannual': float(plan.price_semiannual),
'yearly': float(plan.price_yearly)
},
'features': json.loads(plan.features) if plan.features else [],
'is_active': plan.is_active,
'display_order': plan.display_order
})
return jsonify({
'success': True,
'data': data
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取套餐列表失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/calculate-price', methods=['POST'])
def calculate_price_v2():
"""
计算订阅价格(新版 - 新购和续费价格一致)
Request Body:
{
"plan_code": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
Response:
{
"success": true,
"data": {
"plan_code": "pro",
"plan_name": "Pro 专业版",
"billing_cycle": "yearly",
"original_price": 2699.00,
"discount_amount": 539.80,
"final_amount": 2159.20,
"promo_code": "WELCOME2025",
"promo_error": null
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
plan_code = data.get('plan_code')
billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code')
if not plan_code or not billing_cycle:
return jsonify({
'success': False,
'error': '参数不完整'
}), 400
# 计算价格
result = calculate_subscription_price(
plan_code=plan_code,
billing_cycle=billing_cycle,
promo_code=promo_code,
user_id=session['user_id'],
db_session=db.session
)
if not result.get('success'):
return jsonify(result), 400
return jsonify({
'success': True,
'data': result
})
except Exception as e:
return jsonify({
'success': False,
'error': f'计算价格失败: {str(e)}'
}), 500
@app.route('/api/v2/payment/create-order', methods=['POST'])
def create_order_v2():
"""
创建支付订单(新版)
Request Body:
{
"plan_code": "pro",
"billing_cycle": "yearly",
"promo_code": "WELCOME2025" // 可选
}
Response:
{
"success": true,
"data": {
"order_no": "1732012345678901231234",
"plan_code": "pro",
"billing_cycle": "yearly",
"subscription_type": "renew", // 或 "new"
"original_price": 2699.00,
"discount_amount": 539.80,
"final_amount": 2159.20,
"qr_code_url": "https://...",
"status": "pending",
"expired_at": "2025-11-19T15:30:00",
...
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
data = request.get_json()
plan_code = data.get('plan_code')
billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code')
if not plan_code or not billing_cycle:
return jsonify({
'success': False,
'error': '参数不完整'
}), 400
# 创建订单
order_result = create_subscription_order(
user_id=session['user_id'],
plan_code=plan_code,
billing_cycle=billing_cycle,
promo_code=promo_code,
db_session=db.session
)
if not order_result.get('success'):
return jsonify({
'success': False,
'error': order_result.get('error')
}), 400
order = order_result['order']
# 生成微信支付二维码
try:
from wechat_pay import create_wechat_pay_instance, check_wechat_pay_ready
is_ready, ready_msg = check_wechat_pay_ready()
if not is_ready:
# 使用模拟二维码
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
order.remark = f"演示模式 - {ready_msg}"
else:
wechat_pay = create_wechat_pay_instance()
# 创建微信支付订单
plan_display = f"{plan_code.upper()}-{billing_cycle}"
wechat_result = wechat_pay.create_native_order(
order_no=order.order_no,
total_fee=float(order.final_amount),
body=f"VFr-{plan_display}",
product_id=f"{plan_code}_{billing_cycle}"
)
if wechat_result['success']:
wechat_code_url = wechat_result['code_url']
import urllib.parse
encoded_url = urllib.parse.quote(wechat_code_url, safe='')
qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded_url}"
order.qr_code_url = qr_image_url
order.prepay_id = wechat_result.get('prepay_id')
order.remark = f"微信支付 - {wechat_code_url}"
else:
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
order.remark = f"微信支付失败: {wechat_result.get('error')}"
except Exception as e:
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
order.remark = f"支付异常: {str(e)}"
db.session.commit()
return jsonify({
'success': True,
'data': {
'id': order.id,
'order_no': order.order_no,
'plan_code': order.plan_code,
'billing_cycle': order.billing_cycle,
'subscription_type': order.subscription_type,
'original_price': float(order.original_price),
'discount_amount': float(order.discount_amount),
'final_amount': float(order.final_amount),
'promo_code': order.promo_code,
'qr_code_url': order.qr_code_url,
'status': order.status,
'expired_at': order.expired_at.isoformat() if order.expired_at else None,
'created_at': order.created_at.isoformat() if order.created_at else None
},
'message': '订单创建成功'
})
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': f'创建订单失败: {str(e)}'
}), 500
@app.route('/api/v2/payment/order/<int:order_id>/status', methods=['GET'])
def check_order_status_v2(order_id):
"""
查询订单支付状态(新版)
Response:
{
"success": true,
"payment_success": true, // 是否支付成功
"data": {
"order_no": "...",
"status": "paid",
...
},
"message": "支付成功!订阅已激活"
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
from models import PaymentOrder
order = PaymentOrder.query.filter_by(
id=order_id,
user_id=session['user_id']
).first()
if not order:
return jsonify({'success': False, 'error': '订单不存在'}), 404
# 如果订单已经是已支付状态
if order.status == 'paid':
return jsonify({
'success': True,
'payment_success': True,
'data': {
'order_no': order.order_no,
'status': order.status,
'final_amount': float(order.final_amount)
},
'message': '订单已支付'
})
# 如果订单过期
if order.is_expired():
order.status = 'expired'
db.session.commit()
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': 'expired'},
'message': '订单已过期'
})
# 调用微信支付API查询状态
try:
from wechat_pay import create_wechat_pay_instance
wechat_pay = create_wechat_pay_instance()
query_result = wechat_pay.query_order(order_no=order.order_no)
if query_result['success']:
trade_state = query_result.get('trade_state')
transaction_id = query_result.get('transaction_id')
if trade_state == 'SUCCESS':
# 支付成功
order.mark_as_paid(transaction_id)
db.session.commit()
# 激活订阅
activate_result = activate_subscription_after_payment(
order.id,
db_session=db.session
)
if activate_result.get('success'):
return jsonify({
'success': True,
'payment_success': True,
'data': {
'order_no': order.order_no,
'status': 'paid'
},
'message': '支付成功!订阅已激活'
})
else:
return jsonify({
'success': True,
'payment_success': True,
'data': {'status': 'paid'},
'message': '支付成功,但激活失败,请联系客服'
})
elif trade_state in ['NOTPAY', 'USERPAYING']:
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': 'pending'},
'message': '等待支付...'
})
else:
order.status = 'cancelled'
db.session.commit()
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': 'cancelled'},
'message': '支付已取消'
})
except Exception as e:
# 查询失败,返回当前状态
pass
return jsonify({
'success': True,
'payment_success': False,
'data': {'status': order.status},
'message': '无法查询支付状态,请稍后重试'
})
except Exception as e:
return jsonify({
'success': False,
'error': f'查询失败: {str(e)}'
}), 500
@app.route('/api/v2/payment/order/<int:order_id>/force-update', methods=['POST'])
def force_update_status_v2(order_id):
"""
强制更新订单支付状态(新版)
用于支付完成但页面未更新的情况
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
from models import PaymentOrder
order = PaymentOrder.query.filter_by(
id=order_id,
user_id=session['user_id']
).first()
if not order:
return jsonify({'success': False, 'error': '订单不存在'}), 404
# 检查微信支付状态
try:
from wechat_pay import create_wechat_pay_instance
wechat_pay = create_wechat_pay_instance()
query_result = wechat_pay.query_order(order_no=order.order_no)
if query_result['success'] and query_result.get('trade_state') == 'SUCCESS':
transaction_id = query_result.get('transaction_id')
# 标记订单为已支付
order.mark_as_paid(transaction_id)
db.session.commit()
# 激活订阅
activate_result = activate_subscription_after_payment(
order.id,
db_session=db.session
)
if activate_result.get('success'):
return jsonify({
'success': True,
'payment_success': True,
'message': '状态更新成功!订阅已激活'
})
else:
return jsonify({
'success': True,
'payment_success': True,
'message': '支付成功,但激活失败,请联系客服',
'error': activate_result.get('error')
})
else:
return jsonify({
'success': True,
'payment_success': False,
'message': '微信支付状态未更新,请继续等待'
})
except Exception as e:
return jsonify({
'success': False,
'error': f'查询微信支付状态失败: {str(e)}'
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': f'强制更新失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/current', methods=['GET'])
def get_current_subscription_v2():
"""
获取当前用户订阅信息(新版)
Response:
{
"success": true,
"data": {
"subscription_id": "SUB_1732012345_12345",
"plan_code": "pro",
"plan_name": "Pro 专业版",
"billing_cycle": "yearly",
"status": "active",
"start_date": "2025-11-19T00:00:00",
"end_date": "2026-11-19T00:00:00",
"days_left": 365,
"auto_renew": false
}
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
from models import SubscriptionPlan
subscription = get_current_subscription(session['user_id'])
if not subscription:
return jsonify({
'success': True,
'data': {
'plan_code': 'free',
'plan_name': '免费版',
'status': 'active'
}
})
# 获取套餐名称
plan = SubscriptionPlan.query.filter_by(plan_code=subscription.plan_code).first()
plan_name = plan.plan_name if plan else subscription.plan_code.upper()
# 计算剩余天数
from datetime import datetime
now = datetime.now()
days_left = (subscription.end_date - now).days if subscription.end_date > now else 0
return jsonify({
'success': True,
'data': {
'subscription_id': subscription.subscription_id,
'plan_code': subscription.plan_code,
'plan_name': plan_name,
'billing_cycle': subscription.billing_cycle,
'status': subscription.status,
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
'days_left': days_left,
'auto_renew': subscription.auto_renew
}
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取订阅信息失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/history', methods=['GET'])
def get_subscription_history_v2():
"""
获取用户订阅历史(新版)
Query Params:
limit: 返回记录数量默认10
Response:
{
"success": true,
"data": [
{
"subscription_id": "SUB_...",
"plan_code": "pro",
"billing_cycle": "yearly",
"start_date": "...",
"end_date": "...",
"paid_amount": 2699.00,
"status": "expired"
},
...
]
}
"""
try:
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
limit = request.args.get('limit', 10, type=int)
subscriptions = get_user_subscription_history(session['user_id'], limit)
data = []
for sub in subscriptions:
data.append({
'subscription_id': sub.subscription_id,
'plan_code': sub.plan_code,
'billing_cycle': sub.billing_cycle,
'start_date': sub.start_date.isoformat() if sub.start_date else None,
'end_date': sub.end_date.isoformat() if sub.end_date else None,
'paid_amount': float(sub.paid_amount),
'original_price': float(sub.original_price),
'discount_amount': float(sub.discount_amount),
'status': sub.status,
'created_at': sub.created_at.isoformat() if sub.created_at else None
})
return jsonify({
'success': True,
'data': data
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取订阅历史失败: {str(e)}'
}), 500
@app.route('/api/v2/subscription/button-text', methods=['POST'])
def get_button_text_v2():
"""
获取订阅按钮文字(新版)
Request Body:
{
"plan_code": "pro",
"billing_cycle": "yearly"
}
Response:
{
"success": true,
"button_text": "续费 Pro 专业版"
}
"""
try:
if 'user_id' not in session:
return jsonify({
'success': True,
'button_text': '选择套餐'
})
data = request.get_json()
plan_code = data.get('plan_code')
billing_cycle = data.get('billing_cycle')
if not plan_code or not billing_cycle:
return jsonify({
'success': False,
'error': '参数不完整'
}), 400
button_text = get_subscription_button_text(
session['user_id'],
plan_code,
billing_cycle
)
return jsonify({
'success': True,
'button_text': button_text
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取按钮文字失败: {str(e)}'
}), 500

View File

@@ -6,9 +6,9 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@asseinfo/react-kanban": "^2.2.0", "@asseinfo/react-kanban": "^2.2.0",
"@chakra-ui/icons": "^2.1.1", "@chakra-ui/icons": "^2.2.6",
"@chakra-ui/react": "^2.8.2", "@chakra-ui/react": "^2.10.9",
"@chakra-ui/theme-tools": "^1.3.6", "@chakra-ui/theme-tools": "^2.2.6",
"@emotion/cache": "^11.4.0", "@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.0", "@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
@@ -29,6 +29,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"dayjs": "^1.11.19",
"draft-js": "^0.11.7", "draft-js": "^0.11.7",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
@@ -39,9 +40,8 @@
"history": "^5.3.0", "history": "^5.3.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"match-sorter": "6.3.0", "match-sorter": "6.3.0",
"moment": "^2.29.1",
"nouislider": "15.0.0", "nouislider": "15.0.0",
"posthog-js": "^1.281.0", "posthog-js": "^1.295.0",
"react": "18.3.1", "react": "18.3.1",
"react-apexcharts": "^1.3.9", "react-apexcharts": "^1.3.9",
"react-big-calendar": "^0.33.2", "react-big-calendar": "^0.33.2",

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" dir="ltr" layout="admin"> <html lang="zh-CN" dir="ltr" layout="admin">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
@@ -7,6 +7,177 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no" content="width=device-width, initial-scale=1, shrink-to-fit=no"
/> />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<!-- 基本 SEO -->
<title>价值前沿 - 金融AI舆情分析系统 | LLM赋能的智能分析平台</title>
<meta name="description" content="基于金融大语言模型实时监控股市行情、a股、美股提供英伟达、小米等企业舆情分析助力投资决策" />
<meta name="keywords" content="金融AI,舆情分析,股市行情,LLM,价值前沿,a股,美股,投资分析" />
<link rel="canonical" href="https://valuefrontier.cn/" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://valuefrontier.cn/" />
<meta property="og:title" content="价值前沿 - 金融AI舆情分析系统" />
<meta property="og:description" content="基于金融大语言模型实时监控股市行情、a股、美股提供英伟达、小米等企业舆情分析" />
<meta property="og:image" content="https://valuefrontier.cn/og-image.jpg" />
<meta property="og:site_name" content="价值前沿" />
<meta property="og:locale" content="zh_CN" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://valuefrontier.cn/" />
<meta name="twitter:title" content="价值前沿 - 金融AI舆情分析系统" />
<meta name="twitter:description" content="基于金融大语言模型实时监控股市行情、a股、美股" />
<meta name="twitter:image" content="https://valuefrontier.cn/og-image.jpg" />
<!-- SEO 增强 -->
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="author" content="价值前沿团队" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="价值前沿 - 金融AI舆情分析系统" />
<!-- 性能优化: DNS 预连接 -->
<link rel="preconnect" href="https://valuefrontier.cn" />
<link rel="dns-prefetch" href="https://valuefrontier.cn" />
<!-- JSON-LD 结构化数据: 组织信息 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "价值前沿",
"url": "https://valuefrontier.cn",
"logo": "https://valuefrontier.cn/logo.png",
"description": "基于金融大语言模型的智能舆情分析平台",
"foundingDate": "2023",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "Customer Service",
"availableLanguage": ["zh-CN"]
},
"sameAs": [
"https://valuefrontier.cn"
]
}
</script>
<!-- JSON-LD 结构化数据: 网站信息 + 搜索功能 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "价值前沿",
"url": "https://valuefrontier.cn",
"description": "金融AI舆情分析系统实时监控股市行情",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://valuefrontier.cn/search?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
}
</script>
<!-- JSON-LD 结构化数据: 软件应用产品信息 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "价值前沿",
"applicationCategory": "FinanceApplication",
"operatingSystem": "Web",
"url": "https://valuefrontier.cn",
"description": "基于金融大语言模型实时监控股市行情、a股、美股提供企业舆情分析",
"offers": [
{
"@type": "Offer",
"name": "专业版",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "198",
"priceCurrency": "CNY",
"billingDuration": "P1M",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": "1",
"unitText": "月"
}
},
"availability": "https://schema.org/InStock",
"url": "https://valuefrontier.cn/home/pages/account/subscription"
},
{
"@type": "Offer",
"name": "旗舰版",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "998",
"priceCurrency": "CNY",
"billingDuration": "P1M",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": "1",
"unitText": "月"
}
},
"availability": "https://schema.org/InStock",
"url": "https://valuefrontier.cn/home/pages/account/subscription"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"ratingCount": "1250",
"bestRating": "5",
"worstRating": "1"
},
"featureList": [
"实时舆情监控",
"智能事件分析",
"多维度数据可视化",
"AI驱动的投资建议",
"行业板块分析"
]
}
</script>
<!-- JSON-LD 结构化数据: 面包屑导航 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "首页",
"item": "https://valuefrontier.cn/"
},
{
"@type": "ListItem",
"position": 2,
"name": "事件中心",
"item": "https://valuefrontier.cn/community"
},
{
"@type": "ListItem",
"position": 3,
"name": "概念分析",
"item": "https://valuefrontier.cn/concepts"
},
{
"@type": "ListItem",
"position": 4,
"name": "个股分析",
"item": "https://valuefrontier.cn/stocks"
}
]
}
</script>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" /> <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
<link <link
@@ -15,10 +186,19 @@
href="%PUBLIC_URL%/apple-icon.png" href="%PUBLIC_URL%/apple-icon.png"
/> />
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" /> <link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
<title>价值前沿——LLM赋能的分析平台</title>
</head> </head>
<body> <body>
<noscript> You need to enable JavaScript to run this app. </noscript> <noscript>
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 20px;">
<div>
<h1 style="font-size: 2em; margin-bottom: 20px;">⚠️ 需要启用 JavaScript</h1>
<p style="font-size: 1.2em; line-height: 1.6; max-width: 600px; margin: 0 auto;">
价值前沿是一个现代化的 Web 应用,需要 JavaScript 才能正常运行。<br><br>
请在浏览器设置中启用 JavaScript然后刷新页面。
</p>
</div>
</div>
</noscript>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View File

@@ -9,8 +9,9 @@
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware.
*/ */
import React, { useEffect } from "react"; import React, { useEffect, useRef } from "react";
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
// Routes // Routes
import AppRoutes from './routes'; import AppRoutes from './routes';
@@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
// Utils // Utils
import { logger } from './utils/logger'; import { logger } from './utils/logger';
// PostHog 追踪
import { trackEvent, trackEventAsync } from '@lib/posthog';
// Contexts
import { useAuth } from '@contexts/AuthContext';
/** /**
* AppContent - 应用核心内容 * AppContent - 应用核心内容
* 负责 PostHog 初始化和渲染路由 * 负责 PostHog 初始化和渲染路由
*/ */
function AppContent() { function AppContent() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const location = useLocation();
const { isAuthenticated } = useAuth();
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化 // 🎯 PostHog Redux 初始化
useEffect(() => { useEffect(() => {
@@ -43,6 +56,67 @@ function AppContent() {
logger.info('App', 'PostHog Redux 初始化已触发'); logger.info('App', 'PostHog Redux 初始化已触发');
}, [dispatch]); }, [dispatch]);
// ✅ 首次访问追踪
useEffect(() => {
const hasVisited = localStorage.getItem('has_visited');
if (!hasVisited) {
const urlParams = new URLSearchParams(location.search);
// ⚡ 使用异步追踪,不阻塞页面渲染
trackEventAsync('first_visit', {
referrer: document.referrer || 'direct',
utm_source: urlParams.get('utm_source'),
utm_medium: urlParams.get('utm_medium'),
utm_campaign: urlParams.get('utm_campaign'),
landing_page: location.pathname,
timestamp: new Date().toISOString()
});
localStorage.setItem('has_visited', 'true');
}
}, [location.search, location.pathname]);
// ✅ 页面浏览时长追踪
useEffect(() => {
// 计算上一个页面的停留时长
const calculateAndTrackDuration = () => {
const exitTime = Date.now();
const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
if (duration > 1) {
// ⚡ 使用异步追踪,不阻塞页面切换
trackEventAsync('page_view_duration', {
path: currentPathRef.current,
duration_seconds: duration,
is_authenticated: isAuthenticated,
timestamp: new Date().toISOString()
});
}
};
// 路由切换时追踪上一个页面的时长
if (currentPathRef.current !== location.pathname) {
calculateAndTrackDuration();
// 更新为新页面
currentPathRef.current = location.pathname;
pageEnterTimeRef.current = Date.now();
}
// 页面关闭/刷新时追踪时长
const handleBeforeUnload = () => {
calculateAndTrackDuration();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [location.pathname, isAuthenticated]);
return <AppRoutes />; return <AppRoutes />;
} }

View File

@@ -356,24 +356,22 @@ export default function AuthFormContent() {
// 更新session // 更新session
await checkSession(); await checkSession();
// ✅ 兼容后端两种命名格式camelCase (isNewUser) 和 snake_case (is_new_user)
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
// 追踪登录成功并识别用户 // 追踪登录成功并识别用户
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser); authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
// ✅ 保留登录成功 toast关键操作提示 // ✅ 保留登录成功 toast关键操作提示
toast({ toast({
title: data.isNewUser ? '注册成功' : '登录成功', title: isNewUser ? '注册成功' : '登录成功',
description: config.successDescription, description: config.successDescription,
status: "success", status: "success",
duration: 2000, duration: 2000,
}); });
logger.info('AuthFormContent', '登录成功', {
isNewUser: data.isNewUser,
userId: data.user?.id
});
// 检查是否为新注册用户 // 检查是否为新注册用户
if (data.isNewUser) { if (isNewUser) {
// 新注册用户,延迟后显示昵称设置引导 // 新注册用户,延迟后显示昵称设置引导
setTimeout(() => { setTimeout(() => {
setCurrentPhone(phone); setCurrentPhone(phone);

View File

@@ -1,5 +1,5 @@
// src/components/Auth/AuthModalManager.js // src/components/Auth/AuthModalManager.js
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { import {
Modal, Modal,
ModalOverlay, ModalOverlay,
@@ -10,6 +10,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAuthModal } from '../../hooks/useAuthModal'; import { useAuthModal } from '../../hooks/useAuthModal';
import AuthFormContent from './AuthFormContent'; import AuthFormContent from './AuthFormContent';
import { trackEventAsync } from '@lib/posthog';
import { ACTIVATION_EVENTS } from '@lib/constants';
/** /**
* 全局认证弹窗管理器 * 全局认证弹窗管理器
@@ -21,6 +23,27 @@ export default function AuthModalManager() {
closeModal closeModal
} = useAuthModal(); } = useAuthModal();
// ✅ 追踪弹窗打开次数(用于漏斗分析)
const hasTrackedOpen = useRef(false);
useEffect(() => {
if (isAuthModalOpen && !hasTrackedOpen.current) {
// ✅ 使用异步追踪,不阻塞渲染
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
timestamp: new Date().toISOString(),
modal_type: 'auth_modal',
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
});
hasTrackedOpen.current = true;
}
// ✅ 弹窗关闭时重置标记(允许再次追踪)
if (!isAuthModalOpen) {
hasTrackedOpen.current = false;
}
}, [isAuthModalOpen]);
// 响应式尺寸配置 // 响应式尺寸配置
const modalSize = useBreakpointValue({ const modalSize = useBreakpointValue({
base: "md", // 移动端md不占满全屏 base: "md", // 移动端md不占满全屏

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

@@ -13,10 +13,10 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
const CommentItem = ({ comment }) => { const CommentItem = ({ comment }) => {
const itemBg = useColorModeValue('gray.50', 'gray.700'); const itemBg = useColorModeValue('gray.50', 'gray.700');
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
// 格式化时间 // 格式化时间
const formatTime = (timestamp) => { const formatTime = (timestamp) => {
const now = moment(); const now = dayjs();
const time = moment(timestamp); const time = dayjs(timestamp);
const diffMinutes = now.diff(time, 'minutes'); const diffMinutes = now.diff(time, 'minutes');
const diffHours = now.diff(time, 'hours'); const diffHours = now.diff(time, 'hours');
const diffDays = now.diff(time, 'days'); const diffDays = now.diff(time, 'days');

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Spin, Typography } from 'antd'; import { Modal, Button, Spin, Typography } from 'antd';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import moment from 'moment'; import dayjs from 'dayjs';
import { stockService } from '../../services/eventService'; import { stockService } from '../../services/eventService';
import CitedContent from '../Citation/CitedContent'; import CitedContent from '../Citation/CitedContent';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) { if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30 // 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) { if (eventMoment.hour() >= 15) {
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid()) { if (eventMoment.isValid()) {
// 如果是15:00之后的事件推到下一个交易日的9:30 // 如果是15:00之后的事件推到下一个交易日的9:30
if (eventMoment.hour() >= 15) { if (eventMoment.hour() >= 15) {
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
// 计算事件标记线位置 // 计算事件标记线位置
let markLineData = []; let markLineData = [];
if (eventTime && times.length > 0) { if (eventTime && times.length > 0) {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
const eventDate = eventMoment.format('YYYY-MM-DD'); const eventDate = eventMoment.format('YYYY-MM-DD');
if (activeChartType === 'timeline') { if (activeChartType === 'timeline') {

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react'; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import moment from 'moment'; import dayjs from 'dayjs';
import { stockService } from '../../services/eventService'; import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer'; import RiskDisclaimer from '../RiskDisclaimer';
@@ -50,7 +50,7 @@ const StockChartModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) { if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day'); const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0); nextDay.hour(9).minute(30).second(0).millisecond(0);
@@ -111,7 +111,7 @@ const StockChartModal = ({
let adjustedEventTime = eventTime; let adjustedEventTime = eventTime;
if (eventTime) { if (eventTime) {
try { try {
const eventMoment = moment(eventTime); const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) { if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day'); const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0); nextDay.hour(9).minute(30).second(0).millisecond(0);
@@ -182,7 +182,7 @@ const StockChartModal = ({
// 计算事件标记线位置 // 计算事件标记线位置
let eventMarkLineData = []; let eventMarkLineData = [];
if (originalEventTime && times.length > 0) { if (originalEventTime && times.length > 0) {
const eventMoment = moment(originalEventTime); const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD'); const eventDate = eventMoment.format('YYYY-MM-DD');
const eventTime = eventMoment.format('HH:mm'); const eventTime = eventMoment.format('HH:mm');
@@ -357,7 +357,7 @@ const StockChartModal = ({
// 计算事件标记线位置(重要修复) // 计算事件标记线位置(重要修复)
let eventMarkLineData = []; let eventMarkLineData = [];
if (originalEventTime && dates.length > 0) { if (originalEventTime && dates.length > 0) {
const eventMoment = moment(originalEventTime); const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD'); const eventDate = eventMoment.format('YYYY-MM-DD');
// 找到事件发生日期或最接近的交易日 // 找到事件发生日期或最接近的交易日

View File

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

File diff suppressed because it is too large Load Diff

204
src/constants/tracking.js Normal file
View File

@@ -0,0 +1,204 @@
// src/constants/tracking.js
// PostHog 事件追踪优先级配置
/**
* 事件优先级枚举
*
* 用于决定事件的追踪时机,优化性能和用户体验。
*
* @enum {string}
*/
export const EVENT_PRIORITY = {
/**
* 关键事件 - 立即发送,不可延迟
* 示例:登录、注册、支付、订阅购买
*/
CRITICAL: 'critical',
/**
* 高优先级事件 - 立即发送
* 示例:详情打开、搜索提交、关注操作、分享操作
*/
HIGH: 'high',
/**
* 普通优先级事件 - 空闲时发送
* 示例:列表查看、筛选应用、排序变更
*/
NORMAL: 'normal',
/**
* 低优先级事件 - 空闲时发送,可批量合并
* 示例鼠标移动、滚动事件、hover 事件
*/
LOW: 'low',
};
/**
* Community 页面(新闻催化分析)事件优先级映射
*
* 映射规则:
* - CRITICAL: 无Community 页面无关键业务操作)
* - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转)
* - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序)
* - LOW: 暂未使用
*
* @type {Object<string, string>}
*/
export const COMMUNITY_EVENT_PRIORITIES = {
// ==================== 普通优先级(空闲时追踪)====================
/**
* 页面浏览事件 - NORMAL
* 触发时机:用户进入 Community 页面
* 延迟原因:页面加载时避免阻塞渲染
*/
'Community Page Viewed': EVENT_PRIORITY.NORMAL,
/**
* 新闻列表查看 - NORMAL
* 触发时机:新闻列表加载完成
* 延迟原因:避免阻塞列表渲染
*/
'News List Viewed': EVENT_PRIORITY.NORMAL,
/**
* 新闻筛选应用 - NORMAL
* 触发时机:用户应用筛选条件(重要性、日期、行业)
* 延迟原因:筛选操作频繁,避免阻塞 UI 更新
*/
'News Filter Applied': EVENT_PRIORITY.NORMAL,
/**
* 新闻排序变更 - NORMAL
* 触发时机:用户切换排序方式(最新、最热、收益率)
* 延迟原因:排序操作频繁,避免阻塞 UI 更新
*/
'News Sorted': EVENT_PRIORITY.NORMAL,
/**
* 新闻标签页点击 - NORMAL
* 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线)
* 延迟原因:标签切换高频,延迟追踪不影响用户体验
*/
'News Tab Clicked': EVENT_PRIORITY.NORMAL,
// ==================== 高优先级(立即追踪)====================
/**
* 新闻文章点击 - HIGH
* 触发时机:用户点击新闻卡片
* 立即追踪原因:关键交互操作,需要准确记录点击位置和时间
*/
'News Article Clicked': EVENT_PRIORITY.HIGH,
/**
* 新闻详情打开 - HIGH
* 触发时机:打开新闻详情弹窗或页面
* 立即追踪原因:关键交互操作,需要准确记录查看时间
*/
'News Detail Opened': EVENT_PRIORITY.HIGH,
/**
* 搜索查询提交 - HIGH
* 触发时机:用户提交搜索关键词
* 立即追踪原因:用户明确操作,需要准确记录搜索意图
*/
'Search Query Submitted': EVENT_PRIORITY.HIGH,
/**
* 搜索无结果 - HIGH
* 触发时机:搜索返回 0 个结果
* 立即追踪原因:重要的用户体验指标,需要及时发现问题
*/
'Search No Results': EVENT_PRIORITY.HIGH,
/**
* 相关股票点击 - HIGH
* 触发时机:用户从新闻详情点击相关股票
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
*/
'Stock Clicked': EVENT_PRIORITY.HIGH,
/**
* 相关概念点击 - HIGH
* 触发时机:用户从新闻详情点击相关概念
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
*/
'Concept Clicked': EVENT_PRIORITY.HIGH,
/**
* 事件关注操作 - HIGH
* 触发时机:用户点击关注按钮
* 立即追踪原因:关键业务操作,需要准确记录关注行为
*/
'Event Followed': EVENT_PRIORITY.HIGH,
/**
* 事件取消关注 - HIGH
* 触发时机:用户取消关注事件
* 立即追踪原因:关键业务操作,需要准确记录取关原因
*/
'Event Unfollowed': EVENT_PRIORITY.HIGH,
};
/**
* requestIdleCallback 配置
*
* @type {Object}
*/
export const IDLE_CALLBACK_CONFIG = {
/**
* 超时时间(毫秒)
* 即使浏览器不空闲,也会在此时间后强制执行追踪
*
* 设置为 2000ms 的原因:
* - 足够长:避免在用户快速操作时阻塞主线程
* - 足够短:确保用户快速关闭页面前也能发送事件
* - 平衡点2 秒是用户注意力的典型持续时间
*/
timeout: 2000,
};
/**
* 获取事件优先级
*
* @param {string} eventName - 事件名称
* @returns {string} 事件优先级CRITICAL | HIGH | NORMAL | LOW
*/
export const getEventPriority = (eventName) => {
return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL;
};
/**
* 判断事件是否需要立即追踪
*
* @param {string} eventName - 事件名称
* @returns {boolean} 是否立即追踪
*/
export const shouldTrackImmediately = (eventName) => {
const priority = getEventPriority(eventName);
return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH;
};
/**
* 判断事件是否可以延迟追踪
*
* @param {string} eventName - 事件名称
* @returns {boolean} 是否可以延迟追踪
*/
export const canTrackIdle = (eventName) => {
const priority = getEventPriority(eventName);
return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW;
};
// ==================== 默认导出 ====================
export default {
EVENT_PRIORITY,
COMMUNITY_EVENT_PRIORITIES,
IDLE_CALLBACK_CONFIG,
getEventPriority,
shouldTrackImmediately,
canTrackIdle,
};

View File

@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
import { SPECIAL_EVENTS } from '@lib/constants';
// 创建认证上下文 // 创建认证上下文
const AuthContext = createContext(); const AuthContext = createContext();
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
if (prevUser && prevUser.id === data.user.id) { if (prevUser && prevUser.id === data.user.id) {
return prevUser; return prevUser;
} }
// ✅ 识别用户身份到 PostHog
identifyUser(data.user.id, {
email: data.user.email,
username: data.user.username,
subscription_tier: data.user.subscription_tier,
role: data.user.role,
registration_date: data.user.created_at
});
return data.user; return data.user;
}); });
setIsAuthenticated((prev) => prev === true ? prev : true); setIsAuthenticated((prev) => prev === true ? prev : true);
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
setUser(data.user); setUser(data.user);
setIsAuthenticated(true); setIsAuthenticated(true);
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
// 事件名:'User Logged In' 或 'User Signed Up'
// 属性名login_method (不是 loginType)
// ⚡ 移除toast让调用者处理UI反馈避免并发更新冲突 // ⚡ 移除toast让调用者处理UI反馈避免并发更新冲突
// toast({ // toast({
// title: "登录成功", // title: "登录成功",
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
setUser(data.user); setUser(data.user);
setIsAuthenticated(true); setIsAuthenticated(true);
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
// 事件名:'User Signed Up'(不是 'user_registered'
// 属性名login_method不是 method
toast({ toast({
title: "注册成功", title: "注册成功",
description: "欢迎加入价值前沿!", description: "欢迎加入价值前沿!",
@@ -286,58 +308,6 @@ export const AuthProvider = ({ children }) => {
} }
}; };
// 邮箱注册
const registerWithEmail = async (email, code, username, password) => {
try {
setIsLoading(true);
const response = await fetch(`/api/auth/register/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
email,
code,
username,
password
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '注册失败');
}
// 注册成功后自动登录
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
status: "success",
duration: 3000,
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
logger.error('AuthContext', 'registerWithEmail', error);
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// 发送手机验证码 // 发送手机验证码
const sendSmsCode = async (phone) => { const sendSmsCode = async (phone) => {
try { try {
@@ -367,35 +337,6 @@ export const AuthProvider = ({ children }) => {
} }
}; };
// 发送邮箱验证码
const sendEmailCode = async (email) => {
try {
const response = await fetch(`/api/auth/send-email-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '发送失败');
}
// ❌ 移除成功 toast
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
return { success: true };
} catch (error) {
// ❌ 移除错误 toast
logger.error('AuthContext', 'sendEmailCode', error);
return { success: false, error: error.message };
}
};
// 登出方法 // 登出方法
const logout = async () => { const logout = async () => {
try { try {
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
credentials: 'include' credentials: 'include'
}); });
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
timestamp: new Date().toISOString(),
user_id: user?.id || null,
session_duration_minutes: user?.session_start
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
: null,
});
// ✅ 重置 PostHog 用户会话
resetUser();
// 清除本地状态 // 清除本地状态
setUser(null); setUser(null);
setIsAuthenticated(false); setIsAuthenticated(false);
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
updateUser, updateUser,
login, login,
registerWithPhone, registerWithPhone,
registerWithEmail,
sendSmsCode, sendSmsCode,
sendEmailCode,
logout, logout,
hasRole, hasRole,
refreshSession, refreshSession,

View File

@@ -124,6 +124,7 @@ async function startApp() {
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
// Render the app with Router wrapper // Render the app with Router wrapper
// ✅ StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Router <Router

View File

@@ -33,8 +33,8 @@ export const initPostHog = () => {
posthog.init(apiKey, { posthog.init(apiKey, {
api_host: apiHost, api_host: apiHost,
// Pageview tracking - manual control for better accuracy // Pageview tracking - auto-capture for DAU/MAU analytics
capture_pageview: false, // We'll manually capture with custom properties capture_pageview: true, // Auto-capture all page views (required for DAU tracking)
capture_pageleave: true, // Auto-capture when user leaves page capture_pageleave: true, // Auto-capture when user leaves page
// Session Recording Configuration // Session Recording Configuration
@@ -185,6 +185,30 @@ export const trackEvent = (eventName, properties = {}) => {
} }
}; };
/**
* 异步追踪事件(不阻塞主线程)
* 使用 requestIdleCallback 在浏览器空闲时发送事件
*
* @param {string} eventName - 事件名称
* @param {object} properties - 事件属性
*/
export const trackEventAsync = (eventName, properties = {}) => {
// 浏览器支持 requestIdleCallback 时使用(推荐)
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(
() => {
trackEvent(eventName, properties);
},
{ timeout: 2000 } // 最多延迟 2 秒(防止永远不执行)
);
} else {
// 降级方案:使用 setTimeout兼容性更好
setTimeout(() => {
trackEvent(eventName, properties);
}, 0);
}
};
/** /**
* Track page view * Track page view
* *

View File

@@ -53,3 +53,13 @@ export type {
CommentAuthor, CommentAuthor,
CreateCommentParams, CreateCommentParams,
} from './comment'; } from './comment';
// 投资规划相关类型
export type {
EventType,
EventSource,
EventStatus,
InvestmentEvent,
PlanFormData,
PlanningContextValue,
} from './investment';

148
src/types/investment.ts Normal file
View File

@@ -0,0 +1,148 @@
/**
* 投资规划相关类型定义
* 用于 InvestmentPlanningCenter 组件及其子组件
*/
import { UseToastOptions } from '@chakra-ui/react';
/**
* 事件类型枚举
*/
export type EventType = 'plan' | 'review' | 'reminder' | 'analysis';
/**
* 事件来源
*/
export type EventSource = 'user' | 'future' | 'system';
/**
* 事件状态
*/
export type EventStatus = 'active' | 'completed' | 'cancelled';
/**
* 投资事件接口
* 表示日历中的投资计划、复盘或其他事件
*/
export interface InvestmentEvent {
/** 事件唯一标识符 */
id: number;
/** 事件标题 */
title: string;
/** 事件描述/详细内容 */
description?: string;
/** 事件日期 (YYYY-MM-DD 格式) */
event_date: string;
/** 事件类型 */
type: EventType;
/** 事件来源(用户创建/系统生成/未来事件) */
source?: EventSource;
/** 重要度 (1-5) */
importance?: number;
/** 相关股票代码列表 */
stocks?: string[];
/** 标签列表 */
tags?: string[];
/** 事件状态 */
status?: EventStatus;
/** 创建时间 */
created_at?: string;
/** 更新时间 */
updated_at?: string;
/** 事件内容(用于计划/复盘的详细内容) */
content?: string;
/** 日期字段(兼容旧数据) */
date?: string;
}
/**
* 表单数据类型
* 用于创建/编辑投资计划或复盘
*/
export interface PlanFormData {
/** 事件日期 (YYYY-MM-DD 格式) */
date: string;
/** 标题 */
title: string;
/** 内容/描述 */
content: string;
/** 事件类型 */
type: EventType;
/** 相关股票代码列表 */
stocks: string[];
/** 标签列表 */
tags: string[];
/** 事件状态 */
status: EventStatus;
}
/**
* Planning Context 值类型
* 用于在 InvestmentPlanningCenter 的子组件间共享数据
*/
export interface PlanningContextValue {
/** 所有事件列表 */
allEvents: InvestmentEvent[];
/** 设置事件列表 */
setAllEvents: React.Dispatch<React.SetStateAction<InvestmentEvent[]>>;
/** 重新加载所有数据 */
loadAllData: () => Promise<void>;
/** 加载状态 */
loading: boolean;
/** 设置加载状态 */
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
/** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */
activeTab: number;
/** 设置激活的标签页 */
setActiveTab: React.Dispatch<React.SetStateAction<number>>;
/** Chakra UI Toast 实例 */
toast: {
(options?: UseToastOptions): string | number | undefined;
close: (id: string | number) => void;
closeAll: (options?: { positions?: Array<'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'> }) => void;
update: (id: string | number, options: Omit<UseToastOptions, 'id'>) => void;
isActive: (id: string | number) => boolean;
};
// 颜色主题变量(基于当前主题模式)
/** 背景色 */
bgColor: string;
/** 边框颜色 */
borderColor: string;
/** 主要文本颜色 */
textColor: string;
/** 次要文本颜色 */
secondaryText: string;
/** 卡片背景色 */
cardBg: string;
}

View File

@@ -0,0 +1,337 @@
// src/utils/trackingHelpers.js
// PostHog 追踪性能优化工具 - 使用 requestIdleCallback 延迟非关键事件
import { shouldTrackImmediately } from '../constants/tracking';
/**
* requestIdleCallback Polyfill
* Safari 和旧浏览器不支持 requestIdleCallback使用 setTimeout 降级
*
* @param {Function} callback - 回调函数
* @param {Object} options - 配置选项
* @param {number} options.timeout - 超时时间(毫秒)
* @returns {number} 定时器 ID
*/
const requestIdleCallbackPolyfill = (callback, options = {}) => {
const timeout = options.timeout || 2000;
const start = Date.now();
return setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
});
}, 1);
};
/**
* cancelIdleCallback Polyfill
*
* @param {number} id - 定时器 ID
*/
const cancelIdleCallbackPolyfill = (id) => {
clearTimeout(id);
};
// 使用原生 API 或 polyfill
const requestIdleCallbackCompat =
typeof window !== 'undefined' && window.requestIdleCallback
? window.requestIdleCallback.bind(window)
: requestIdleCallbackPolyfill;
const cancelIdleCallbackCompat =
typeof window !== 'undefined' && window.cancelIdleCallback
? window.cancelIdleCallback.bind(window)
: cancelIdleCallbackPolyfill;
// ==================== 待发送事件队列 ====================
/**
* 待发送事件队列(用于批量发送优化)
* @type {Array<{trackFn: Function, args: Array}>}
*/
let pendingEvents = [];
/**
* 已调度的 idle callback ID防止重复调度
* @type {number|null}
*/
let scheduledCallbackId = null;
/**
* 刷新待发送事件队列
* 立即执行所有待发送的追踪事件
*/
const flushPendingEvents = () => {
if (pendingEvents.length === 0) return;
const eventsToFlush = [...pendingEvents];
pendingEvents = [];
eventsToFlush.forEach(({ trackFn, args }) => {
try {
trackFn(...args);
} catch (error) {
console.error('❌ [trackingHelpers] Failed to flush event:', error);
}
});
if (process.env.NODE_ENV === 'development') {
console.log(
`%c✅ [trackingHelpers] Flushed ${eventsToFlush.length} pending event(s)`,
'color: #10B981; font-weight: bold;'
);
}
};
/**
* 处理空闲时执行待发送事件
*
* @param {IdleDeadline} deadline - 空闲时间信息
*/
const processIdleEvents = (deadline) => {
scheduledCallbackId = null;
// 如果超时或队列为空,强制刷新
if (deadline.didTimeout || pendingEvents.length === 0) {
flushPendingEvents();
return;
}
// 在空闲时间内尽可能多地处理事件
while (pendingEvents.length > 0 && deadline.timeRemaining() > 0) {
const { trackFn, args } = pendingEvents.shift();
try {
trackFn(...args);
} catch (error) {
console.error('❌ [trackingHelpers] Failed to track event:', error);
}
}
// 如果还有未处理的事件,继续调度
if (pendingEvents.length > 0) {
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
timeout: 2000,
});
}
};
// ==================== 公共 API ====================
/**
* 在浏览器空闲时追踪事件(非关键事件优化)
*
* 使用 requestIdleCallback API 延迟事件追踪到浏览器空闲时执行,
* 避免阻塞主线程,提升页面交互响应速度。
*
* **适用场景**
* - 页面浏览事件page_viewed
* - 列表查看事件list_viewed
* - 筛选/排序事件filter_applied, sorted
* - 低优先级交互事件
*
* **不适用场景**
* - 关键业务事件(登录、支付、关注)
* - 用户明确操作事件(按钮点击、详情打开)
* - 需要实时追踪的事件
*
* @param {Function} trackFn - PostHog 追踪函数(如 track, trackPageView
* @param {...any} args - 传递给追踪函数的参数
*
* @example
* import { trackEventIdle } from '@utils/trackingHelpers';
* import { trackEvent } from '@lib/posthog';
*
* // 延迟追踪页面浏览事件
* trackEventIdle(trackEvent, 'page_viewed', { page: '/community' });
*
* // 延迟追踪筛选事件
* trackEventIdle(track, 'news_filter_applied', { importance: 'high' });
*/
export const trackEventIdle = (trackFn, ...args) => {
if (!trackFn || typeof trackFn !== 'function') {
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
return;
}
// 添加到待发送队列
pendingEvents.push({ trackFn, args });
if (process.env.NODE_ENV === 'development') {
console.log(
`%c⏱ [trackingHelpers] Event queued for idle execution (queue: ${pendingEvents.length})`,
'color: #8B5CF6; font-weight: bold;',
args[0] // 事件名称
);
}
// 如果没有已调度的 callback调度一个新的
if (scheduledCallbackId === null) {
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
timeout: 2000, // 2秒超时保护确保事件不会无限延迟
});
}
};
/**
* 立即追踪事件(关键事件)
*
* 同步执行追踪,不延迟。用于需要实时追踪的关键业务事件。
*
* **适用场景**
* - 关键业务事件(登录、注册、支付、订阅)
* - 用户明确操作(按钮点击、详情打开、搜索提交)
* - 高优先级交互事件(关注、分享、评论)
* - 需要准确时序的事件
*
* @param {Function} trackFn - PostHog 追踪函数
* @param {...any} args - 传递给追踪函数的参数
*
* @example
* import { trackEventImmediate } from '@utils/trackingHelpers';
* import { trackEvent } from '@lib/posthog';
*
* // 立即追踪登录事件
* trackEventImmediate(trackEvent, 'user_logged_in', { method: 'password' });
*
* // 立即追踪详情打开事件
* trackEventImmediate(track, 'news_detail_opened', { news_id: 123 });
*/
export const trackEventImmediate = (trackFn, ...args) => {
if (!trackFn || typeof trackFn !== 'function') {
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
return;
}
try {
trackFn(...args);
if (process.env.NODE_ENV === 'development') {
console.log(
`%c⚡ [trackingHelpers] Event tracked immediately`,
'color: #F59E0B; font-weight: bold;',
args[0] // 事件名称
);
}
} catch (error) {
console.error('❌ [trackingHelpers] Failed to track event immediately:', error);
}
};
/**
* 智能追踪包装器
*
* 根据事件优先级自动选择立即追踪或空闲时追踪。
* 使用 `shouldTrackImmediately()` 判断事件优先级,简化调用方代码。
*
* **适用场景**
* - 业务代码不需要关心事件优先级细节
* - 统一的追踪接口,自动优化性能
* - 易于维护和扩展
*
* **优先级规则**(由 `src/constants/tracking.js` 配置):
* - CRITICAL / HIGH → 立即追踪(`trackEventImmediate`
* - NORMAL / LOW → 空闲时追踪(`trackEventIdle`
*
* @param {Function} trackFn - PostHog 追踪函数(如 `track` from `usePostHogTrack`
* @param {string} eventName - 事件名称(需在 `tracking.js` 中定义优先级)
* @param {Object} properties - 事件属性
*
* @example
* import { smartTrack } from '@/utils/trackingHelpers';
* import { usePostHogTrack } from '@/hooks/usePostHogRedux';
* import { RETENTION_EVENTS } from '@/lib/constants';
*
* const { track } = usePostHogTrack();
*
* // 自动根据优先级选择追踪方式
* smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: 123 });
* smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: 30 });
*/
export const smartTrack = (trackFn, eventName, properties = {}) => {
if (!trackFn || typeof trackFn !== 'function') {
console.warn('⚠️ [trackingHelpers] smartTrack: trackFn must be a function');
return;
}
if (!eventName || typeof eventName !== 'string') {
console.warn('⚠️ [trackingHelpers] smartTrack: eventName must be a string');
return;
}
// 根据事件优先级选择追踪方式
if (shouldTrackImmediately(eventName)) {
// 高优先级事件:立即追踪
trackEventImmediate(trackFn, eventName, properties);
} else {
// 普通优先级事件:空闲时追踪
trackEventIdle(trackFn, eventName, properties);
}
};
/**
* 页面卸载前刷新所有待发送事件
*
* 在 beforeunload 事件中调用,确保页面关闭前发送所有待发送的追踪事件。
* 防止用户快速关闭页面时丢失事件数据。
*
* **使用方式**
* ```javascript
* import { flushPendingEventsBeforeUnload } from '@utils/trackingHelpers';
*
* useEffect(() => {
* window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
* return () => {
* window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
* };
* }, []);
* ```
*/
export const flushPendingEventsBeforeUnload = () => {
// 取消已调度的 idle callback
if (scheduledCallbackId !== null) {
cancelIdleCallbackCompat(scheduledCallbackId);
scheduledCallbackId = null;
}
// 立即刷新所有待发送事件
flushPendingEvents();
if (process.env.NODE_ENV === 'development') {
console.log(
'%c🔄 [trackingHelpers] Flushed pending events before unload',
'color: #3B82F6; font-weight: bold;'
);
}
};
/**
* 获取当前待发送事件数量(调试用)
*
* @returns {number} 待发送事件数量
*/
export const getPendingEventsCount = () => {
return pendingEvents.length;
};
/**
* 清空待发送事件队列(测试用)
*/
export const clearPendingEvents = () => {
if (scheduledCallbackId !== null) {
cancelIdleCallbackCompat(scheduledCallbackId);
scheduledCallbackId = null;
}
pendingEvents = [];
};
// ==================== 默认导出 ====================
export default {
trackEventIdle,
trackEventImmediate,
smartTrack,
flushPendingEventsBeforeUnload,
getPendingEventsCount,
clearPendingEvents,
};

View File

@@ -1,7 +1,13 @@
// src/utils/tradingTimeUtils.js // src/utils/tradingTimeUtils.js
// 交易时间相关工具函数 // 交易时间相关工具函数
import moment from 'moment'; import dayjs from 'dayjs';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
// 扩展 Day.js 插件
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
/** /**
* 获取当前时间应该显示的实时要闻时间范围 * 获取当前时间应该显示的实时要闻时间范围
@@ -12,7 +18,7 @@ import moment from 'moment';
* @returns {{ startTime: Date, endTime: Date, description: string }} * @returns {{ startTime: Date, endTime: Date, description: string }}
*/ */
export const getCurrentTradingTimeRange = () => { export const getCurrentTradingTimeRange = () => {
const now = moment(); const now = dayjs();
const currentHour = now.hour(); const currentHour = now.hour();
const currentMinute = now.minute(); const currentMinute = now.minute();
@@ -25,18 +31,18 @@ export const getCurrentTradingTimeRange = () => {
if (currentTimeInMinutes < cutoffTime1500) { if (currentTimeInMinutes < cutoffTime1500) {
// 15:00 之前:显示昨日 15:00 - 今日 15:00 // 15:00 之前:显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00'; description = '昨日15:00 - 今日15:00';
} else if (currentTimeInMinutes >= cutoffTime1530) { } else if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示今日 15:00 - 当前时间 // 15:30 之后:显示今日 15:00 - 当前时间
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = now.toDate(); endTime = now.toDate();
description = '今日15:00 - 当前时间'; description = '今日15:00 - 当前时间';
} else { } else {
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00 // 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00'; description = '昨日15:00 - 今日15:00';
} }
@@ -55,7 +61,7 @@ export const getCurrentTradingTimeRange = () => {
* @returns {{ startTime: Date, endTime: Date, description: string }} * @returns {{ startTime: Date, endTime: Date, description: string }}
*/ */
export const getMarketReviewTimeRange = () => { export const getMarketReviewTimeRange = () => {
const now = moment(); const now = dayjs();
const currentHour = now.hour(); const currentHour = now.hour();
const currentMinute = now.minute(); const currentMinute = now.minute();
@@ -67,13 +73,13 @@ export const getMarketReviewTimeRange = () => {
if (currentTimeInMinutes >= cutoffTime1530) { if (currentTimeInMinutes >= cutoffTime1530) {
// 15:30 之后:显示昨日 15:00 - 今日 15:00刚刚完成的交易日 // 15:30 之后:显示昨日 15:00 - 今日 15:00刚刚完成的交易日
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate(); endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
description = '昨日15:00 - 今日15:00'; description = '昨日15:00 - 今日15:00';
} else { } else {
// 15:30 之前:显示前日 15:00 - 昨日 15:00上一个完整交易日 // 15:30 之前:显示前日 15:00 - 昨日 15:00上一个完整交易日
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate(); startTime = dayjs().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate(); endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
description = '前日15:00 - 昨日15:00'; description = '前日15:00 - 昨日15:00';
} }
@@ -102,15 +108,15 @@ export const filterEventsByTimeRange = (events, startTime, endTime) => {
return events; return events;
} }
const startMoment = moment(startTime); const startMoment = dayjs(startTime);
const endMoment = moment(endTime); const endMoment = dayjs(endTime);
return events.filter(event => { return events.filter(event => {
if (!event.created_at) { if (!event.created_at) {
return false; return false;
} }
const eventTime = moment(event.created_at); const eventTime = dayjs(event.created_at);
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment); return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
}); });
}; };
@@ -138,8 +144,8 @@ export const getTimeRangeDescription = (startTime, endTime) => {
return ''; return '';
} }
const startStr = moment(startTime).format('MM-DD HH:mm'); const startStr = dayjs(startTime).format('MM-DD HH:mm');
const endStr = moment(endTime).format('MM-DD HH:mm'); const endStr = dayjs(endTime).format('MM-DD HH:mm');
return `${startStr} - ${endStr}`; return `${startStr} - ${endStr}`;
}; };
@@ -152,7 +158,7 @@ export const getTimeRangeDescription = (startTime, endTime) => {
* @returns {boolean} * @returns {boolean}
*/ */
export const isTradingDay = (date) => { export const isTradingDay = (date) => {
const day = moment(date).day(); const day = dayjs(date).day();
// 0 = 周日, 6 = 周六 // 0 = 周日, 6 = 周六
return day !== 0 && day !== 6; return day !== 0 && day !== 6;
}; };
@@ -164,7 +170,7 @@ export const isTradingDay = (date) => {
* @returns {Date} * @returns {Date}
*/ */
export const getPreviousTradingDay = (date) => { export const getPreviousTradingDay = (date) => {
let prevDay = moment(date).subtract(1, 'day'); let prevDay = dayjs(date).subtract(1, 'day');
// 如果是周末,继续往前找 // 如果是周末,继续往前找
while (!isTradingDay(prevDay.toDate())) { while (!isTradingDay(prevDay.toDate())) {

View File

@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
'fourRowData.total': fourRowData.total, 'fourRowData.total': fourRowData.total,
}); });
// 根据模式选择数据源 // 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
// 纵向模式data 是页码映射 { 1: [...], 2: [...] } // 纵向模式data 是页码映射 { 1: [...], 2: [...] }
// 平铺模式data 是数组 [...] // 平铺模式data 是数组 [...]
const modeData = currentMode === 'four-row' ? fourRowData : verticalData; const modeData = useMemo(
() => currentMode === 'four-row' ? fourRowData : verticalData,
[currentMode, fourRowData, verticalData]
);
const { const {
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组 data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
loading = false, loading = false,
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
cachedPageCount = 0 cachedPageCount = 0
} = modeData; } = modeData;
// 传递给 usePagination 的数据 // 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined; const allCachedEventsByPage = useMemo(
const allCachedEvents = currentMode === 'four-row' ? data : undefined; () => currentMode === 'vertical' ? data : undefined,
[currentMode, data]
);
const allCachedEvents = useMemo(
() => currentMode === 'four-row' ? data : undefined,
[currentMode, data]
);
// 🔍 调试:选择的数据源 // 🔍 调试:选择的数据源
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', { console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {

View File

@@ -13,7 +13,7 @@ import {
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons'; import { ViewIcon } from '@chakra-ui/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import StockChangeIndicators from '../../../../components/StockChangeIndicators'; import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton'; import EventFollowButton from '../EventCard/EventFollowButton';
@@ -98,7 +98,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
{/* 日期 */} {/* 日期 */}
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap"> <Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
{moment(event.created_at).format('YYYY年MM月DD日')} {dayjs(event.created_at).format('YYYY年MM月DD日')}
</Text> </Text>
</Flex> </Flex>

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js // src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import moment from 'moment'; import dayjs from 'dayjs';
import { import {
fetchKlineData, fetchKlineData,
getCacheKey, getCacheKey,
@@ -26,7 +26,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
// 稳定的事件时间 // 稳定的事件时间
const stableEventTime = useMemo(() => { const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]); }, [eventTime]);
useEffect(() => { useEffect(() => {
@@ -105,9 +105,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
let eventMarkLineData = []; let eventMarkLineData = [];
if (stableEventTime && Array.isArray(dates) && dates.length > 0) { if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
try { try {
const eventDate = moment(stableEventTime).format('YYYY-MM-DD'); const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
const eventIdx = dates.findIndex(d => { const eventIdx = dates.findIndex(d => {
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d); const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
return dateStr.includes(eventDate); return dateStr.includes(eventDate);
}); });

View File

@@ -8,7 +8,7 @@ import {
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FaCalendarAlt } from 'react-icons/fa'; import { FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment'; import dayjs from 'dayjs';
/** /**
* 交易日期信息提示组件 * 交易日期信息提示组件
@@ -28,9 +28,9 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
<FaCalendarAlt color="gray" size={12} /> <FaCalendarAlt color="gray" size={12} />
<Text fontSize="xs" color={stockCountColor}> <Text fontSize="xs" color={stockCountColor}>
涨跌幅数据{effectiveTradingDate} 涨跌幅数据{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && ( {eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}> <Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据) (事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')}显示下一交易日数据)
</Text> </Text>
)} )}
</Text> </Text>

View File

@@ -16,7 +16,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import moment from 'moment'; import dayjs from 'dayjs';
import SimpleConceptCard from './SimpleConceptCard'; import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard'; import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo'; import TradingDateInfo from './TradingDateInfo';
@@ -89,16 +89,16 @@ const RelatedConceptsSection = ({
let formattedTradeDate; let formattedTradeDate;
try { try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD // 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD'); formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
// 验证日期是否有效 // 验证日期是否有效
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) { if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期'); console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = moment().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
} catch (error) { } catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error); console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = moment().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
const requestBody = { const requestBody = {

View File

@@ -11,7 +11,7 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
// 导入子组件 // 导入子组件
@@ -137,7 +137,7 @@ const CompactEventCard = ({
<Text>@{event.creator?.username || 'Anonymous'}</Text> <Text>@{event.creator?.username || 'Anonymous'}</Text>
<Text></Text> <Text></Text>
<Text fontWeight="bold" color={linkColor}> <Text fontWeight="bold" color={linkColor}>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')} {dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text> </Text>
</HStack> </HStack>
</Flex> </Flex>

View File

@@ -9,7 +9,7 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
// 导入子组件 // 导入子组件
@@ -127,7 +127,7 @@ const DetailedEventCard = ({
{/* 右侧:时间 + 作者 */} {/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}> <HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}> <Text fontWeight="bold" color={linkColor}>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')} {dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text> </Text>
<Text color={mutedColor}></Text> <Text color={mutedColor}></Text>
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text> <Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>

View File

@@ -11,7 +11,7 @@ import {
Tooltip, Tooltip,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { getChangeColor } from '../../../../utils/colorUtils'; import { getChangeColor } from '../../../../utils/colorUtils';
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = React.memo(({
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'} * @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
*/ */
const getTradingPeriod = (timestamp) => { const getTradingPeriod = (timestamp) => {
const eventTime = moment(timestamp); const eventTime = dayjs(timestamp);
const hour = eventTime.hour(); const hour = eventTime.hour();
const minute = eventTime.minute(); const minute = eventTime.minute();
const timeInMinutes = hour * 60 + minute; const timeInMinutes = hour * 60 + minute;
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = React.memo(({
color={timeLabelStyle.textColor} color={timeLabelStyle.textColor}
lineHeight="1.3" lineHeight="1.3"
> >
{moment(event.created_at).format('YYYY-MM-DD HH:mm')} {dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
{periodLabel && ( {periodLabel && (
<> <>
{' • '} {' • '}

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/EventCard/EventTimeline.js // src/views/Community/components/EventCard/EventTimeline.js
import React from 'react'; import React from 'react';
import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/react'; import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
/** /**
* 事件时间轴组件 * 事件时间轴组件
@@ -56,7 +56,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
color={timelineStyle.textColor} color={timelineStyle.textColor}
lineHeight="1.2" lineHeight="1.2"
> >
{moment(createdAt).format('MM-DD')} {dayjs(createdAt).format('MM-DD')}
</Text> </Text>
{/* 时间 HH:mm */} {/* 时间 HH:mm */}
<Text <Text
@@ -66,7 +66,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
lineHeight="1.2" lineHeight="1.2"
mt={0.5} mt={0.5}
> >
{moment(createdAt).format('HH:mm')} {dayjs(createdAt).format('HH:mm')}
</Text> </Text>
</Box> </Box>
{/* 时间轴竖线 */} {/* 时间轴竖线 */}

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

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

@@ -11,7 +11,7 @@ import {
ModalCloseButton, ModalCloseButton,
useDisclosure useDisclosure
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import moment from 'moment'; import dayjs from 'dayjs';
import './HotEvents.css'; import './HotEvents.css';
import defaultEventImage from '../../../assets/img/default-event.jpg'; import defaultEventImage from '../../../assets/img/default-event.jpg';
import DynamicNewsDetailPanel from './DynamicNewsDetail'; import DynamicNewsDetailPanel from './DynamicNewsDetail';
@@ -181,9 +181,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
<div className="event-footer"> <div className="event-footer">
<span className="creator">{event.creator?.username || 'Anonymous'}</span> <span className="creator">{event.creator?.username || 'Anonymous'}</span>
<span className="time"> <span className="time">
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span> <span className="time-date">{dayjs(event.created_at).format('YYYY-MM-DD')}</span>
{' '} {' '}
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span> <span className="time-hour">{dayjs(event.created_at).format('HH:mm')}</span>
</span> </span>
</div> </div>
</Card> </Card>

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

@@ -8,7 +8,7 @@ import {
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined, StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService'; import { eventService, stockService } from '../../../services/eventService';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal'; import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
@@ -33,7 +33,7 @@ const InvestmentCalendar = () => {
const [selectedDateEvents, setSelectedDateEvents] = useState([]); const [selectedDateEvents, setSelectedDateEvents] = useState([]);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentMonth, setCurrentMonth] = useState(moment()); const [currentMonth, setCurrentMonth] = useState(dayjs());
// 新增状态 // 新增状态
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
@@ -344,7 +344,7 @@ const InvestmentCalendar = () => {
render: (time) => ( render: (time) => (
<Space> <Space>
<ClockCircleOutlined /> <ClockCircleOutlined />
<Text>{moment(time).format('HH:mm')}</Text> <Text>{dayjs(time).format('HH:mm')}</Text>
</Space> </Space>
) )
}, },

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

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { Drawer, Spin, Button, Alert } from 'antd'; import { Drawer, Spin, Button, Alert } from 'antd';
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons'; import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { Tabs as AntdTabs } from 'antd'; import { Tabs as AntdTabs } from 'antd';
import moment from 'moment'; import dayjs from 'dayjs';
// Services and Utils // Services and Utils
import { eventService } from '../../../services/eventService'; import { eventService } from '../../../services/eventService';
@@ -167,7 +167,7 @@ function StockDetailPanel({ visible, event, onClose }) {
if (fixedCharts.length === 0) return null; if (fixedCharts.length === 0) return null;
const formattedEventTime = event?.start_time const formattedEventTime = event?.start_time
? moment(event.start_time).format('YYYY-MM-DD HH:mm') ? dayjs(event.start_time).format('YYYY-MM-DD HH:mm')
: undefined; : undefined;
return fixedCharts.map(({ stock }, index) => ( return fixedCharts.map(({ stock }, index) => (

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import moment from 'moment'; import dayjs from 'dayjs';
import { import {
fetchKlineData, fetchKlineData,
getCacheKey, getCacheKey,
@@ -27,7 +27,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
// 稳定的事件时间,避免因为格式化导致的重复请求 // 稳定的事件时间,避免因为格式化导致的重复请求
const stableEventTime = useMemo(() => { const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]); }, [eventTime]);
useEffect(() => { useEffect(() => {
@@ -109,7 +109,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
let eventMarkLineData = []; let eventMarkLineData = [];
if (stableEventTime && Array.isArray(times) && times.length > 0) { if (stableEventTime && Array.isArray(times) && times.length > 0) {
try { try {
const eventMinute = moment(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm'); const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
const parseMinuteTime = (timeStr) => { const parseMinuteTime = (timeStr) => {
const [h, m] = String(timeStr).split(':').map(Number); const [h, m] = String(timeStr).split(':').map(Number);
return h * 60 + m; return h * 60 + m;

View File

@@ -2,7 +2,7 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { Table, Button } from 'antd'; import { Table, Button } from 'antd';
import { StarFilled, StarOutlined } from '@ant-design/icons'; import { StarFilled, StarOutlined } from '@ant-design/icons';
import moment from 'moment'; import dayjs from 'dayjs';
import MiniTimelineChart from './MiniTimelineChart'; import MiniTimelineChart from './MiniTimelineChart';
import { logger } from '../../../../../utils/logger'; import { logger } from '../../../../../utils/logger';
@@ -31,7 +31,7 @@ const StockTable = ({
// 稳定的事件时间,避免重复渲染 // 稳定的事件时间,避免重复渲染
const stableEventTime = useMemo(() => { const stableEventTime = useMemo(() => {
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]); }, [eventTime]);
// 切换行展开状态 // 切换行展开状态

View File

@@ -1,5 +1,5 @@
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js // src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
import moment from 'moment'; import dayjs from 'dayjs';
import { stockService } from '../../../../../services/eventService'; import { stockService } from '../../../../../services/eventService';
import { logger } from '../../../../../utils/logger'; import { logger } from '../../../../../utils/logger';
@@ -19,7 +19,7 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
* @returns {string} 缓存键 * @returns {string} 缓存键
*/ */
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => { export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD'); const date = eventTime ? dayjs(eventTime).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
return `${stockCode}|${date}|${chartType}`; return `${stockCode}|${date}|${chartType}`;
}; };
@@ -36,7 +36,7 @@ export const shouldRefreshData = (cacheKey) => {
const elapsed = now - lastTime; const elapsed = now - lastTime;
// 如果是今天的数据且交易时间内,允许更频繁的更新 // 如果是今天的数据且交易时间内,允许更频繁的更新
const today = moment().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const isToday = cacheKey.includes(today); const isToday = cacheKey.includes(today);
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
const isTradingHours = currentHour >= 9 && currentHour < 16; const isTradingHours = currentHour >= 9 && currentHour < 16;
@@ -76,7 +76,7 @@ export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline
// 3. 发起新请求 // 3. 发起新请求
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType }); logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined; const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
const requestPromise = stockService const requestPromise = stockService
.getKlineData(stockCode, chartType, normalizedEventTime) .getKlineData(stockCode, chartType, normalizedEventTime)
.then((res) => { .then((res) => {

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

@@ -1,10 +1,12 @@
// src/views/Community/hooks/useCommunityEvents.js // src/views/Community/hooks/useCommunityEvents.js
// 新闻催化分析页面事件追踪 Hook // 新闻催化分析页面事件追踪 Hook
// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; import { usePostHogTrack } from '@/hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants'; import { RETENTION_EVENTS } from '@/lib/constants';
import { logger } from '../../../utils/logger'; import { logger } from '@/utils/logger';
import { smartTrack } from '@/utils/trackingHelpers';
/** /**
* 新闻催化分析Community事件追踪 Hook * 新闻催化分析Community事件追踪 Hook
@@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger';
export const useCommunityEvents = ({ navigate } = {}) => { export const useCommunityEvents = ({ navigate } = {}) => {
const { track } = usePostHogTrack(); const { track } = usePostHogTrack();
// 🎯 页面浏览事件 - 页面加载时触发 // 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪)
useEffect(() => { useEffect(() => {
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, { smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
logger.debug('useCommunityEvents', '📰 Community Page Viewed'); logger.debug('useCommunityEvents', '📰 Community Page Viewed');
@@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
* @param {string} params.industryFilter - 行业筛选 * @param {string} params.industryFilter - 行业筛选
*/ */
const trackNewsListViewed = useCallback((params = {}) => { const trackNewsListViewed = useCallback((params = {}) => {
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, { smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, {
total_count: params.totalCount || 0, total_count: params.totalCount || 0,
sort_by: params.sortBy || 'new', sort_by: params.sortBy || 'new',
importance_filter: params.importance || 'all', importance_filter: params.importance || 'all',
@@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
news_id: news.id, news_id: news.id,
news_title: news.title || '', news_title: news.title || '',
importance: news.importance || 'unknown', importance: news.importance || 'unknown',
@@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, { smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
news_id: news.id, news_id: news.id,
news_title: news.title || '', news_title: news.title || '',
importance: news.importance || 'unknown', importance: news.importance || 'unknown',
@@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, { smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, {
tab_name: tabName, tab_name: tabName,
news_id: newsId, news_id: newsId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
* @param {string} filters.industryCode - 行业代码 * @param {string} filters.industryCode - 行业代码
*/ */
const trackNewsFilterApplied = useCallback((filters = {}) => { const trackNewsFilterApplied = useCallback((filters = {}) => {
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, { smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
importance: filters.importance || 'all', importance: filters.importance || 'all',
date_range: filters.dateRange || 'all', date_range: filters.dateRange || 'all',
industry_classification: filters.industryClassification || 'all', industry_classification: filters.industryClassification || 'all',
@@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.NEWS_SORTED, { smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, {
sort_by: sortBy, sort_by: sortBy,
previous_sort: previousSort, previous_sort: previousSort,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
const trackNewsSearched = useCallback((query, resultCount = 0) => { const trackNewsSearched = useCallback((query, resultCount = 0) => {
if (!query) return; if (!query) return;
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, { smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
query, query,
result_count: resultCount, result_count: resultCount,
has_results: resultCount > 0, has_results: resultCount > 0,
@@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
// 如果没有搜索结果,额外追踪 // 如果没有搜索结果,额外追踪(高优先级,立即发送)
if (resultCount === 0) { if (resultCount === 0) {
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, { smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, {
query, query,
context: 'community_news', context: 'community_news',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.STOCK_CLICKED, { smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, {
stock_code: stock.code, stock_code: stock.code,
stock_name: stock.name || '', stock_name: stock.name || '',
source: 'news_related_stocks', source: 'news_related_stocks',
@@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
return; return;
} }
track(RETENTION_EVENTS.CONCEPT_CLICKED, { smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, {
concept_code: concept.code, concept_code: concept.code,
concept_name: concept.name || '', concept_name: concept.name || '',
source: 'news_related_concepts', source: 'news_related_concepts',

View File

@@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux';
import { import {
fetchPopularKeywords, fetchPopularKeywords,
fetchHotEvents fetchHotEvents
} from '../../store/slices/communityDataSlice'; } from '@/store/slices/communityDataSlice';
import { import {
Box, Box,
Container, Container,
@@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData';
import { useEventFilters } from './hooks/useEventFilters'; import { useEventFilters } from './hooks/useEventFilters';
import { useCommunityEvents } from './hooks/useCommunityEvents'; import { useCommunityEvents } from './hooks/useCommunityEvents';
import { logger } from '../../utils/logger'; import { logger } from '@/utils/logger';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '@/contexts/NotificationContext';
import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme';
import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers';
// 导航栏已由 MainLayout 提供,无需在此导入 // 导航栏已由 MainLayout 提供,无需在此导入
@@ -96,6 +97,15 @@ const Community = () => {
dispatch(fetchHotEvents()); dispatch(fetchHotEvents());
}, [dispatch]); }, [dispatch]);
// ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化)
useEffect(() => {
window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
return () => {
window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
};
}, []);
// 🎯 追踪新闻列表查看(当事件列表加载完成后) // 🎯 追踪新闻列表查看(当事件列表加载完成后)
useEffect(() => { useEffect(() => {
if (events && events.length > 0 && !loading) { if (events && events.length > 0 && !loading) {

View File

@@ -0,0 +1,504 @@
/**
* CalendarPanel - 投资日历面板组件
* 使用 FullCalendar 展示投资计划、复盘等事件
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Tooltip,
Icon,
Input,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiStar,
FiTrendingUp,
} from 'react-icons/fi';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { DateClickArg } from '@fullcalendar/interaction';
import { EventClickArg } from '@fullcalendar/common';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, EventType } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 新事件表单数据类型
*/
interface NewEventForm {
title: string;
description: string;
type: EventType;
importance: number;
stocks: string;
}
/**
* FullCalendar 事件类型
*/
interface CalendarEvent {
id: string;
title: string;
start: string;
date: string;
backgroundColor: string;
borderColor: string;
extendedProps: InvestmentEvent & {
isSystem: boolean;
};
}
/**
* CalendarPanel 组件
* 日历视图面板,显示所有投资事件
*/
export const CalendarPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
setActiveTab,
toast,
borderColor,
secondaryText,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
const [newEvent, setNewEvent] = useState<NewEventForm>({
title: '',
description: '',
type: 'plan',
importance: 3,
stocks: '',
});
// 转换数据为 FullCalendar 格式
const calendarEvents: CalendarEvent[] = allEvents.map(event => ({
...event,
id: `${event.source || 'user'}-${event.id}`,
title: event.title,
start: event.event_date,
date: event.event_date,
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
extendedProps: {
...event,
isSystem: event.source === 'future',
}
}));
// 处理日期点击
const handleDateClick = (info: DateClickArg): void => {
const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(event =>
dayjs(event.event_date).isSame(clickedDate, 'day')
);
setSelectedDateEvents(dayEvents);
onOpen();
};
// 处理事件点击
const handleEventClick = (info: EventClickArg): void => {
const event = info.event;
const clickedDate = dayjs(event.start);
setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(ev =>
dayjs(ev.event_date).isSame(clickedDate, 'day')
);
setSelectedDateEvents(dayEvents);
onOpen();
};
// 添加新事件
const handleAddEvent = async (): Promise<void> => {
try {
const base = getApiBase();
const eventData = {
...newEvent,
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
};
const response = await fetch(base + '/api/account/calendar/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(eventData),
});
if (response.ok) {
const data = await response.json();
if (data.success) {
logger.info('CalendarPanel', '添加事件成功', {
eventTitle: eventData.title,
eventDate: eventData.event_date
});
toast({
title: '添加成功',
description: '投资计划已添加',
status: 'success',
duration: 3000,
});
onAddClose();
loadAllData();
setNewEvent({
title: '',
description: '',
type: 'plan',
importance: 3,
stocks: '',
});
}
}
} catch (error) {
logger.error('CalendarPanel', 'handleAddEvent', error, {
eventTitle: newEvent?.title
});
toast({
title: '添加失败',
description: '无法添加投资计划',
status: 'error',
duration: 3000,
});
}
};
// 删除事件
const handleDeleteEvent = async (eventId: number): Promise<void> => {
if (!eventId) {
logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId });
toast({
title: '无法删除',
description: '缺少事件 ID',
status: 'error',
duration: 3000,
});
return;
}
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('CalendarPanel', '删除事件成功', { eventId });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 跳转到计划或复盘标签页
const handleViewDetails = (event: InvestmentEvent): void => {
if (event.type === 'plan') {
setActiveTab(1); // 跳转到"我的计划"标签页
} else if (event.type === 'review') {
setActiveTab(2); // 跳转到"我的复盘"标签页
}
onClose();
};
return (
<Box>
<Flex justify="flex-end" mb={4}>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => {
if (!selectedDate) setSelectedDate(dayjs());
onAddOpen();
}}
>
</Button>
</Flex>
{loading ? (
<Center h="560px">
<Spinner size="xl" color="purple.500" />
</Center>
) : (
<Box height={{ base: '500px', md: '600px' }}>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: ''
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={3}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周'
}}
/>
</Box>
)}
{/* 查看事件详情 Modal */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{selectedDate && selectedDate.format('YYYY年MM月DD日')}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedDateEvents.length === 0 ? (
<Center py={8}>
<VStack>
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => {
onClose();
onAddOpen();
}}
>
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={4}>
{selectedDateEvents.map((event, idx) => (
<Box
key={idx}
p={4}
borderRadius="md"
border="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="start" mb={2}>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold" fontSize="lg">
{event.title}
</Text>
{event.source === 'future' ? (
<Badge colorScheme="blue" variant="subtle"></Badge>
) : event.type === 'plan' ? (
<Badge colorScheme="purple" variant="subtle"></Badge>
) : (
<Badge colorScheme="green" variant="subtle"></Badge>
)}
</HStack>
{event.importance && (
<HStack spacing={2}>
<Icon as={FiStar} color="yellow.500" />
<Text fontSize="sm" color={secondaryText}>
: {event.importance}/5
</Text>
</HStack>
)}
</VStack>
<HStack>
{!event.source || event.source === 'user' ? (
<>
<Tooltip label="查看详情">
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={() => handleViewDetails(event)}
aria-label="查看详情"
/>
</Tooltip>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDeleteEvent(event.id)}
aria-label="删除事件"
/>
</>
) : null}
</HStack>
</Flex>
{event.description && (
<Text fontSize="sm" color={secondaryText} mb={2}>
{event.description}
</Text>
)}
{event.stocks && event.stocks.length > 0 && (
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={secondaryText}>:</Text>
{event.stocks.map((stock, i) => (
<Tag key={i} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</HStack>
)}
</Box>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}></Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 添加投资计划 Modal */}
{isAddOpen && (
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={newEvent.title}
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
placeholder="例如:关注半导体板块"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={newEvent.description}
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={3}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={newEvent.type}
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
>
<option value="plan"></option>
<option value="review"></option>
<option value="reminder"></option>
<option value="analysis"></option>
</Select>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={newEvent.importance}
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
>
<option value={5}> </option>
<option value={4}> </option>
<option value={3}> </option>
<option value={2}> </option>
<option value={1}> </option>
</Select>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Input
value={newEvent.stocks}
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
placeholder="例如600519,000858,002415"
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>
</Button>
<Button
colorScheme="purple"
onClick={handleAddEvent}
isDisabled={!newEvent.title}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -52,13 +52,13 @@ import {
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
import './InvestmentCalendar.css'; import './InvestmentCalendar.css';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
export default function InvestmentCalendarChakra() { export default function InvestmentCalendarChakra() {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -140,12 +140,12 @@ export default function InvestmentCalendarChakra() {
// 处理日期点击 // 处理日期点击
const handleDateClick = (info) => { const handleDateClick = (info) => {
const clickedDate = moment(info.date); const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
// 筛选当天的事件 // 筛选当天的事件
const dayEvents = events.filter(event => const dayEvents = events.filter(event =>
moment(event.start).isSame(clickedDate, 'day') dayjs(event.start).isSame(clickedDate, 'day')
); );
setSelectedDateEvents(dayEvents); setSelectedDateEvents(dayEvents);
onOpen(); onOpen();
@@ -154,7 +154,7 @@ export default function InvestmentCalendarChakra() {
// 处理事件点击 // 处理事件点击
const handleEventClick = (info) => { const handleEventClick = (info) => {
const event = info.event; const event = info.event;
const clickedDate = moment(event.start); const clickedDate = dayjs(event.start);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
setSelectedDateEvents([{ setSelectedDateEvents([{
title: event.title, title: event.title,
@@ -173,7 +173,7 @@ export default function InvestmentCalendarChakra() {
const eventData = { const eventData = {
...newEvent, ...newEvent,
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')), event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s), stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
}; };
@@ -274,7 +274,7 @@ export default function InvestmentCalendarChakra() {
size="sm" size="sm"
colorScheme="blue" colorScheme="blue"
leftIcon={<FiPlus />} leftIcon={<FiPlus />}
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }} onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
> >
添加计划 添加计划
</Button> </Button>

View File

@@ -66,13 +66,13 @@ import {
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
import '../components/InvestmentCalendar.css'; import '../components/InvestmentCalendar.css';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
// 创建 Context 用于跨标签页共享数据 // 创建 Context 用于跨标签页共享数据
const PlanningDataContext = createContext(); const PlanningDataContext = createContext();
@@ -232,11 +232,11 @@ function CalendarPanel() {
// 处理日期点击 // 处理日期点击
const handleDateClick = (info) => { const handleDateClick = (info) => {
const clickedDate = moment(info.date); const clickedDate = dayjs(info.date);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(event => const dayEvents = allEvents.filter(event =>
moment(event.event_date).isSame(clickedDate, 'day') dayjs(event.event_date).isSame(clickedDate, 'day')
); );
setSelectedDateEvents(dayEvents); setSelectedDateEvents(dayEvents);
onOpen(); onOpen();
@@ -245,11 +245,11 @@ function CalendarPanel() {
// 处理事件点击 // 处理事件点击
const handleEventClick = (info) => { const handleEventClick = (info) => {
const event = info.event; const event = info.event;
const clickedDate = moment(event.start); const clickedDate = dayjs(event.start);
setSelectedDate(clickedDate); setSelectedDate(clickedDate);
const dayEvents = allEvents.filter(ev => const dayEvents = allEvents.filter(ev =>
moment(ev.event_date).isSame(clickedDate, 'day') dayjs(ev.event_date).isSame(clickedDate, 'day')
); );
setSelectedDateEvents(dayEvents); setSelectedDateEvents(dayEvents);
onOpen(); onOpen();
@@ -262,7 +262,7 @@ function CalendarPanel() {
const eventData = { const eventData = {
...newEvent, ...newEvent,
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')), event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s), stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
}; };
@@ -368,7 +368,7 @@ function CalendarPanel() {
size="sm" size="sm"
colorScheme="purple" colorScheme="purple"
leftIcon={<FiPlus />} leftIcon={<FiPlus />}
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }} onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
> >
添加计划 添加计划
</Button> </Button>
@@ -619,7 +619,7 @@ function PlansPanel() {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'plan', type: 'plan',
@@ -638,13 +638,13 @@ function PlansPanel() {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
...item, ...item,
date: moment(item.event_date || item.date).format('YYYY-MM-DD'), date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
content: item.description || item.content || '', content: item.description || item.content || '',
}); });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'plan', type: 'plan',
@@ -795,7 +795,7 @@ function PlansPanel() {
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} /> <Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}> <Text fontSize="sm" color={secondaryText}>
{moment(item.event_date || item.date).format('YYYY年MM月DD日')} {dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text> </Text>
<Badge <Badge
colorScheme={statusInfo.color} colorScheme={statusInfo.color}
@@ -1043,7 +1043,7 @@ function ReviewsPanel() {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'review', type: 'review',
@@ -1062,13 +1062,13 @@ function ReviewsPanel() {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
...item, ...item,
date: moment(item.event_date || item.date).format('YYYY-MM-DD'), date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
content: item.description || item.content || '', content: item.description || item.content || '',
}); });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'review', type: 'review',
@@ -1205,7 +1205,7 @@ function ReviewsPanel() {
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} /> <Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}> <Text fontSize="sm" color={secondaryText}>
{moment(item.event_date || item.date).format('YYYY年MM月DD日')} {dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text> </Text>
</HStack> </HStack>
</VStack> </VStack>

View File

@@ -0,0 +1,203 @@
/**
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
*
* 性能优化:
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
* - 使用 TypeScript 提供类型安全
*
* 组件架构:
* - InvestmentPlanningCenter (主组件,~200 行)
* - CalendarPanel (日历面板,懒加载)
* - PlansPanel (计划面板,懒加载)
* - ReviewsPanel (复盘面板,懒加载)
* - PlanningContext (数据共享层)
*/
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
import {
Box,
Card,
CardHeader,
CardBody,
Heading,
HStack,
Flex,
Icon,
useColorModeValue,
useToast,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Spinner,
Center,
} from '@chakra-ui/react';
import {
FiCalendar,
FiTarget,
FiFileText,
} from 'react-icons/fi';
import { PlanningDataProvider } from './PlanningContext';
import type { InvestmentEvent, PlanningContextValue } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
import './InvestmentCalendar.css';
// 懒加载子面板组件(实现代码分割)
const CalendarPanel = lazy(() =>
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
);
const PlansPanel = lazy(() =>
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
);
const ReviewsPanel = lazy(() =>
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
);
/**
* 面板加载占位符
*/
const PanelLoadingFallback: React.FC = () => (
<Center py={12}>
<Spinner size="xl" color="purple.500" thickness="4px" />
</Center>
);
/**
* InvestmentPlanningCenter 主组件
*/
const InvestmentPlanningCenter: React.FC = () => {
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textColor = useColorModeValue('gray.700', 'white');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const cardBg = useColorModeValue('gray.50', 'gray.700');
// 全局数据状态
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<number>(0);
/**
* 加载所有事件数据(日历事件 + 计划 + 复盘)
*/
const loadAllData = useCallback(async (): Promise<void> => {
try {
setLoading(true);
const base = getApiBase();
const response = await fetch(base + '/api/account/calendar/events', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setAllEvents(data.data || []);
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
count: data.data?.length || 0
});
}
}
} catch (error) {
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
} finally {
setLoading(false);
}
}, []);
// 组件挂载时加载数据
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 提供给子组件的 Context 值
const contextValue: PlanningContextValue = {
allEvents,
setAllEvents,
loadAllData,
loading,
setLoading,
activeTab,
setActiveTab,
toast,
bgColor,
borderColor,
textColor,
secondaryText,
cardBg,
};
// 计算各类型事件数量
const planCount = allEvents.filter(e => e.type === 'plan').length;
const reviewCount = allEvents.filter(e => e.type === 'review').length;
return (
<PlanningDataProvider value={contextValue}>
<Card bg={bgColor} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiTarget} color="purple.500" boxSize={5} />
<Heading size="md"></Heading>
</HStack>
</Flex>
</CardHeader>
<CardBody pt={0}>
<Tabs
index={activeTab}
onChange={setActiveTab}
variant="enclosed"
colorScheme="purple"
>
<TabList>
<Tab>
<Icon as={FiCalendar} mr={2} />
</Tab>
<Tab>
<Icon as={FiTarget} mr={2} />
({planCount})
</Tab>
<Tab>
<Icon as={FiFileText} mr={2} />
({reviewCount})
</Tab>
</TabList>
<TabPanels>
{/* 日历视图面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<CalendarPanel />
</Suspense>
</TabPanel>
{/* 计划列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<PlansPanel />
</Suspense>
</TabPanel>
{/* 复盘列表面板 */}
<TabPanel px={0}>
<Suspense fallback={<PanelLoadingFallback />}>
<ReviewsPanel />
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
</PlanningDataProvider>
);
};
export default InvestmentPlanningCenter;

View File

@@ -60,12 +60,12 @@ import {
FiXCircle, FiXCircle,
FiAlertCircle, FiAlertCircle,
} from 'react-icons/fi'; } from 'react-icons/fi';
import moment from 'moment'; import dayjs from 'dayjs';
import 'moment/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig'; import { getApiBase } from '../../../utils/apiConfig';
moment.locale('zh-cn'); dayjs.locale('zh-cn');
export default function InvestmentPlansAndReviews({ type = 'both' }) { export default function InvestmentPlansAndReviews({ type = 'both' }) {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -83,7 +83,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: 'plan', type: 'plan',
@@ -134,12 +134,12 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
...item, ...item,
date: moment(item.date).format('YYYY-MM-DD'), date: dayjs(item.date).format('YYYY-MM-DD'),
}); });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
date: moment().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
title: '', title: '',
content: '', content: '',
type: itemType, type: itemType,
@@ -291,7 +291,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} /> <Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}> <Text fontSize="sm" color={secondaryText}>
{moment(item.date).format('YYYY年MM月DD日')} {dayjs(item.date).format('YYYY年MM月DD日')}
</Text> </Text>
<Badge <Badge
colorScheme={statusInfo.color} colorScheme={statusInfo.color}

View File

@@ -29,7 +29,7 @@ import {
} from 'react-icons/fi'; } from 'react-icons/fi';
import { eventService } from '../../../services/eventService'; import { eventService } from '../../../services/eventService';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import moment from 'moment'; import dayjs from 'dayjs';
export default function MyFutureEvents({ limit = 5 }) { export default function MyFutureEvents({ limit = 5 }) {
const [futureEvents, setFutureEvents] = useState([]); const [futureEvents, setFutureEvents] = useState([]);
@@ -51,7 +51,7 @@ export default function MyFutureEvents({ limit = 5 }) {
if (response.success) { if (response.success) {
// 按时间排序,最近的在前 // 按时间排序,最近的在前
const sortedEvents = (response.data || []).sort((a, b) => const sortedEvents = (response.data || []).sort((a, b) =>
moment(a.calendar_time).valueOf() - moment(b.calendar_time).valueOf() dayjs(a.calendar_time).valueOf() - dayjs(b.calendar_time).valueOf()
); );
setFutureEvents(sortedEvents); setFutureEvents(sortedEvents);
logger.debug('MyFutureEvents', '未来事件加载成功', { logger.debug('MyFutureEvents', '未来事件加载成功', {
@@ -98,8 +98,8 @@ export default function MyFutureEvents({ limit = 5 }) {
// 格式化时间 // 格式化时间
const formatEventTime = (time) => { const formatEventTime = (time) => {
const eventTime = moment(time); const eventTime = dayjs(time);
const now = moment(); const now = dayjs();
const daysDiff = eventTime.diff(now, 'days'); const daysDiff = eventTime.diff(now, 'days');
if (daysDiff === 0) { if (daysDiff === 0) {

View File

@@ -0,0 +1,60 @@
/**
* InvestmentPlanningCenter Context
* 用于在日历、计划、复盘三个面板间共享数据和状态
*/
import React, { createContext, useContext, ReactNode } from 'react';
import type { PlanningContextValue } from '@/types';
/**
* Planning Data Context
* 提供投资规划数据和操作方法
*/
const PlanningDataContext = createContext<PlanningContextValue | null>(null);
/**
* PlanningDataProvider Props
*/
interface PlanningDataProviderProps {
/** Context 值 */
value: PlanningContextValue;
/** 子组件 */
children: ReactNode;
}
/**
* PlanningDataProvider 组件
* 包裹需要访问投资规划数据的组件
*/
export const PlanningDataProvider: React.FC<PlanningDataProviderProps> = ({ value, children }) => {
return (
<PlanningDataContext.Provider value={value}>
{children}
</PlanningDataContext.Provider>
);
};
/**
* usePlanningData Hook
* 在子组件中访问投资规划数据
*
* @throws {Error} 如果在 PlanningDataProvider 外部调用
* @returns {PlanningContextValue} Context 值
*
* @example
* ```tsx
* function CalendarPanel() {
* const { allEvents, loading, toast } = usePlanningData();
* // ...
* }
* ```
*/
export const usePlanningData = (): PlanningContextValue => {
const context = useContext(PlanningDataContext);
if (!context) {
throw new Error('usePlanningData 必须在 PlanningDataProvider 内部使用');
}
return context;
};

View File

@@ -0,0 +1,506 @@
/**
* PlansPanel - 投资计划列表面板组件
* 显示、编辑和管理投资计划
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Grid,
Card,
CardBody,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Input,
InputGroup,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiTarget,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* PlansPanel 组件
* 计划列表面板,显示所有投资计划
*/
export const PlansPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
const [formData, setFormData] = useState<PlanFormData>({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>('');
// 筛选计划列表(排除系统事件)
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
if (item) {
setEditingItem(item);
setFormData({
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
title: item.title,
content: item.description || item.content || '',
type: 'plan',
stocks: item.stocks || [],
tags: item.tags || [],
status: item.status || 'active',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'plan',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async (): Promise<void> => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadAllData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('PlansPanel', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return;
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('PlansPanel', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = (): void => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = (): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态信息
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
const renderCard = (item: InvestmentEvent): JSX.Element => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={FiTarget} color="purple.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
aria-label="编辑计划"
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
aria-label="删除计划"
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="purple.500" />
</Center>
) : plans.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiTarget} boxSize={12} color="gray.300" />
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="purple"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{plans.map(renderCard)}
</Grid>
)}
</VStack>
{/* 编辑/新建模态框 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:布局新能源板块"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="详细描述您的投资计划..."
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.stocks || []).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.tags || []).map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="purple">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button
colorScheme="purple"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -0,0 +1,506 @@
/**
* ReviewsPanel - 投资复盘列表面板组件
* 显示、编辑和管理投资复盘
*/
import React, { useState } from 'react';
import {
Box,
Button,
Badge,
IconButton,
Flex,
Grid,
Card,
CardBody,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Input,
InputGroup,
InputLeftElement,
FormControl,
FormLabel,
Textarea,
Select,
Tag,
TagLabel,
TagLeftIcon,
TagCloseButton,
} from '@chakra-ui/react';
import {
FiPlus,
FiEdit2,
FiTrash2,
FiSave,
FiFileText,
FiCalendar,
FiTrendingUp,
FiHash,
FiCheckCircle,
FiXCircle,
FiAlertCircle,
} from 'react-icons/fi';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { usePlanningData } from './PlanningContext';
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
import { logger } from '@/utils/logger';
import { getApiBase } from '@/utils/apiConfig';
dayjs.locale('zh-cn');
/**
* 状态信息接口
*/
interface StatusInfo {
icon: React.ComponentType;
color: string;
text: string;
}
/**
* ReviewsPanel 组件
* 复盘列表面板,显示所有投资复盘
*/
export const ReviewsPanel: React.FC = () => {
const {
allEvents,
loadAllData,
loading,
toast,
textColor,
secondaryText,
cardBg,
borderColor,
} = usePlanningData();
const { isOpen, onOpen, onClose } = useDisclosure();
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
const [formData, setFormData] = useState<PlanFormData>({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
const [stockInput, setStockInput] = useState<string>('');
const [tagInput, setTagInput] = useState<string>('');
// 筛选复盘列表(排除系统事件)
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
// 打开编辑/新建模态框
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
if (item) {
setEditingItem(item);
setFormData({
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
title: item.title,
content: item.description || item.content || '',
type: 'review',
stocks: item.stocks || [],
tags: item.tags || [],
status: item.status || 'active',
});
} else {
setEditingItem(null);
setFormData({
date: dayjs().format('YYYY-MM-DD'),
title: '',
content: '',
type: 'review',
stocks: [],
tags: [],
status: 'active',
});
}
onOpen();
};
// 保存数据
const handleSave = async (): Promise<void> => {
try {
const base = getApiBase();
const url = editingItem
? base + `/api/account/investment-plans/${editingItem.id}`
: base + '/api/account/investment-plans';
const method = editingItem ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(formData),
});
if (response.ok) {
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
itemId: editingItem?.id,
title: formData.title,
});
toast({
title: editingItem ? '更新成功' : '创建成功',
status: 'success',
duration: 2000,
});
onClose();
loadAllData();
} else {
throw new Error('保存失败');
}
} catch (error) {
logger.error('ReviewsPanel', 'handleSave', error, {
itemId: editingItem?.id,
title: formData?.title
});
toast({
title: '保存失败',
description: '无法保存数据',
status: 'error',
duration: 3000,
});
}
};
// 删除数据
const handleDelete = async (id: number): Promise<void> => {
if (!window.confirm('确定要删除吗?')) return;
try {
const base = getApiBase();
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
logger.info('ReviewsPanel', '删除成功', { itemId: id });
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
loadAllData();
}
} catch (error) {
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
toast({
title: '删除失败',
status: 'error',
duration: 3000,
});
}
};
// 添加股票
const handleAddStock = (): void => {
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
setFormData({
...formData,
stocks: [...formData.stocks, stockInput.trim()],
});
setStockInput('');
}
};
// 添加标签
const handleAddTag = (): void => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
// 获取状态信息
const getStatusInfo = (status?: EventStatus): StatusInfo => {
switch (status) {
case 'completed':
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
case 'cancelled':
return { icon: FiXCircle, color: 'red', text: '已取消' };
default:
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
}
};
// 渲染单个卡片
const renderCard = (item: InvestmentEvent): JSX.Element => {
const statusInfo = getStatusInfo(item.status);
return (
<Card
key={item.id}
bg={cardBg}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="stretch" spacing={3}>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Icon as={FiFileText} color="green.500" />
<Text fontWeight="bold" fontSize="lg">
{item.title}
</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
<Text fontSize="sm" color={secondaryText}>
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
</Text>
<Badge
colorScheme={statusInfo.color}
variant="subtle"
>
{statusInfo.text}
</Badge>
</HStack>
</VStack>
<HStack>
<IconButton
icon={<FiEdit2 />}
size="sm"
variant="ghost"
onClick={() => handleOpenModal(item)}
aria-label="编辑复盘"
/>
<IconButton
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDelete(item.id)}
aria-label="删除复盘"
/>
</HStack>
</Flex>
{(item.content || item.description) && (
<Text fontSize="sm" color={textColor} noOfLines={3}>
{item.content || item.description}
</Text>
)}
<HStack spacing={2} flexWrap="wrap">
{item.stocks && item.stocks.length > 0 && (
<>
{item.stocks.map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
</Tag>
))}
</>
)}
{item.tags && item.tags.length > 0 && (
<>
{item.tags.map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
</Tag>
))}
</>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
return (
<Box>
<VStack align="stretch" spacing={4}>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</Flex>
{loading ? (
<Center py={8}>
<Spinner size="xl" color="green.500" />
</Center>
) : reviews.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiFileText} boxSize={12} color="gray.300" />
<Text color={secondaryText}></Text>
<Button
size="sm"
colorScheme="green"
leftIcon={<FiPlus />}
onClick={() => handleOpenModal(null)}
>
</Button>
</VStack>
</Center>
) : (
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
{reviews.map(renderCard)}
</Grid>
)}
</VStack>
{/* 编辑/新建模态框 */}
{isOpen && (
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{editingItem ? '编辑' : '新建'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel></FormLabel>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiCalendar} color={secondaryText} />
</InputLeftElement>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</InputGroup>
</FormControl>
<FormControl isRequired>
<FormLabel></FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:本周操作复盘"
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="详细记录您的投资复盘..."
rows={6}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={stockInput}
onChange={(e) => setStockInput(e.target.value)}
placeholder="输入股票代码"
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
/>
<Button onClick={handleAddStock}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.stocks || []).map((stock, idx) => (
<Tag key={idx} size="sm" colorScheme="blue">
<TagLeftIcon as={FiTrendingUp} />
<TagLabel>{stock}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
stocks: formData.stocks.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<HStack>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="输入标签"
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button onClick={handleAddTag}></Button>
</HStack>
<HStack mt={2} spacing={2} flexWrap="wrap">
{(formData.tags || []).map((tag, idx) => (
<Tag key={idx} size="sm" colorScheme="green">
<TagLeftIcon as={FiHash} />
<TagLabel>{tag}</TagLabel>
<TagCloseButton
onClick={() => setFormData({
...formData,
tags: formData.tags.filter((_, i) => i !== idx)
})}
/>
</Tag>
))}
</HStack>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
>
<option value="active"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</Select>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
</Button>
<Button
colorScheme="green"
onClick={handleSave}
isDisabled={!formData.title || !formData.date}
leftIcon={<FiSave />}
>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};

View File

@@ -30,7 +30,7 @@ import {
Divider Divider
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa'; import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
import moment from 'moment'; import dayjs from 'dayjs';
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具 import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
@@ -326,7 +326,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (typeof tradeDate === 'string') { if (typeof tradeDate === 'string') {
formattedTradeDate = tradeDate; formattedTradeDate = tradeDate;
} else if (tradeDate instanceof Date) { } else if (tradeDate instanceof Date) {
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD'); formattedTradeDate = dayjs(tradeDate).format('YYYY-MM-DD');
} else if (moment.isMoment(tradeDate)) { } else if (moment.isMoment(tradeDate)) {
formattedTradeDate = tradeDate.format('YYYY-MM-DD'); formattedTradeDate = tradeDate.format('YYYY-MM-DD');
} else { } else {
@@ -334,7 +334,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
tradeDate, tradeDate,
tradeDateType: typeof tradeDate tradeDateType: typeof tradeDate
}); });
formattedTradeDate = moment().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
const requestBody = { const requestBody = {
@@ -414,18 +414,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
// 检查是否是Date对象 // 检查是否是Date对象
if (eventTime instanceof Date) { if (eventTime instanceof Date) {
eventMoment = moment(eventTime); eventMoment = dayjs(eventTime);
} else if (typeof eventTime === 'string') { } else if (typeof eventTime === 'string') {
eventMoment = moment(eventTime); eventMoment = dayjs(eventTime);
} else if (typeof eventTime === 'number') { } else if (typeof eventTime === 'number') {
eventMoment = moment(eventTime); eventMoment = dayjs(eventTime);
} else { } else {
logger.warn('RelatedConcepts', '未知的事件时间格式', { logger.warn('RelatedConcepts', '未知的事件时间格式', {
eventTime, eventTime,
eventTimeType: typeof eventTime, eventTimeType: typeof eventTime,
eventId eventId
}); });
eventMoment = moment(); eventMoment = dayjs();
} }
// 确保moment对象有效 // 确保moment对象有效
@@ -434,7 +434,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
eventTime, eventTime,
eventId eventId
}); });
eventMoment = moment(); eventMoment = dayjs();
} }
formattedDate = eventMoment.format('YYYY-MM-DD'); formattedDate = eventMoment.format('YYYY-MM-DD');
@@ -448,7 +448,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (typeof nextTradingDay === 'string') { if (typeof nextTradingDay === 'string') {
formattedDate = nextTradingDay; formattedDate = nextTradingDay;
} else if (nextTradingDay instanceof Date) { } else if (nextTradingDay instanceof Date) {
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD'); formattedDate = dayjs(nextTradingDay).format('YYYY-MM-DD');
} else { } else {
logger.warn('RelatedConcepts', '交易日工具返回了无效格式', { logger.warn('RelatedConcepts', '交易日工具返回了无效格式', {
nextTradingDay, nextTradingDay,
@@ -476,16 +476,16 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
if (typeof currentTradingDay === 'string') { if (typeof currentTradingDay === 'string') {
formattedDate = currentTradingDay; formattedDate = currentTradingDay;
} else if (currentTradingDay instanceof Date) { } else if (currentTradingDay instanceof Date) {
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD'); formattedDate = dayjs(currentTradingDay).format('YYYY-MM-DD');
} else { } else {
logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', { logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', {
currentTradingDay, currentTradingDay,
eventId eventId
}); });
formattedDate = moment().format('YYYY-MM-DD'); formattedDate = dayjs().format('YYYY-MM-DD');
} }
} else { } else {
formattedDate = moment().format('YYYY-MM-DD'); formattedDate = dayjs().format('YYYY-MM-DD');
} }
} }
@@ -558,9 +558,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
<FaCalendarAlt color={textColor} /> <FaCalendarAlt color={textColor} />
<Text fontSize="sm" color={textColor}> <Text fontSize="sm" color={textColor}>
涨跌幅数据日期{effectiveTradingDate} 涨跌幅数据日期{effectiveTradingDate}
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && ( {eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
<Text as="span" ml={2} fontSize="xs"> <Text as="span" ml={2} fontSize="xs">
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime}显示下一交易日数据) (事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : eventTime}显示下一交易日数据)
</Text> </Text>
)} )}
</Text> </Text>

View File

@@ -195,9 +195,12 @@ const EnhancedCalendar = ({
onClick={() => onDateChange(date)} onClick={() => onDateChange(date)}
transition="all 0.2s" transition="all 0.2s"
cursor="pointer" cursor="pointer"
display="flex"
alignItems="center"
justifyContent="center"
> >
<Text <Text
fontSize={compact ? 'md' : 'lg'} fontSize={compact ? 'lg' : 'xl'}
fontWeight={isToday || isSelected ? 'bold' : 'normal'} fontWeight={isToday || isSelected ? 'bold' : 'normal'}
color={isSelected ? 'blue.600' : 'gray.700'} color={isSelected ? 'blue.600' : 'gray.700'}
> >
@@ -206,13 +209,13 @@ const EnhancedCalendar = ({
{hasData && ( {hasData && (
<Badge <Badge
position="absolute" position="absolute"
top="2px" top="4px"
right="2px" right="4px"
size={compact ? 'sm' : 'md'} size={compact ? 'sm' : 'md'}
colorScheme={getDateBadgeColor(dateData.count)} colorScheme={getDateBadgeColor(dateData.count)}
fontSize={compact ? '10px' : '11px'} fontSize={compact ? '9px' : '10px'}
px={compact ? 1 : 2} px={compact ? 1 : 2}
minW={compact ? '22px' : '28px'} minW={compact ? '20px' : '24px'}
borderRadius="full" borderRadius="full"
> >
{dateData.count} {dateData.count}
@@ -221,7 +224,7 @@ const EnhancedCalendar = ({
{isToday && ( {isToday && (
<Text <Text
position="absolute" position="absolute"
bottom="2px" bottom="4px"
left="50%" left="50%"
transform="translateX(-50%)" transform="translateX(-50%)"
fontSize={compact ? '9px' : '10px'} fontSize={compact ? '9px' : '10px'}

View File

@@ -444,7 +444,6 @@ export default function LimitAnalyse() {
borderColor="whiteAlpha.300" borderColor="whiteAlpha.300"
backdropFilter="saturate(180%) blur(10px)" backdropFilter="saturate(180%) blur(10px)"
w="full" w="full"
minH="420px"
> >
<CardBody p={4}> <CardBody p={4}>
<EnhancedCalendar <EnhancedCalendar
@@ -453,8 +452,9 @@ export default function LimitAnalyse() {
availableDates={availableDates} availableDates={availableDates}
compact compact
hideSelectionInfo hideSelectionInfo
hideLegend
width="100%" width="100%"
cellHeight={10} cellHeight={16}
/> />
</CardBody> </CardBody>
</Card> </Card>

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 React from 'react';
import SubscriptionContent from 'components/Subscription/SubscriptionContent'; import SubscriptionContentNew from 'components/Subscription/SubscriptionContentNew';
function Subscription() { function Subscription() {
return ( return (
<Flex direction='column'> <SubscriptionContentNew />
<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>
); );
} }

View File

@@ -0,0 +1,221 @@
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: '当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。\n\n计算方式\n• 剩余价值 = 原套餐价格 × (剩余天数 / 总天数)\n• 实付金额 = 新套餐价格 - 剩余价值 - 优惠码折扣\n\n例如您购买了年付Pro版¥2699,使用了180天后升级到Max版¥5399/年),剩余价值约¥1350将自动抵扣,实付约¥4049。',
},
{
question: '可以在不同计费周期之间切换吗?',
answer: '可以。您可以随时更改计费周期。如果从短期切换到长期,系统会计算剩余价值并应用到新的订阅中。长期套餐(季付、半年付、年付)可享受更大的折扣优惠。',
},
{
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;

100
update_pricing_options.sql Normal file
View File

@@ -0,0 +1,100 @@
-- ============================================
-- 更新订阅套餐价格配置
-- 用途:为 subscription_plans 表添加季付、半年付价格
-- 日期2025-11-19
-- ============================================
-- 更新 Pro 专业版的 pricing_options
UPDATE subscription_plans
SET pricing_options = JSON_ARRAY(
JSON_OBJECT(
'months', 1,
'price', 299.00,
'label', '月付',
'cycle_key', 'monthly',
'discount_percent', 0
),
JSON_OBJECT(
'months', 3,
'price', 799.00,
'label', '季付',
'cycle_key', 'quarterly',
'discount_percent', 11,
'original_price', 897.00
),
JSON_OBJECT(
'months', 6,
'price', 1499.00,
'label', '半年付',
'cycle_key', 'semiannual',
'discount_percent', 16,
'original_price', 1794.00
),
JSON_OBJECT(
'months', 12,
'price', 2699.00,
'label', '年付',
'cycle_key', 'yearly',
'discount_percent', 25,
'original_price', 3588.00
)
)
WHERE name = 'pro';
-- 更新 Max 旗舰版的 pricing_options
UPDATE subscription_plans
SET pricing_options = JSON_ARRAY(
JSON_OBJECT(
'months', 1,
'price', 599.00,
'label', '月付',
'cycle_key', 'monthly',
'discount_percent', 0
),
JSON_OBJECT(
'months', 3,
'price', 1599.00,
'label', '季付',
'cycle_key', 'quarterly',
'discount_percent', 11,
'original_price', 1797.00
),
JSON_OBJECT(
'months', 6,
'price', 2999.00,
'label', '半年付',
'cycle_key', 'semiannual',
'discount_percent', 17,
'original_price', 3594.00
),
JSON_OBJECT(
'months', 12,
'price', 5399.00,
'label', '年付',
'cycle_key', 'yearly',
'discount_percent', 25,
'original_price', 7188.00
)
)
WHERE name = 'max';
-- 验证更新结果
SELECT
name AS '套餐',
display_name AS '显示名称',
pricing_options AS '价格配置'
FROM subscription_plans
WHERE name IN ('pro', 'max');
-- 完成提示
SELECT '价格配置已更新!' AS '状态';
SELECT '新价格:' AS '';
SELECT ' Pro 月付: ¥299' AS '';
SELECT ' Pro 季付: ¥799 (省11%)' AS '';
SELECT ' Pro 半年付: ¥1499 (省16%)' AS '';
SELECT ' Pro 年付: ¥2699 (省25%)' AS '';
SELECT '' AS '';
SELECT ' Max 月付: ¥599' AS '';
SELECT ' Max 季付: ¥1599 (省11%)' AS '';
SELECT ' Max 半年付: ¥2999 (省17%)' AS '';
SELECT ' Max 年付: ¥5399 (省25%)' AS '';