Compare commits

..

17 Commits

Author SHA1 Message Date
zdl
163c55f819 refactor: 重构 NotificationContext.js 日志系统,替换所有 console 调用为 logger
## 主要改动
- 将 47 处 console.log/warn/error 全部替换为 logger 方法
- 删除 6 处装饰性分隔线(%c════════)
- 合并冗余日志,优化日志结构
- 减少代码行数:1021 → 1003(-18 行)

## 日志分类
- logger.info (43 处):重要操作(连接、订阅、通知发送)
- logger.debug (17 处):详细调试信息(数据内容、中间状态)
- logger.warn (5 处):警告信息(权限问题、重复事件、断开连接)
- logger.error (9 处):错误信息(失败、异常、Ref 未初始化)

## 优化效果
-  生产环境可通过 REACT_APP_ENABLE_DEBUG 控制日志输出
-  日志更规范,带时间戳和统一格式
-  console.log 调用:47 → 0(100% 清理完成)
-  代码更清洁,无装饰性代码

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:10:41 +08:00
zdl
990d1ca0bc perf: 使用 React.memo 优化社区组件渲染性能
**优化目标**:
- 减少组件卸载次数:从 6 次/刷新 → 1-2 次/刷新(↓ 66-83%)
- 减少渲染次数:从 9 次/刷新 → 4-5 次/刷新(↓ 44-55%)

**优化组件**(共 7 个):
1.  ModeToggleButtons.js - 简单 UI 组件
2.  DynamicNewsEventCard.js - 平铺模式卡片(被渲染 30+ 次)
3.  HorizontalDynamicNewsEventCard.js - 纵向模式卡片(被渲染 10+ 次)
4.  VerticalModeLayout.js - 布局组件
5.  EventScrollList.js - 列表组件
6.  VirtualizedFourRowGrid.js - 虚拟化网格(forwardRef)
7.  DynamicNewsCard.js - 主组件(forwardRef)

**技术实现**:
- 普通组件:`React.memo(Component)`
- forwardRef 组件:`React.memo(forwardRef(...))`
- 所有回调函数已使用 useCallback 确保引用稳定

**预期效果**:
- 列表渲染的卡片组件收益最大(减少 90% 重渲染)
- 布局组件渲染次数从 9 次降到 1 次(减少 88%)
- 整体用户体验更流畅,无明显卡顿

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 15:00:46 +08:00
zdl
3fe2d2bdc9 pref: 删除NotificationTestTool 组件, notificationDebugger.js 调试工具删除完成 2025-11-17 14:59:39 +08:00
zdl
a9f0c5ced2 pref: 删除测试 API(优先)通知 2025-11-17 14:47:24 +08:00
zdl
9b355b402d feat: 升级 logger logger 支持环境变量控制 2025-11-17 14:33:39 +08:00
zdl
3cadd02492 fix: 修复 Socket 新事件通知后不刷新列表的问题
问题描述:
- 用户在第1页时收到新事件 Socket 通知
- 系统调用 handlePageChange(1) 想要刷新列表
- 但列表未刷新,新事件不显示
- 控制台日志显示"⚠️ 重复点击当前页: 1"

根本原因:
- usePagination 的 handlePageChange 函数有"重复点击"检查
- 当 newPage === currentPage 时直接 return(第 169-174 行)
- Socket 新事件触发刷新时,当前在第1页,调用 handlePageChange(1)
- 函数误认为是"用户重复点击分页按钮",阻止了刷新

设计冲突:
- 原始设计:防止用户重复点击分页按钮,避免不必要的 API 请求 
- 副作用:阻止了 Socket 新事件触发的强制刷新逻辑 

修复方案(添加 force 参数):
1. 修改 handlePageChange 函数签名:(newPage, force = false)
   - force = true: 强制刷新(绕过"重复点击"检查)
   - force = false: 正常翻页(保留原有检查)

2. 修改边界检查逻辑(第 173-184 行):
   - 只有在非强制模式下才检查重复点击
   - 强制模式下,即使页码相同也继续执行
   - 添加日志:🔄 [翻页] 强制刷新当前页

3. 修改 Socket 新事件刷新调用(DynamicNewsCard.js:231):
   - 修改前:handlePageChange(1)
   - 修改后:handlePageChange(1, true)  // force = true

修改文件:
- src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
  - 第 161 行:修改函数签名(添加 force 参数)
  - 第 173-184 行:修改边界检查逻辑(添加 force 判断)

- src/views/Community/components/DynamicNewsCard.js
  - 第 230-231 行:修改 handlePageChange 调用(传递 force: true)

修复效果:
-  收到新事件后,第1页强制刷新并显示新事件
-  保留原有的"重复点击"优化(普通翻页)
-  不影响其他页面的用户体验(第2页及以上不打断)
-  清晰的日志区分:
  - 🔄 [翻页] 强制刷新当前页: 1(强制刷新)
  - ⚠️ [翻页] 重复点击当前页: 2(阻止重复点击)

🤖 Generated with Claude Code
2025-11-17 12:12:01 +08:00
zdl
d69a32a320 fix: 修复个人中心不显示新发表的评论问题
问题描述:
- 用户在事件中心发表评论后,打开个人中心看不到新评论
- 个人中心"我的评论"区域始终为空或显示旧数据

根本原因:
- 项目存在两套独立的评论系统:
  1. 旧系统(EventComment 表)- 个人中心查询此表
  2. 新系统(Post 表)- 事件中心写入此表
- 创建评论时写入 Post 表,但个人中心查询 EventComment 表
- 两个表完全独立,数据不同步

修复方案(统一到 Post 系统):
1. 后端新增 API:GET /api/account/events/posts
   - 查询 Post 表中当前用户的所有评论
   - 返回格式完全兼容旧 EventComment.to_dict()
   - 新增 event_title 字段(改进点,旧 API 没有)

2. 前端修改 API 调用:Center.js
   - 将 /api/account/events/comments 改为 /api/account/events/posts
   - 无需修改数据渲染逻辑(格式兼容)

修改文件:
- app.py (第 4144-4187 行) - 新增 get_my_event_posts API
  - 查询 Post 表(user_id 过滤 + 按时间倒序)
  - JOIN 查询关联的 Event(获取 event_title)
  - 返回兼容格式:author(字符串), likes, created_at, event_title

- src/views/Dashboard/Center.js (第 105 行) - 修改 API 调用路径
  - 修改前:GET /api/account/events/comments
  - 修改后:GET /api/account/events/posts

数据兼容性:
- author 字段:字符串类型(与旧 EventComment 一致)
- likes 字段:映射自 likes_count
- created_at 字段:ISO 8601 格式
- 新增:event_title 字段(个人中心可显示评论关联的事件)

修复效果:
- 用户在事件中心发表评论 → 立即在个人中心看到新评论 
- 评论显示完整信息:内容、时间、关联事件标题 
- 前端无需修改渲染逻辑(完全兼容) 

🤖 Generated with Claude Code
2025-11-17 11:25:18 +08:00
zdl
8d3327e4dd fix: 修复评论用户名显示不一致问题(乐观更新显示正确,刷新后显示 Anonymous)
问题描述:
- 用户发表评论时,乐观更新显示用户名 "zdl"
- 但 API 返回后,用户名变成 "Anonymous"
- 刷新页面后,用户名仍然是 "Anonymous"

根本原因:
- 前端代码期望评论对象包含 `author` 字段(src/types/comment.ts)
- 后端 API 返回的是 `user` 字段(app.py:7710-7714)
- 前端渲染时读取 comment.author?.username(CommentItem.js:72)
- 因为 comment.author 不存在,所以显示 'Anonymous'

修复方案:
- 在 eventService.getPosts 中添加数据转换逻辑
- 将后端返回的 user 字段映射为前端期望的 author 字段
- 兼容 avatar_url 和 avatar 两种字段名
- 处理 user 为 null 的边界情况(显示 Anonymous)

影响范围:
- src/services/eventService.js - getPosts 函数数据转换
- 所有使用 getPosts API 的组件都会受益于此修复
- 保持类型定义不变,符合业务语义

修复效果:
- 乐观更新显示:zdl 刚刚 打卡
- API 返回后显示:zdl 刚刚 打卡 (之前会变成 Anonymous)
- 刷新页面后显示:zdl XX分钟前 打卡 

🤖 Generated with Claude Code
2025-11-17 11:08:59 +08:00
zdl
3a02c13dfe fix: 修复评论在所有事件中串联显示的严重 Bug
问题描述:
- 在事件 A 下发表评论后,该评论会出现在事件 B、C 等所有事件下
- 切换事件时,评论列表没有重新加载,导致数据混乱

根本原因:
- usePagination Hook 的 useEffect 只依赖 autoLoad(常量)
- 当 eventId 变化时,loadCommentsFunction 被重新创建(包含新的 eventId)
- 但 useEffect 不会重新执行,导致旧数据(上一个事件的评论)持续显示

修复方案:
- 在 useEffect 依赖数组中添加 loadFunction
- 当 loadFunction 变化时(eventId 变化 → loadCommentsFunction 变化)
- useEffect 重新执行,加载新事件的评论数据

影响范围:
- EventCommentSection 组件(评论区)
- 所有使用 usePagination Hook 的组件都会受益于此修复
- 确保数据隔离性和正确性

🤖 Generated with Claude Code
2025-11-17 10:30:57 +08:00
d28915ac90 add forum 2025-11-15 10:09:17 +08:00
b2f3a8f140 add forum 2025-11-15 09:50:55 +08:00
3014317c12 add forum 2025-11-15 09:15:54 +08:00
2013a0f868 Merge branch 'feature_bugfix/251113_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251113_ui 2025-11-15 09:11:57 +08:00
05b497de29 add forum 2025-11-15 09:10:26 +08:00
zdl
d9013d1e85 Merge branch 'feature_bugfix/251113_bugfix' into feature_bugfix/251113_ui
* feature_bugfix/251113_bugfix:
  feat: 实现 Socket 触发的智能列表自动刷新功能(带防抖)
  feat: 实现评论分页功能并迁移到 TypeScript
  feat: 接入Ts配置
  feat: 添加评论功能
2025-11-14 19:04:45 +08:00
2753fbc37f update ui 2025-11-14 18:48:39 +08:00
43de7f7a52 update ui 2025-11-14 18:03:55 +08:00
38 changed files with 3657 additions and 26206 deletions

69
app.py
View File

@@ -1602,7 +1602,7 @@ def calculate_subscription_price():
data = request.get_json()
to_plan = data.get('to_plan')
to_cycle = data.get('to_cycle')
promo_code = data.get('promo_code', '').strip() or None
promo_code = (data.get('promo_code') or '').strip() or None
if not to_plan or not to_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
@@ -1638,7 +1638,7 @@ def create_payment_order():
data = request.get_json()
plan_name = data.get('plan_name')
billing_cycle = data.get('billing_cycle')
promo_code = data.get('promo_code', '').strip() or None
promo_code = (data.get('promo_code') or '').strip() or None
if not plan_name or not billing_cycle:
return jsonify({'success': False, 'error': '参数不完整'}), 400
@@ -3424,8 +3424,20 @@ def login_with_wechat():
# 更新最后登录时间
user.update_last_seen()
# 清除session
del wechat_qr_sessions[session_id]
# ✅ 修复不立即删除session而是标记为已完成避免轮询报错
# 原因:前端可能还在轮询检查状态,立即删除会导致 "无效的session" 错误
# 保留原状态login_ready/register_ready前端会正确处理
# wechat_qr_sessions[session_id]['status'] 保持不变
# 设置延迟删除10秒后自动清理给前端足够时间完成轮询
import threading
def delayed_cleanup():
import time
time.sleep(10)
if session_id in wechat_qr_sessions:
del wechat_qr_sessions[session_id]
print(f"✅ 延迟清理微信登录session: {session_id[:8]}...")
threading.Thread(target=delayed_cleanup, daemon=True).start()
# 生成登录响应
response_data = {
@@ -3442,7 +3454,8 @@ def login_with_wechat():
'wechat_union_id': user.wechat_union_id,
'created_at': user.created_at.isoformat() if user.created_at else None,
'last_seen': user.last_seen.isoformat() if user.last_seen else None
}
},
'isNewUser': session['status'] == 'register_ready' # 标记是否为新用户
}
# 如果需要token认证可以在这里生成
@@ -4128,6 +4141,52 @@ def get_my_event_comments():
return jsonify({'success': True, 'data': [c.to_dict() for c in comments]})
@app.route('/api/account/events/posts', methods=['GET'])
def get_my_event_posts():
"""获取我在事件上的帖子Post- 用于个人中心显示"""
if 'user_id' not in session:
return jsonify({'success': False, 'error': '未登录'}), 401
try:
# 查询当前用户的所有 Post按创建时间倒序
posts = Post.query.filter_by(
user_id=session['user_id'],
status='active'
).order_by(Post.created_at.desc()).limit(100).all()
posts_data = []
for post in posts:
# 获取关联的事件信息
event = Event.query.get(post.event_id)
event_title = event.title if event else '未知事件'
# 获取用户信息
user = User.query.get(post.user_id)
author = user.username if user else '匿名用户'
# ⚡ 返回格式兼容旧 EventComment.to_dict()
posts_data.append({
'id': post.id,
'event_id': post.event_id,
'event_title': event_title, # ⚡ 新增字段(旧 API 没有)
'user_id': post.user_id,
'author': author, # ⚡ 兼容旧格式(字符串类型)
'content': post.content,
'title': post.title, # Post 独有字段(可选)
'content_type': post.content_type, # Post 独有字段
'likes': post.likes_count, # ⚡ 兼容旧字段名
'created_at': post.created_at.isoformat(),
'updated_at': post.updated_at.isoformat(),
'status': post.status,
})
return jsonify({'success': True, 'data': posts_data})
except Exception as e:
print(f"获取用户帖子失败: {e}")
return jsonify({'success': False, 'error': '获取帖子失败'}), 500
@app.route('/api/account/future-events/following', methods=['GET'])
def get_my_following_future_events():
"""获取当前用户关注的未来事件"""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

145
init-forum-es.js Normal file
View File

@@ -0,0 +1,145 @@
/**
* 初始化价值论坛 Elasticsearch 索引
* 运行方式node init-forum-es.js
*/
const axios = require('axios');
// Elasticsearch 配置
const ES_BASE_URL = 'http://222.128.1.157:19200';
// 创建 axios 实例
const esClient = axios.create({
baseURL: ES_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// 索引名称
const INDICES = {
POSTS: 'forum_posts',
COMMENTS: 'forum_comments',
EVENTS: 'forum_events',
};
async function initializeIndices() {
try {
console.log('开始初始化 Elasticsearch 索引...\n');
// 1. 创建帖子索引
console.log('创建帖子索引 (forum_posts)...');
try {
await esClient.put(`/${INDICES.POSTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
title: { type: 'text' },
content: { type: 'text' },
images: { type: 'keyword' },
tags: { type: 'keyword' },
category: { type: 'keyword' },
likes_count: { type: 'integer' },
comments_count: { type: 'integer' },
views_count: { type: 'integer' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
is_pinned: { type: 'boolean' },
status: { type: 'keyword' },
},
},
});
console.log('✅ 帖子索引创建成功\n');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('⚠️ 帖子索引已存在,跳过创建\n');
} else {
throw error;
}
}
// 2. 创建评论索引
console.log('创建评论索引 (forum_comments)...');
try {
await esClient.put(`/${INDICES.COMMENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
content: { type: 'text' },
parent_id: { type: 'keyword' },
likes_count: { type: 'integer' },
created_at: { type: 'date' },
status: { type: 'keyword' },
},
},
});
console.log('✅ 评论索引创建成功\n');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('⚠️ 评论索引已存在,跳过创建\n');
} else {
throw error;
}
}
// 3. 创建事件时间轴索引
console.log('创建事件时间轴索引 (forum_events)...');
try {
await esClient.put(`/${INDICES.EVENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
event_type: { type: 'keyword' },
title: { type: 'text' },
description: { type: 'text' },
source: { type: 'keyword' },
source_url: { type: 'keyword' },
related_stocks: { type: 'keyword' },
occurred_at: { type: 'date' },
created_at: { type: 'date' },
importance: { type: 'keyword' },
},
},
});
console.log('✅ 事件时间轴索引创建成功\n');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('⚠️ 事件时间轴索引已存在,跳过创建\n');
} else {
throw error;
}
}
// 4. 验证索引
console.log('验证索引...');
const indices = await esClient.get('/_cat/indices/forum_*?v&format=json');
console.log('已创建的论坛索引:');
indices.data.forEach(index => {
console.log(` - ${index.index} (docs: ${index['docs.count']}, size: ${index['store.size']})`);
});
console.log('\n🎉 所有索引初始化完成!');
console.log('\n下一步');
console.log('1. 访问 https://valuefrontier.cn/value-forum');
console.log('2. 点击"发布帖子"按钮创建第一篇帖子');
} catch (error) {
console.error('❌ 初始化失败:', error.message);
if (error.response) {
console.error('响应数据:', JSON.stringify(error.response.data, null, 2));
}
process.exit(1);
}
}
// 执行初始化
initializeIndices();

View File

@@ -78,7 +78,8 @@
"styled-components": "^5.3.11",
"stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1",
"tsparticles-slim": "^2.12.0"
"tsparticles-slim": "^2.12.0",
"typescript": "^5.9.3"
},
"resolutions": {
"react-error-overlay": "6.0.9",
@@ -138,7 +139,6 @@
"react-error-overlay": "6.0.9",
"sharp": "^0.34.4",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^4.10.2",
"yn": "^5.1.0"
},

View File

@@ -0,0 +1,96 @@
#!/bin/bash
# 初始化价值论坛 Elasticsearch 索引
# 使用 Nginx 代理或直连 ES
set -e
echo "🚀 开始初始化价值论坛 Elasticsearch 索引..."
echo ""
# ES 地址(根据环境选择)
if [ -n "$USE_PROXY" ]; then
ES_URL="https://valuefrontier.cn/es-api"
echo "📡 使用 Nginx 代理: $ES_URL"
else
ES_URL="http://222.128.1.157:19200"
echo "📡 直连 Elasticsearch: $ES_URL"
fi
echo ""
# 1. 创建帖子索引
echo "📝 创建帖子索引 (forum_posts)..."
curl -X PUT "$ES_URL/forum_posts" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"author_id": { "type": "keyword" },
"author_name": { "type": "text" },
"author_avatar": { "type": "keyword" },
"title": { "type": "text" },
"content": { "type": "text" },
"images": { "type": "keyword" },
"tags": { "type": "keyword" },
"category": { "type": "keyword" },
"likes_count": { "type": "integer" },
"comments_count": { "type": "integer" },
"views_count": { "type": "integer" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"is_pinned": { "type": "boolean" },
"status": { "type": "keyword" }
}
}
}' 2>/dev/null && echo "✅ 帖子索引创建成功" || echo "⚠️ 帖子索引已存在或创建失败"
echo ""
# 2. 创建评论索引
echo "💬 创建评论索引 (forum_comments)..."
curl -X PUT "$ES_URL/forum_comments" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"post_id": { "type": "keyword" },
"author_id": { "type": "keyword" },
"author_name": { "type": "text" },
"author_avatar": { "type": "keyword" },
"content": { "type": "text" },
"parent_id": { "type": "keyword" },
"likes_count": { "type": "integer" },
"created_at": { "type": "date" },
"status": { "type": "keyword" }
}
}
}' 2>/dev/null && echo "✅ 评论索引创建成功" || echo "⚠️ 评论索引已存在或创建失败"
echo ""
# 3. 创建事件时间轴索引
echo "⏰ 创建事件时间轴索引 (forum_events)..."
curl -X PUT "$ES_URL/forum_events" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"id": { "type": "keyword" },
"post_id": { "type": "keyword" },
"event_type": { "type": "keyword" },
"title": { "type": "text" },
"description": { "type": "text" },
"source": { "type": "keyword" },
"source_url": { "type": "keyword" },
"related_stocks": { "type": "keyword" },
"occurred_at": { "type": "date" },
"created_at": { "type": "date" },
"importance": { "type": "keyword" }
}
}
}' 2>/dev/null && echo "✅ 事件时间轴索引创建成功" || echo "⚠️ 事件时间轴索引已存在或创建失败"
echo ""
# 4. 验证索引
echo "🔍 验证已创建的索引..."
curl -X GET "$ES_URL/_cat/indices/forum_*?v" 2>/dev/null
echo ""
echo "🎉 初始化完成!"
echo ""
echo "下一步:"
echo " 1. 访问 https://valuefrontier.cn/value-forum"
echo " 2. 点击"发布帖子"按钮创建第一篇帖子"

View File

@@ -9,7 +9,6 @@ import { logger } from '../utils/logger';
// Global Components
import AuthModalManager from './Auth/AuthModalManager';
import NotificationContainer from './NotificationContainer';
import NotificationTestTool from './NotificationTestTool';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
@@ -71,7 +70,6 @@ function ConnectionStatusBarWrapper() {
* - ScrollToTop: 路由切换时自动滚动到顶部
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - NotificationTestTool: 通知测试工具 (仅开发环境)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
*/
export function GlobalComponents() {
@@ -92,9 +90,6 @@ export function GlobalComponents() {
{/* 通知容器 */}
<NotificationContainer />
{/* 通知测试工具 (仅开发环境) */}
<NotificationTestTool />
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && (
<BytedeskWidget

View File

@@ -264,15 +264,20 @@ const MobileDrawer = memo(({
</HStack>
</Link>
<Link
onClick={() => handleNavigate('/value-forum')}
py={1}
px={3}
borderRadius="md"
_hover={{}}
cursor="not-allowed"
color="gray.400"
pointerEvents="none"
_hover={{ bg: 'gray.50' }}
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
>
<Text fontSize="sm" color="gray.400">今日热议</Text>
<HStack justify="space-between">
<Text fontSize="sm">价值论坛</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="yellow">黑金</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</HStack>
</Link>
<Link
py={1}

View File

@@ -239,11 +239,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Flex>
</MenuItem>
<MenuItem
isDisabled
cursor="not-allowed"
color="gray.400"
onClick={() => {
navEvents.trackMenuItemClicked('价值论坛', 'dropdown', '/value-forum');
navigate('/value-forum');
}}
borderRadius="md"
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/value-forum') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/value-forum') ? 'bold' : 'normal'}
>
<Text fontSize="sm" color="gray.400">今日热议</Text>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">价值论坛</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="yellow">黑金</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
isDisabled

View File

@@ -155,8 +155,21 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</HStack>
</Flex>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">今日热议</Text>
<MenuItem
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/value-forum');
}}
borderRadius="md"
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">价值论坛</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="yellow">黑金</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">个股社区</Text>

View File

@@ -1,663 +0,0 @@
// src/components/NotificationTestTool/index.js
/**
* 金融资讯通知测试工具 - 仅在开发环境显示
* 用于手动测试4种通知类型
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
VStack,
HStack,
Text,
IconButton,
Collapse,
useDisclosure,
Badge,
Divider,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Code,
UnorderedList,
ListItem,
} from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
const NotificationTestTool = () => {
// 只在开发环境显示 - 必须在所有 Hooks 调用之前检查
if (process.env.NODE_ENV !== 'development') {
return null;
}
const { isOpen, onToggle } = useDisclosure();
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
const [testCount, setTestCount] = useState(0);
// 测试状态
const [isTestingNotification, setIsTestingNotification] = useState(false);
const [testCountdown, setTestCountdown] = useState(0);
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
// 系统环境检测
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
// 故障排查面板
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
// 检测系统环境
useEffect(() => {
// 检测是否为 macOS
const platform = navigator.platform.toLowerCase();
setIsMacOS(platform.includes('mac'));
// 检测全屏状态
const checkFullscreen = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', checkFullscreen);
checkFullscreen();
return () => {
document.removeEventListener('fullscreenchange', checkFullscreen);
};
}, []);
// 倒计时逻辑
useEffect(() => {
if (testCountdown > 0) {
const timer = setTimeout(() => {
setTestCountdown(testCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (testCountdown === 0 && isTestingNotification) {
// 倒计时结束,询问用户
setIsTestingNotification(false);
// 延迟一下再询问,确保用户有时间看到通知
setTimeout(() => {
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
setNotificationShown(sawNotification);
if (!sawNotification) {
// 没看到通知,展开故障排查面板
if (!isTroubleshootOpen) {
onTroubleshootToggle();
}
}
}, 500);
}
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
// 浏览器权限状态标签
const getPermissionLabel = () => {
switch (browserPermission) {
case 'granted':
return '已授权';
case 'denied':
return '已拒绝';
case 'default':
return '未授权';
default:
return '不支持';
}
};
const getPermissionColor = () => {
switch (browserPermission) {
case 'granted':
return 'green';
case 'denied':
return 'red';
case 'default':
return 'gray';
default:
return 'gray';
}
};
// 请求浏览器权限
const handleRequestPermission = async () => {
await requestBrowserPermission();
};
// 公告通知测试数据
const testAnnouncement = () => {
addNotification({
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】贵州茅台发布2024年度财报公告',
content: '2024年度营收同比增长15.2%净利润创历史新高董事会建议每10股派息180元',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/test001',
extra: {
announcementType: '财报',
companyCode: '600519',
companyName: '贵州茅台',
},
autoClose: 10000,
});
setTestCount(prev => prev + 1);
};
// 事件动向测试数据
const testEventAlert = () => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】央行宣布降准0.5个百分点',
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点释放长期资金约1万亿元利好股市',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/test003',
extra: {
eventId: 'test003',
relatedStocks: 12,
impactLevel: '重大利好',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
};
// 分析报告测试数据非AI
const testAnalysisReport = () => {
addNotification({
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】医药行业深度报告:创新药迎来政策拐点',
content: 'CXO板块持续受益于全球创新药研发外包需求建议关注药明康德、凯莱英等龙头企业',
publishTime: Date.now(),
pushTime: Date.now(),
author: {
name: '李明',
organization: '中信证券',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=test004',
extra: {
reportType: '行业研报',
industry: '医药',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
};
// 预测通知测试数据(不可跳转)
const testPrediction = () => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】【预测】央行可能宣布降准政策',
content: '基于最新宏观数据分析预计央行将在本周宣布降准0.5个百分点,释放长期资金',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
},
autoClose: 15000,
});
setTestCount(prev => prev + 1);
};
// 预测→详情流程测试先推预测5秒后推详情
const testPredictionFlow = () => {
// 阶段 1: 推送预测
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】【预测】新能源汽车补贴政策将延期',
content: '根据政策趋势分析预计财政部将宣布新能源汽车购置补贴政策延长至2025年底',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false,
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_test_001',
},
autoClose: 15000,
});
setTestCount(prev => prev + 1);
// 阶段 2: 5秒后推送详情
setTimeout(() => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】新能源汽车补贴政策延期至2025年底',
content: '财政部宣布新能源汽车购置补贴政策延长至2025年底涉及比亚迪、理想汽车等5家龙头企业',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true, // ✅ 可点击
link: '/event-detail/test_pred_001',
extra: {
isPrediction: false,
relatedPredictionId: 'pred_test_001',
eventId: 'test_pred_001',
relatedStocks: 5,
impactLevel: '重大利好',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
}, 5000);
};
return (
<Box
position="fixed"
top="116px"
right={4}
zIndex={9998}
bg="white"
borderRadius="md"
boxShadow="lg"
overflow="hidden"
>
{/* 折叠按钮 */}
<HStack
p={2}
bg="blue.500"
color="white"
cursor="pointer"
onClick={onToggle}
spacing={2}
>
<MdNotifications size={20} />
<Text fontSize="sm" fontWeight="bold">
金融资讯测试工具
</Text>
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
{isConnected ? 'Connected' : 'Disconnected'}
</Badge>
<Badge colorScheme="purple">
REAL
</Badge>
<Badge colorScheme={getPermissionColor()}>
浏览器: {getPermissionLabel()}
</Badge>
<IconButton
icon={isOpen ? <MdClose /> : <MdNotifications />}
size="xs"
variant="ghost"
colorScheme="whiteAlpha"
aria-label={isOpen ? '关闭' : '打开'}
/>
</HStack>
{/* 工具面板 */}
<Collapse in={isOpen} animateOpacity>
<VStack p={4} spacing={3} align="stretch" minW="280px">
<Text fontSize="xs" color="gray.600" fontWeight="bold">
通知类型测试
</Text>
{/* 公告通知 */}
<Button
size="sm"
colorScheme="blue"
leftIcon={<MdCampaign />}
onClick={testAnnouncement}
>
公告通知
</Button>
{/* 事件动向 */}
<Button
size="sm"
colorScheme="orange"
leftIcon={<MdArticle />}
onClick={testEventAlert}
>
事件动向
</Button>
{/* 分析报告 */}
<Button
size="sm"
colorScheme="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
>
分析报告
</Button>
{/* 预测通知 */}
<Button
size="sm"
colorScheme="gray"
leftIcon={<MdArticle />}
onClick={testPrediction}
>
预测通知不可跳转
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
组合测试
</Text>
{/* 预测→详情流程测试 */}
<Button
size="sm"
colorScheme="cyan"
onClick={testPredictionFlow}
>
预测详情流程5秒延迟
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
浏览器通知
</Text>
{/* 请求权限按钮 */}
{browserPermission !== 'granted' && (
<Button
size="sm"
colorScheme={browserPermission === 'denied' ? 'red' : 'blue'}
onClick={handleRequestPermission}
isDisabled={browserPermission === 'denied'}
>
{browserPermission === 'denied' ? '权限已拒绝' : '请求浏览器权限'}
</Button>
)}
{/* 测试浏览器通知按钮 */}
{browserPermission === 'granted' && (
<Button
size="sm"
colorScheme="green"
leftIcon={<MdNotifications />}
onClick={() => {
console.log('测试浏览器通知按钮被点击');
console.log('Notification support:', 'Notification' in window);
console.log('Notification permission:', Notification?.permission);
console.log('Platform:', navigator.platform);
console.log('Fullscreen:', !!document.fullscreenElement);
// 直接使用原生 Notification API 测试
if (!('Notification' in window)) {
alert('您的浏览器不支持桌面通知');
return;
}
if (Notification.permission !== 'granted') {
alert('浏览器通知权限未授予\n当前权限状态' + Notification.permission);
return;
}
// 重置状态
setNotificationShown(null);
setIsTestingNotification(true);
setTestCountdown(8); // 8秒倒计时
try {
console.log('正在创建浏览器通知...');
const notification = new Notification('【测试】浏览器通知测试', {
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
icon: '/logo192.png',
badge: '/badge.png',
tag: 'test_notification_' + Date.now(),
requireInteraction: false,
});
console.log('浏览器通知创建成功:', notification);
// 监听通知显示(成功显示)
notification.onshow = () => {
console.log('✅ 浏览器通知已显示onshow 事件触发)');
setNotificationShown(true);
};
// 监听通知错误
notification.onerror = (error) => {
console.error('❌ 浏览器通知错误:', error);
setNotificationShown(false);
};
// 监听通知关闭
notification.onclose = () => {
console.log('浏览器通知已关闭');
};
// 8秒后自动关闭
setTimeout(() => {
notification.close();
console.log('浏览器通知已自动关闭');
}, 8000);
// 点击通知时聚焦窗口
notification.onclick = () => {
console.log('浏览器通知被点击');
window.focus();
notification.close();
setNotificationShown(true);
};
setTestCount(prev => prev + 1);
} catch (error) {
console.error('创建浏览器通知失败:', error);
alert('创建浏览器通知失败:' + error.message);
setIsTestingNotification(false);
setNotificationShown(false);
}
}}
isLoading={isTestingNotification}
loadingText={`等待通知... ${testCountdown}s`}
>
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
</Button>
)}
{/* 浏览器通知状态说明 */}
{browserPermission === 'granted' && (
<Text fontSize="xs" color="green.500">
浏览器通知已启用
</Text>
)}
{browserPermission === 'denied' && (
<Text fontSize="xs" color="red.500">
请在浏览器设置中允许通知
</Text>
)}
{/* 实时权限状态 */}
<HStack spacing={2} justify="center">
<Text fontSize="xs" color="gray.500">
实际权限
</Text>
<Badge
colorScheme={
('Notification' in window && Notification.permission === 'granted') ? 'green' :
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
}
>
{('Notification' in window) ? Notification.permission : '不支持'}
</Badge>
</HStack>
{/* 环境警告 */}
{isFullscreen && (
<Alert status="warning" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">全屏模式</Text>
<Text>某些浏览器在全屏模式下不显示通知</Text>
</Box>
</Alert>
)}
{isMacOS && notificationShown === false && (
<Alert status="error" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">未检测到通知显示</Text>
<Text>可能是专注模式阻止了通知</Text>
</Box>
</Alert>
)}
<Divider />
{/* 故障排查面板 */}
<VStack spacing={2} align="stretch">
<Button
size="sm"
variant="outline"
colorScheme="orange"
leftIcon={<MdWarning />}
onClick={onTroubleshootToggle}
>
{isTroubleshootOpen ? '收起' : '故障排查指南'}
</Button>
<Collapse in={isTroubleshootOpen} animateOpacity>
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
<Text fontSize="xs" fontWeight="bold" color="orange.800">
如果看不到浏览器通知请检查
</Text>
{/* macOS 专注模式 */}
{isMacOS && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>点击右上角控制中心</ListItem>
<ListItem>关闭专注模式勿扰模式</ListItem>
<ListItem>或者系统设置 专注模式 关闭</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* macOS 系统通知设置 */}
{isMacOS && (
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>系统设置 通知</ListItem>
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> <Code fontSize="xs">Microsoft Edge</Code></ListItem>
<ListItem>确保允许通知已开启</ListItem>
<ListItem>通知样式设置为横幅提醒</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* Chrome 浏览器设置 */}
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
<ListItem>确保网站可以请求发送通知已开启</ListItem>
<ListItem>检查本站点是否在允许列表中</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
{/* 全屏模式提示 */}
{isFullscreen && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
<AlertDescription>
<Code fontSize="xs">ESC</Code> 退
</AlertDescription>
</Box>
</Alert>
)}
{/* 测试结果反馈 */}
{notificationShown === true && (
<Alert status="success" size="sm">
<AlertIcon />
<Text fontSize="xs"> 通知功能正常</Text>
</Alert>
)}
</VStack>
</Collapse>
</VStack>
<Divider />
{/* 功能按钮 */}
<HStack spacing={2}>
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={clearAllNotifications}
flex={1}
>
清空全部
</Button>
<IconButton
size="sm"
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
colorScheme={soundEnabled ? 'blue' : 'gray'}
onClick={toggleSound}
aria-label="切换音效"
/>
</HStack>
{/* 统计信息 */}
<VStack spacing={1}>
<HStack justify="space-between" w="full">
<Text fontSize="xs" color="gray.500">
当前队列:
</Text>
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
{notifications.length} / 5
</Badge>
</HStack>
<Text fontSize="xs" color="gray.400" textAlign="center">
已测试: {testCount} 条通知
</Text>
</VStack>
</VStack>
</Collapse>
</Box>
);
};
export default NotificationTestTool;

View File

@@ -353,13 +353,13 @@ export const NotificationProvider = ({ children }) => {
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
console.log('[NotificationContext] 通知数据:', notificationData);
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
logger.debug('NotificationContext', 'sendBrowserNotification 被调用', {
notificationData,
browserPermission
});
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
logger.warn('NotificationContext', '浏览器权限未授予,无法发送通知');
return;
}
@@ -371,7 +371,7 @@ export const NotificationProvider = ({ children }) => {
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
logger.debug('NotificationContext', '准备发送浏览器通知', {
title,
body: content,
tag,
@@ -390,12 +390,12 @@ export const NotificationProvider = ({ children }) => {
});
if (notification) {
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
logger.info('NotificationContext', '通知对象创建成功', { notification });
// 设置点击处理(聚焦窗口并跳转)
if (link) {
notification.onclick = () => {
console.log('[NotificationContext] 通知被点击,跳转到:', link);
logger.info('NotificationContext', '通知被点击,跳转到', { link });
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
@@ -405,7 +405,7 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
} else {
console.error('[NotificationContext] ❌ 通知对象创建失败!');
logger.error('NotificationContext', '通知对象创建失败');
}
}, [browserPermission]);
@@ -640,19 +640,18 @@ export const NotificationProvider = ({ children }) => {
*/
useEffect(() => {
addNotificationRef.current = addNotification;
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
logger.debug('NotificationContext', '已更新 addNotificationRef');
}, [addNotification]);
useEffect(() => {
adaptEventToNotificationRef.current = adaptEventToNotification;
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
logger.debug('NotificationContext', '已更新 adaptEventToNotificationRef');
}, [adaptEventToNotification]);
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...');
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接方案2只注册一次', 'color: #673AB7; font-weight: bold;');
logger.info('NotificationContext', '初始化 Socket 连接方案2只注册一次');
// ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => {
@@ -661,15 +660,14 @@ export const NotificationProvider = ({ children }) => {
// 判断是首次连接还是重连
if (isFirstConnect.current) {
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
logger.info('NotificationContext', '首次连接成功', {
socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
logger.info('NotificationContext', 'Socket connected (first time)');
} else {
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
logger.info('NotificationContext', 'Socket reconnected');
// 清除之前的定时器
if (reconnectedTimerRef.current) {
@@ -684,20 +682,18 @@ export const NotificationProvider = ({ children }) => {
}
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
logger.info('NotificationContext', '重新订阅事件推送');
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType: 'all',
importance: 'all',
onSubscribed: (data) => {
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] 订阅确认:', data);
logger.info('NotificationContext', 'Events subscribed', data);
logger.info('NotificationContext', '订阅成功', data);
},
});
} else {
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
}
});
@@ -705,8 +701,7 @@ export const NotificationProvider = ({ children }) => {
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket disconnected', { reason });
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
logger.warn('NotificationContext', 'Socket 已断开', { reason });
});
// ========== 监听连接错误 ==========
@@ -716,15 +711,13 @@ export const NotificationProvider = ({ children }) => {
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
});
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', 'Socket reconnect_failed');
logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED);
console.error('[NotificationContext] ❌ 重连失败');
toast({
title: '连接失败',
@@ -737,21 +730,17 @@ export const NotificationProvider = ({ children }) => {
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
socket.on('new_event', (data) => {
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('[NotificationContext] 原始事件数据:', data);
console.log('[NotificationContext] 事件 ID:', data?.id);
console.log('[NotificationContext] 事件标题:', data?.title);
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
console.log('[NotificationContext] 事件重要性:', data?.importance);
logger.info('NotificationContext', 'Received new event', data);
logger.info('NotificationContext', '收到 new_event 事件', {
id: data?.id,
title: data?.title,
eventType: data?.event_type || data?.type,
importance: data?.importance
});
logger.debug('NotificationContext', '原始事件数据', data);
// ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
logger.error('NotificationContext', 'Refs not initialized', {
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
});
@@ -770,14 +759,12 @@ export const NotificationProvider = ({ children }) => {
}
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
return;
}
processedEventIds.current.add(eventId);
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
@@ -790,45 +777,41 @@ export const NotificationProvider = ({ children }) => {
// ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
console.log('[NotificationContext] 正在转换事件格式...');
logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data);
console.log('[NotificationContext] 转换后的通知对象:', notification);
logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
console.log('[NotificationContext] 准备添加通知到队列...');
logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列');
logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) {
console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`);
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('NotificationContext', 'Event update callback error', error);
console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
logger.error('NotificationContext', '事件更新回调执行失败', error);
}
});
console.log('[NotificationContext] ✅ 所有事件更新回调已触发');
logger.debug('NotificationContext', '所有事件更新回调已触发');
}
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
});
// ========== 监听系统通知(兼容性) ==========
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
console.log('[NotificationContext] 📢 收到系统通知:', data);
logger.info('NotificationContext', '收到系统通知', data);
if (addNotificationRef.current) {
addNotificationRef.current(data);
} else {
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
logger.error('NotificationContext', 'addNotificationRef 未初始化');
}
});
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
logger.info('NotificationContext', '所有监听器已注册(只注册一次)');
// ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
@@ -836,13 +819,12 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ========== 启动连接 ==========
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
logger.info('NotificationContext', '调用 socket.connect()');
socket.connect();
// ========== 清理函数(组件卸载时) ==========
return () => {
logger.info('NotificationContext', 'Cleaning up socket connection');
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
logger.info('NotificationContext', '清理 Socket 连接');
// 清理 reconnected 状态定时器
if (reconnectedTimerRef.current) {
@@ -868,7 +850,7 @@ export const NotificationProvider = ({ children }) => {
// 断开连接
socket.disconnect();
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
logger.info('NotificationContext', '清理完成');
};
}, []); // ⚠️ 空依赖数组,确保只执行一次
@@ -984,92 +966,6 @@ export const NotificationProvider = ({ children }) => {
};
}, [browserPermission, toast]);
// 🔧 开发环境调试:暴露方法到 window
useEffect(() => {
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
if (typeof window !== 'undefined') {
window.__TEST_NOTIFICATION__ = {
// 手动触发网页通知
testWebNotification: (type = 'event_alert', priority = 'normal') => {
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
const testData = {
id: `test_${Date.now()}`,
type: type,
priority: priority,
title: '🧪 测试网页通知',
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
timestamp: Date.now(),
clickable: true,
link: '/home',
};
console.log('测试数据:', testData);
addNotification(testData);
console.log('✅ 通知已添加到队列');
},
// 测试所有类型
testAllTypes: () => {
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
types.forEach((type, i) => {
setTimeout(() => {
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
}, i * 2000); // 每 2 秒一个
});
},
// 测试所有优先级
testAllPriorities: () => {
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
const priorities = ['normal', 'important', 'urgent'];
priorities.forEach((priority, i) => {
setTimeout(() => {
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
}, i * 2000);
});
},
// 帮助
help: () => {
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
console.log(' type (通知类型):');
console.log(' - "announcement" 公告通知(蓝色)');
console.log(' - "stock_alert" 股票动向(红色/绿色)');
console.log(' - "event_alert" 事件动向(橙色)');
console.log(' - "analysis_report" 分析报告(紫色)');
console.log('\n priority (优先级):');
console.log(' - "normal" 普通15秒自动关闭');
console.log(' - "important" 重要30秒自动关闭');
console.log(' - "urgent" 紧急(不自动关闭)');
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
console.log(' // 测试紧急事件通知');
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
console.log('\n // 测试所有类型');
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
console.log('\n // 测试所有优先级');
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
console.log('\n');
}
};
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
}
}
// 清理函数
return () => {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
delete window.__TEST_NOTIFICATION__;
}
};
}, [addNotification]); // 依赖 addNotification 函数
const value = {
notifications,
isConnected,

View File

@@ -10,20 +10,17 @@
* 全局 API:
* - window.__DEBUG__ - 调试 API 主对象
* - window.__DEBUG__.api - API 调试工具
* - window.__DEBUG__.notification - 通知调试工具
* - window.__DEBUG__.socket - Socket 调试工具
* - window.__DEBUG__.help() - 显示帮助信息
* - window.__DEBUG__.exportAll() - 导出所有日志
*/
import { apiDebugger } from './apiDebugger';
import { notificationDebugger } from './notificationDebugger';
import { socketDebugger } from './socketDebugger';
class DebugToolkit {
constructor() {
this.api = apiDebugger;
this.notification = notificationDebugger;
this.socket = socketDebugger;
}
@@ -47,7 +44,6 @@ class DebugToolkit {
// 初始化各个调试工具
this.api.init();
this.notification.init();
this.socket.init();
// 暴露到全局
@@ -69,22 +65,13 @@ class DebugToolkit {
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
console.log('');
console.log('%c2通知调试:', 'color: #9C27B0; font-weight: bold;');
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
console.log('');
console.log('%c3⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
console.log('%c2Socket 调试:', 'color: #00BCD4; font-weight: bold;');
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
console.log('');
console.log('%c4️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
console.log('%c3️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
console.log(' __DEBUG__.help() - 显示帮助信息');
console.log(' __DEBUG__.exportAll() - 导出所有日志');
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
@@ -113,7 +100,6 @@ class DebugToolkit {
const allLogs = {
timestamp: new Date().toISOString(),
api: this.api.getLogs(),
notification: this.notification.getLogs(),
socket: this.socket.getLogs(),
};
@@ -138,15 +124,11 @@ class DebugToolkit {
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
const apiStats = this.api.printStats();
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
const notificationStats = this.notification.printStats();
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
const socketStats = this.socket.printStats();
return {
api: apiStats,
notification: notificationStats,
socket: socketStats,
};
}
@@ -157,7 +139,6 @@ class DebugToolkit {
clearAll() {
console.log('[Debug Toolkit] Clearing all logs...');
this.api.clearLogs();
this.notification.clearLogs();
this.socket.clearLogs();
console.log('[Debug Toolkit] ✅ All logs cleared');
}
@@ -169,15 +150,11 @@ class DebugToolkit {
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
// 1. Socket 状态
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;');
console.log('\n%c[1/2] Socket 状态', 'color: #00BCD4; font-weight: bold;');
const socketStatus = this.socket.getStatus();
// 2. 通知权限
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
const notificationStatus = this.notification.checkPermission();
// 3. API 错误
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
// 2. API 错误
console.log('\n%c[2/2] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
const recentErrors = this.api.getRecentErrors(5);
if (recentErrors.length > 0) {
console.table(
@@ -193,11 +170,10 @@ class DebugToolkit {
console.log('✅ 没有 API 错误');
}
// 4. 汇总报告
// 3. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: socketStatus,
notification: notificationStatus,
apiErrors: recentErrors.length,
};

View File

@@ -1,204 +0,0 @@
// src/debug/notificationDebugger.js
/**
* 通知系统调试工具
* 扩展现有的 window.__NOTIFY_DEBUG__添加更多生产环境调试能力
*/
import { browserNotificationService } from '@services/browserNotificationService';
class NotificationDebugger {
constructor() {
this.eventLog = [];
this.maxLogSize = 100;
}
/**
* 初始化调试工具
*/
init() {
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
}
/**
* 记录通知事件
*/
logEvent(eventType, data) {
const logEntry = {
type: eventType,
timestamp: new Date().toISOString(),
data,
};
this.eventLog.unshift(logEntry);
if (this.eventLog.length > this.maxLogSize) {
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
}
console.log(
`%c[Notification Event] ${eventType}`,
'color: #9C27B0; font-weight: bold;',
data
);
}
/**
* 获取所有事件日志
*/
getLogs() {
return this.eventLog;
}
/**
* 清空日志
*/
clearLogs() {
this.eventLog = [];
console.log('[Notification Debugger] Logs cleared');
}
/**
* 导出日志
*/
exportLogs() {
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[Notification Debugger] Logs exported');
}
/**
* 强制发送浏览器通知(测试用)
*/
forceNotification(options = {}) {
const defaultOptions = {
title: '🧪 测试通知',
body: `测试时间: ${new Date().toLocaleString()}`,
tag: `test_${Date.now()}`,
requireInteraction: false,
autoClose: 5000,
};
const finalOptions = { ...defaultOptions, ...options };
console.log('[Notification Debugger] Sending test notification:', finalOptions);
const notification = browserNotificationService.sendNotification(finalOptions);
if (notification) {
console.log('[Notification Debugger] ✅ Notification sent successfully');
} else {
console.error('[Notification Debugger] ❌ Failed to send notification');
}
return notification;
}
/**
* 检查通知权限状态
*/
checkPermission() {
const permission = browserNotificationService.getPermissionStatus();
const isSupported = browserNotificationService.isSupported();
const status = {
supported: isSupported,
permission,
canSend: isSupported && permission === 'granted',
};
console.table(status);
return status;
}
/**
* 请求通知权限
*/
async requestPermission() {
console.log('[Notification Debugger] Requesting notification permission...');
const result = await browserNotificationService.requestPermission();
console.log(`[Notification Debugger] Permission result: ${result}`);
return result;
}
/**
* 打印事件统计
*/
printStats() {
const stats = {
total: this.eventLog.length,
byType: {},
};
this.eventLog.forEach((log) => {
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
});
console.log('=== Notification Stats ===');
console.table(stats.byType);
console.log(`Total events: ${stats.total}`);
return stats;
}
/**
* 按类型过滤日志
*/
getLogsByType(eventType) {
return this.eventLog.filter((log) => log.type === eventType);
}
/**
* 获取最近的事件
*/
getRecentEvents(count = 10) {
return this.eventLog.slice(0, count);
}
/**
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
*/
testWebNotification(type = 'event_alert', priority = 'normal') {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
console.log('[Notification Debugger] 调用测试 API');
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
console.error('💡 请确保:');
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
console.error(' 2. NotificationContext 已加载');
console.error(' 3. 页面已刷新');
}
}
/**
* 测试所有通知类型
*/
testAllNotificationTypes() {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
window.__TEST_NOTIFICATION__.testAllTypes();
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
}
}
/**
* 测试所有优先级
*/
testAllNotificationPriorities() {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
window.__TEST_NOTIFICATION__.testAllPriorities();
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
}
}
}
// 导出单例
export const notificationDebugger = new NotificationDebugger();
export default notificationDebugger;

View File

@@ -120,7 +120,7 @@ export function usePagination<T>(
loadData(1, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoLoad]);
}, [autoLoad, loadFunction]);
return {
data,

View File

@@ -38,6 +38,10 @@ export const lazyComponents = {
// Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')),
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
};
/**
@@ -63,4 +67,6 @@ export const {
FinancialPanorama,
MarketDataView,
AgentChat,
ValueForum,
ForumPostDetail,
} = lazyComponents;

View File

@@ -150,6 +150,28 @@ export const routeConfig = [
}
},
// ==================== 价值论坛模块 ====================
{
path: 'value-forum',
component: lazyComponents.ValueForum,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '价值论坛',
description: '投资者价值讨论社区'
}
},
{
path: 'value-forum/post/:postId',
component: lazyComponents.ForumPostDetail,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '帖子详情',
description: '论坛帖子详细内容'
}
},
// ==================== Agent模块 ====================
{
path: 'agent-chat',

View File

@@ -144,8 +144,8 @@ export const WECHAT_STATUS = {
WAITING: 'waiting',
SCANNED: 'scanned',
AUTHORIZED: 'authorized',
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
LOGIN_SUCCESS: 'login_ready', // ✅ 修复:与后端返回的状态一致
REGISTER_SUCCESS: 'register_ready', // ✅ 修复:与后端返回的状态一致
EXPIRED: 'expired',
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
AUTH_FAILED: 'auth_failed', // 授权失败

View File

@@ -0,0 +1,442 @@
/**
* Elasticsearch 服务层
* 用于价值论坛的帖子、评论存储和搜索
*/
import axios from 'axios';
// Elasticsearch 配置
// 使用 Nginx 代理路径避免 Mixed Content 问题
const ES_CONFIG = {
baseURL: process.env.NODE_ENV === 'production'
? '/es-api' // 生产环境使用 Nginx 代理
: 'http://222.128.1.157:19200', // 开发环境直连
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
};
// 创建 axios 实例
const esClient = axios.create(ES_CONFIG);
// 索引名称
const INDICES = {
POSTS: 'forum_posts',
COMMENTS: 'forum_comments',
EVENTS: 'forum_events',
};
/**
* 初始化索引(创建索引和映射)
*/
export const initializeIndices = async () => {
try {
// 创建帖子索引
await esClient.put(`/${INDICES.POSTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
title: { type: 'text', analyzer: 'ik_max_word' },
content: { type: 'text', analyzer: 'ik_max_word' },
images: { type: 'keyword' },
tags: { type: 'keyword' },
category: { type: 'keyword' },
likes_count: { type: 'integer' },
comments_count: { type: 'integer' },
views_count: { type: 'integer' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
is_pinned: { type: 'boolean' },
status: { type: 'keyword' }, // active, deleted, hidden
},
},
});
// 创建评论索引
await esClient.put(`/${INDICES.COMMENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
author_id: { type: 'keyword' },
author_name: { type: 'text' },
author_avatar: { type: 'keyword' },
content: { type: 'text', analyzer: 'ik_max_word' },
parent_id: { type: 'keyword' }, // 用于嵌套评论
likes_count: { type: 'integer' },
created_at: { type: 'date' },
status: { type: 'keyword' },
},
},
});
// 创建事件时间轴索引
await esClient.put(`/${INDICES.EVENTS}`, {
mappings: {
properties: {
id: { type: 'keyword' },
post_id: { type: 'keyword' },
event_type: { type: 'keyword' }, // news, price_change, announcement, etc.
title: { type: 'text' },
description: { type: 'text', analyzer: 'ik_max_word' },
source: { type: 'keyword' },
source_url: { type: 'keyword' },
related_stocks: { type: 'keyword' },
occurred_at: { type: 'date' },
created_at: { type: 'date' },
importance: { type: 'keyword' }, // high, medium, low
},
},
});
console.log('Elasticsearch 索引初始化成功');
} catch (error) {
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
console.log('索引已存在,跳过创建');
} else {
console.error('初始化索引失败:', error);
throw error;
}
}
};
// ==================== 帖子相关操作 ====================
/**
* 创建新帖子
*/
export const createPost = async (postData) => {
try {
const post = {
id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...postData,
likes_count: 0,
comments_count: 0,
views_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
is_pinned: false,
status: 'active',
};
const response = await esClient.post(`/${INDICES.POSTS}/_doc/${post.id}`, post);
return { ...post, _id: response.data._id };
} catch (error) {
console.error('创建帖子失败:', error);
throw error;
}
};
/**
* 获取帖子列表(支持分页、排序、筛选)
*/
export const getPosts = async ({ page = 1, size = 20, sort = 'created_at', order = 'desc', category = null, tags = [] }) => {
try {
const from = (page - 1) * size;
const query = {
bool: {
must: [{ match: { status: 'active' } }],
},
};
if (category) {
query.bool.must.push({ term: { category } });
}
if (tags.length > 0) {
query.bool.must.push({ terms: { tags } });
}
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
from,
size,
query,
sort: [
{ is_pinned: { order: 'desc' } },
{ [sort]: { order } },
],
});
return {
total: response.data.hits.total.value,
posts: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
page,
size,
};
} catch (error) {
console.error('获取帖子列表失败:', error);
throw error;
}
};
/**
* 获取单个帖子详情
*/
export const getPostById = async (postId) => {
try {
const response = await esClient.get(`/${INDICES.POSTS}/_doc/${postId}`);
// 增加浏览量
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
script: {
source: 'ctx._source.views_count += 1',
lang: 'painless',
},
});
return { ...response.data._source, _id: response.data._id };
} catch (error) {
console.error('获取帖子详情失败:', error);
throw error;
}
};
/**
* 更新帖子
*/
export const updatePost = async (postId, updateData) => {
try {
const response = await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
doc: {
...updateData,
updated_at: new Date().toISOString(),
},
});
return response.data;
} catch (error) {
console.error('更新帖子失败:', error);
throw error;
}
};
/**
* 删除帖子(软删除)
*/
export const deletePost = async (postId) => {
try {
await updatePost(postId, { status: 'deleted' });
} catch (error) {
console.error('删除帖子失败:', error);
throw error;
}
};
/**
* 点赞帖子
*/
export const likePost = async (postId) => {
try {
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
script: {
source: 'ctx._source.likes_count += 1',
lang: 'painless',
},
});
} catch (error) {
console.error('点赞帖子失败:', error);
throw error;
}
};
/**
* 搜索帖子
*/
export const searchPosts = async (keyword, { page = 1, size = 20 }) => {
try {
const from = (page - 1) * size;
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
from,
size,
query: {
bool: {
must: [
{
multi_match: {
query: keyword,
fields: ['title^3', 'content', 'tags^2'],
type: 'best_fields',
},
},
{ match: { status: 'active' } },
],
},
},
highlight: {
fields: {
title: {},
content: {},
},
},
});
return {
total: response.data.hits.total.value,
posts: response.data.hits.hits.map((hit) => ({
...hit._source,
_id: hit._id,
highlight: hit.highlight,
})),
page,
size,
};
} catch (error) {
console.error('搜索帖子失败:', error);
throw error;
}
};
// ==================== 评论相关操作 ====================
/**
* 创建评论
*/
export const createComment = async (commentData) => {
try {
const comment = {
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...commentData,
likes_count: 0,
created_at: new Date().toISOString(),
status: 'active',
};
const response = await esClient.post(`/${INDICES.COMMENTS}/_doc/${comment.id}`, comment);
// 增加帖子评论数
await esClient.post(`/${INDICES.POSTS}/_update/${commentData.post_id}`, {
script: {
source: 'ctx._source.comments_count += 1',
lang: 'painless',
},
});
return { ...comment, _id: response.data._id };
} catch (error) {
console.error('创建评论失败:', error);
throw error;
}
};
/**
* 获取帖子的评论列表
*/
export const getCommentsByPostId = async (postId, { page = 1, size = 50 }) => {
try {
const from = (page - 1) * size;
const response = await esClient.post(`/${INDICES.COMMENTS}/_search`, {
from,
size,
query: {
bool: {
must: [
{ term: { post_id: postId } },
{ match: { status: 'active' } },
],
},
},
sort: [{ created_at: { order: 'asc' } }],
});
return {
total: response.data.hits.total.value,
comments: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
};
} catch (error) {
// 如果索引不存在404返回空结果
if (error.response?.status === 404) {
console.warn('评论索引不存在,返回空结果:', INDICES.COMMENTS);
return { total: 0, comments: [] };
}
console.error('获取评论列表失败:', error);
throw error;
}
};
/**
* 点赞评论
*/
export const likeComment = async (commentId) => {
try {
await esClient.post(`/${INDICES.COMMENTS}/_update/${commentId}`, {
script: {
source: 'ctx._source.likes_count += 1',
lang: 'painless',
},
});
} catch (error) {
console.error('点赞评论失败:', error);
throw error;
}
};
// ==================== 事件时间轴相关操作 ====================
/**
* 创建事件
*/
export const createEvent = async (eventData) => {
try {
const event = {
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
...eventData,
created_at: new Date().toISOString(),
};
const response = await esClient.post(`/${INDICES.EVENTS}/_doc/${event.id}`, event);
return { ...event, _id: response.data._id };
} catch (error) {
console.error('创建事件失败:', error);
throw error;
}
};
/**
* 获取帖子的事件时间轴
*/
export const getEventsByPostId = async (postId) => {
try {
const response = await esClient.post(`/${INDICES.EVENTS}/_search`, {
size: 100,
query: {
term: { post_id: postId },
},
sort: [{ occurred_at: { order: 'desc' } }],
});
return response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id }));
} catch (error) {
// 如果索引不存在404返回空数组而不是抛出错误
if (error.response?.status === 404) {
console.warn('事件索引不存在,返回空数组:', INDICES.EVENTS);
return [];
}
console.error('获取事件时间轴失败:', error);
throw error;
}
};
export default {
initializeIndices,
// 帖子操作
createPost,
getPosts,
getPostById,
updatePost,
deletePost,
likePost,
searchPosts,
// 评论操作
createComment,
getCommentsByPostId,
likeComment,
// 事件操作
createEvent,
getEventsByPostId,
};

View File

@@ -166,7 +166,27 @@ export const eventService = {
// 帖子相关API
getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => {
try {
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
const result = await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
// ⚡ 数据转换:将后端的 user 字段映射为前端期望的 author 字段
if (result.success && Array.isArray(result.data)) {
result.data = result.data.map(post => ({
...post,
author: post.user ? {
id: post.user.id,
username: post.user.username,
avatar: post.user.avatar_url || post.user.avatar // 兼容 avatar_url 和 avatar
} : {
id: 'anonymous',
username: 'Anonymous',
avatar: null
}
// 保留原始的 user 字段(如果其他地方需要)
// user: post.user
}));
}
return result;
} catch (error) {
logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
return { success: false, data: [], pagination: {} };

227
src/theme/forumTheme.js Normal file
View File

@@ -0,0 +1,227 @@
/**
* 价值论坛黑金主题配置
* 采用深色背景 + 金色点缀的高端配色方案
*/
export const forumColors = {
// 主色调 - 黑金渐变
primary: {
50: '#FFF9E6',
100: '#FFEEBA',
200: '#FFE38D',
300: '#FFD860',
400: '#FFCD33',
500: '#FFC107', // 主金色
600: '#FFB300',
700: '#FFA000',
800: '#FF8F00',
900: '#FF6F00',
},
// 背景色系 - 深黑渐变
background: {
main: '#0A0A0A', // 主背景 - 极黑
secondary: '#121212', // 次级背景
card: '#1A1A1A', // 卡片背景
hover: '#222222', // 悬停背景
elevated: '#2A2A2A', // 提升背景(模态框等)
},
// 文字色系
text: {
primary: '#FFFFFF', // 主文字 - 纯白
secondary: '#B8B8B8', // 次要文字 - 灰色
tertiary: '#808080', // 三级文字 - 深灰
muted: '#5A5A5A', // 弱化文字
gold: '#FFC107', // 金色强调文字
goldGradient: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', // 金色渐变
},
// 边框色系
border: {
default: '#333333',
light: '#404040',
gold: '#FFC107',
goldGlow: 'rgba(255, 193, 7, 0.3)',
},
// 功能色
semantic: {
success: '#4CAF50',
warning: '#FF9800',
error: '#F44336',
info: '#2196F3',
},
// 金色渐变系列
gradients: {
goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
goldSecondary: 'linear-gradient(135deg, #FFC107 0%, #FF8F00 100%)',
goldSubtle: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 165, 0, 0.05) 100%)',
blackGold: 'linear-gradient(135deg, #0A0A0A 0%, #1A1A1A 50%, #2A2020 100%)',
cardHover: 'linear-gradient(135deg, #1A1A1A 0%, #252525 100%)',
},
// 阴影色系
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.5)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.4)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.7), 0 4px 6px -2px rgba(0, 0, 0, 0.5)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 10px 10px -5px rgba(0, 0, 0, 0.6)',
gold: '0 0 20px rgba(255, 193, 7, 0.3), 0 0 40px rgba(255, 193, 7, 0.1)',
goldHover: '0 0 30px rgba(255, 193, 7, 0.5), 0 0 60px rgba(255, 193, 7, 0.2)',
},
};
/**
* 论坛组件样式配置
*/
export const forumComponentStyles = {
// 按钮样式
Button: {
baseStyle: {
fontWeight: '600',
borderRadius: 'md',
transition: 'all 0.3s ease',
},
variants: {
gold: {
bg: forumColors.gradients.goldPrimary,
color: '#0A0A0A',
_hover: {
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
_disabled: {
transform: 'none',
},
},
_active: {
transform: 'translateY(0)',
},
},
goldOutline: {
bg: 'transparent',
color: forumColors.primary[500],
border: '2px solid',
borderColor: forumColors.primary[500],
_hover: {
bg: forumColors.gradients.goldSubtle,
boxShadow: forumColors.shadows.gold,
},
},
dark: {
bg: forumColors.background.card,
color: forumColors.text.primary,
border: '1px solid',
borderColor: forumColors.border.default,
_hover: {
bg: forumColors.background.hover,
borderColor: forumColors.border.light,
},
},
},
},
// 卡片样式
Card: {
baseStyle: {
container: {
bg: forumColors.background.card,
borderRadius: 'lg',
border: '1px solid',
borderColor: forumColors.border.default,
transition: 'all 0.3s ease',
_hover: {
borderColor: forumColors.border.gold,
boxShadow: forumColors.shadows.gold,
transform: 'translateY(-4px)',
},
},
},
},
// 输入框样式
Input: {
variants: {
forum: {
field: {
bg: forumColors.background.secondary,
border: '1px solid',
borderColor: forumColors.border.default,
color: forumColors.text.primary,
_placeholder: {
color: forumColors.text.tertiary,
},
_hover: {
borderColor: forumColors.border.light,
},
_focus: {
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
},
},
},
},
},
// 标签样式
Tag: {
variants: {
gold: {
container: {
bg: forumColors.gradients.goldSubtle,
color: forumColors.primary[500],
border: '1px solid',
borderColor: forumColors.border.gold,
},
},
},
},
};
/**
* 论坛专用动画配置
*/
export const forumAnimations = {
fadeIn: {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.3 },
},
slideIn: {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
transition: { duration: 0.3 },
},
scaleIn: {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.9 },
transition: { duration: 0.2 },
},
goldGlow: {
animate: {
boxShadow: [
forumColors.shadows.gold,
forumColors.shadows.goldHover,
forumColors.shadows.gold,
],
},
transition: {
duration: 2,
repeat: Infinity,
repeatType: 'reverse',
},
},
};
export default {
colors: forumColors,
components: forumComponentStyles,
animations: forumAnimations,
};

View File

@@ -1,7 +1,11 @@
// src/utils/logger.js
// 统一日志工具
const isDevelopment = process.env.NODE_ENV === 'development';
// 支持开发环境或显式开启调试模式
// 生产环境下可以通过设置 REACT_APP_ENABLE_DEBUG=true 来开启调试日志
const isDevelopment =
process.env.NODE_ENV === 'development' ||
process.env.REACT_APP_ENABLE_DEBUG === 'true';
// ========== 日志限流配置 ==========
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次

View File

@@ -63,7 +63,7 @@ let dynamicNewsCardRenderCount = 0;
* @param {Object} trackingFunctions - PostHog 追踪函数集合
* @param {Object} ref - 用于滚动的ref
*/
const DynamicNewsCard = forwardRef(({
const DynamicNewsCardComponent = forwardRef(({
filters = {},
popularKeywords = [],
lastUpdateTime,
@@ -227,8 +227,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
// ========== 纵向模式 ==========
// 只在第1页时刷新避免打断用户浏览其他页
if (state.currentPage === 1) {
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表');
handlePageChange(1); // 清空缓存并刷新第1页
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 强制刷新列表');
handlePageChange(1, true); // ⚡ 传递 force = true强制刷新第1页
toast({
title: '检测到新事件',
status: 'info',
@@ -722,6 +722,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
);
});
DynamicNewsCard.displayName = 'DynamicNewsCard';
DynamicNewsCardComponent.displayName = 'DynamicNewsCard';
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
const DynamicNewsCard = React.memo(DynamicNewsCardComponent);
export default DynamicNewsCard;

View File

@@ -30,7 +30,7 @@ import VerticalModeLayout from './VerticalModeLayout';
* @param {Function} onToggleFollow - 关注按钮回调
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref用于获取滚动位置
*/
const EventScrollList = ({
const EventScrollList = React.memo(({
events,
displayEvents,
loadNextPage,
@@ -144,6 +144,6 @@ const EventScrollList = ({
/>
</Box>
);
};
});
export default EventScrollList;

View File

@@ -9,7 +9,7 @@ import { Button, ButtonGroup } from '@chakra-ui/react';
* @param {string} mode - 当前模式 'vertical' | 'four-row'
* @param {Function} onModeChange - 模式切换回调
*/
const ModeToggleButtons = ({ mode, onModeChange }) => {
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
return (
<ButtonGroup size="sm" isAttached>
<Button
@@ -28,6 +28,6 @@ const ModeToggleButtons = ({ mode, onModeChange }) => {
</Button>
</ButtonGroup>
);
};
});
export default ModeToggleButtons;

View File

@@ -35,7 +35,7 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel'
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
* @param {string} borderColor - 边框颜色
*/
const VerticalModeLayout = ({
const VerticalModeLayout = React.memo(({
display = 'flex',
events,
selectedEvent,
@@ -182,6 +182,6 @@ const VerticalModeLayout = ({
)}
</Flex>
);
};
});
export default VerticalModeLayout;

View File

@@ -25,7 +25,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
* @param {boolean} props.hasMore - 是否还有更多数据
* @param {boolean} props.loading - 加载状态
*/
const VirtualizedFourRowGrid = forwardRef(({
const VirtualizedFourRowGridComponent = forwardRef(({
display = 'block',
events,
columnsPerRow = 4,
@@ -387,6 +387,9 @@ const VirtualizedFourRowGrid = forwardRef(({
);
});
VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid';
VirtualizedFourRowGridComponent.displayName = 'VirtualizedFourRowGrid';
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
const VirtualizedFourRowGrid = React.memo(VirtualizedFourRowGridComponent);
export default VirtualizedFourRowGrid;

View File

@@ -158,7 +158,11 @@ export const usePagination = ({
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
// 翻页处理第1页强制刷新 + 其他页缓存)
const handlePageChange = useCallback(async (newPage) => {
const handlePageChange = useCallback(async (newPage, force = false) => {
// force 参数:是否强制刷新(绕过"重复点击"检查)
// - true: 强制刷新Socket 新事件触发)
// - false: 正常翻页(用户点击分页按钮)
// 边界检查 1: 检查页码范围
if (newPage < 1 || newPage > totalPages) {
console.log(`%c⚠ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
@@ -166,13 +170,19 @@ export const usePagination = ({
return;
}
// 边界检查 2: 检查是否重复点击
if (newPage === currentPage) {
// 边界检查 2: 检查是否重复点击(强制刷新时绕过此检查)
if (!force && newPage === currentPage) {
console.log(`%c⚠ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
logger.debug('usePagination', '页码未改变', { newPage });
return;
}
// ⚡ 如果是强制刷新force = true即使页码相同也继续执行
if (force && newPage === currentPage) {
console.log(`%c🔄 [翻页] 强制刷新当前页: ${newPage}`, 'color: #10B981; font-weight: bold;');
logger.info('usePagination', '强制刷新当前页', { newPage });
}
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
if (loadingPage === newPage) {
console.log(`%c⚠ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');

View File

@@ -33,7 +33,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
* @param {Function} props.onToggleFollow - 切换关注事件
* @param {string} props.borderColor - 边框颜色
*/
const DynamicNewsEventCard = ({
const DynamicNewsEventCard = React.memo(({
event,
index,
isFollowing,
@@ -317,6 +317,6 @@ const DynamicNewsEventCard = ({
</Card>
</VStack>
);
};
});
export default DynamicNewsEventCard;

View File

@@ -39,7 +39,7 @@ import KeywordsCarousel from './KeywordsCarousel';
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
*/
const HorizontalDynamicNewsEventCard = ({
const HorizontalDynamicNewsEventCard = React.memo(({
event,
index,
isFollowing,
@@ -227,6 +227,6 @@ const HorizontalDynamicNewsEventCard = ({
</Box>
</HStack>
);
};
});
export default HorizontalDynamicNewsEventCard;

View File

@@ -102,7 +102,7 @@ export default function CenterDashboard() {
const [w, e, c] = await Promise.all([
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/comments?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
]);
const jw = await w.json();
const je = await e.json();

View File

@@ -0,0 +1,370 @@
/**
* 帖子详情页
* 展示帖子完整内容、事件时间轴、评论区
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
HStack,
VStack,
Avatar,
Badge,
Button,
Image,
SimpleGrid,
Spinner,
Center,
Flex,
IconButton,
Divider,
} from '@chakra-ui/react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
ArrowLeft,
Heart,
MessageCircle,
Eye,
Share2,
Bookmark,
} from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
getPostById,
likePost,
getEventsByPostId,
} from '@services/elasticsearchService';
import EventTimeline from './components/EventTimeline';
import CommentSection from './components/CommentSection';
const MotionBox = motion(Box);
const PostDetail = () => {
const { postId } = useParams();
const navigate = useNavigate();
const [post, setPost] = useState(null);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(0);
// 加载帖子数据
useEffect(() => {
const loadPostData = async () => {
try {
setLoading(true);
// 并行加载帖子和事件
const [postData, eventsData] = await Promise.all([
getPostById(postId),
getEventsByPostId(postId),
]);
setPost(postData);
setLikes(postData.likes_count || 0);
setEvents(eventsData);
} catch (error) {
console.error('加载帖子失败:', error);
} finally {
setLoading(false);
}
};
loadPostData();
}, [postId]);
// 处理点赞
const handleLike = async () => {
try {
if (!isLiked) {
await likePost(postId);
setLikes((prev) => prev + 1);
setIsLiked(true);
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<Box minH="100vh" bg={forumColors.background.main} pt="80px">
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
</Box>
);
}
if (!post) {
return (
<Box minH="100vh" bg={forumColors.background.main} pt="80px">
<Center py="20">
<VStack spacing="4">
<Text color={forumColors.text.secondary} fontSize="lg">
帖子不存在或已被删除
</Text>
<Button
leftIcon={<ArrowLeft size={18} />}
onClick={() => navigate('/value-forum')}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
>
返回论坛
</Button>
</VStack>
</Center>
</Box>
);
}
return (
<Box minH="100vh" bg={forumColors.background.main} pt="80px" pb="20">
<Container maxW="container.xl">
{/* 返回按钮 */}
<Button
leftIcon={<ArrowLeft size={18} />}
onClick={() => navigate('/value-forum')}
variant="ghost"
color={forumColors.text.secondary}
_hover={{ color: forumColors.text.primary }}
mb="6"
>
返回论坛
</Button>
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing="6">
{/* 左侧:帖子内容 + 评论 */}
<Box gridColumn={{ base: '1', lg: '1 / 3' }}>
<VStack spacing="6" align="stretch">
{/* 帖子主体 */}
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
bg={forumColors.background.card}
borderRadius="xl"
border="1px solid"
borderColor={forumColors.border.default}
overflow="hidden"
>
{/* 作者信息 */}
<Box p="6" borderBottomWidth="1px" borderColor={forumColors.border.default}>
<Flex justify="space-between" align="start">
<HStack spacing="3">
<Avatar
size="md"
name={post.author_name}
src={post.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<VStack align="start" spacing="1">
<Text fontSize="md" fontWeight="600" color={forumColors.text.primary}>
{post.author_name}
</Text>
<Text fontSize="xs" color={forumColors.text.muted}>
发布于 {formatTime(post.created_at)}
</Text>
</VStack>
</HStack>
{/* 操作按钮 */}
<HStack spacing="2">
<IconButton
icon={<Share2 size={18} />}
variant="ghost"
color={forumColors.text.tertiary}
_hover={{ color: forumColors.primary[500] }}
aria-label="分享"
/>
<IconButton
icon={<Bookmark size={18} />}
variant="ghost"
color={forumColors.text.tertiary}
_hover={{ color: forumColors.primary[500] }}
aria-label="收藏"
/>
</HStack>
</Flex>
</Box>
{/* 帖子内容 */}
<Box p="6">
{/* 标题 */}
<Heading
as="h1"
fontSize="2xl"
fontWeight="bold"
color={forumColors.text.primary}
mb="4"
>
{post.title}
</Heading>
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack spacing="2" mb="6" flexWrap="wrap">
{post.tags.map((tag, index) => (
<Badge
key={index}
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
fontSize="sm"
>
#{tag}
</Badge>
))}
</HStack>
)}
{/* 正文 */}
<Text
fontSize="md"
color={forumColors.text.secondary}
lineHeight="1.8"
whiteSpace="pre-wrap"
mb="6"
>
{post.content}
</Text>
{/* 图片 */}
{post.images && post.images.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing="4" mb="6">
{post.images.map((img, index) => (
<Image
key={index}
src={img}
alt={`图片 ${index + 1}`}
borderRadius="md"
border="1px solid"
borderColor={forumColors.border.default}
cursor="pointer"
_hover={{
transform: 'scale(1.05)',
boxShadow: forumColors.shadows.gold,
}}
transition="all 0.3s"
/>
))}
</SimpleGrid>
)}
</Box>
{/* 互动栏 */}
<Box
p="4"
borderTopWidth="1px"
borderColor={forumColors.border.default}
bg={forumColors.background.secondary}
>
<Flex justify="space-between" align="center">
<HStack spacing="6">
<HStack
spacing="2"
cursor="pointer"
onClick={handleLike}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
_hover={{ color: forumColors.primary[500] }}
>
<Heart size={20} fill={isLiked ? 'currentColor' : 'none'} />
<Text fontSize="sm" fontWeight="500">
{likes}
</Text>
</HStack>
<HStack spacing="2" color={forumColors.text.tertiary}>
<MessageCircle size={20} />
<Text fontSize="sm" fontWeight="500">
{post.comments_count || 0}
</Text>
</HStack>
<HStack spacing="2" color={forumColors.text.tertiary}>
<Eye size={20} />
<Text fontSize="sm" fontWeight="500">
{post.views_count || 0}
</Text>
</HStack>
</HStack>
<Button
leftIcon={<Heart size={18} />}
onClick={handleLike}
bg={isLiked ? forumColors.primary[500] : 'transparent'}
color={isLiked ? forumColors.background.main : forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.gold}
_hover={{
bg: forumColors.gradients.goldPrimary,
color: forumColors.background.main,
}}
>
{isLiked ? '已点赞' : '点赞'}
</Button>
</Flex>
</Box>
</MotionBox>
{/* 评论区 */}
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<CommentSection postId={postId} />
</MotionBox>
</VStack>
</Box>
{/* 右侧:事件时间轴 */}
<Box gridColumn={{ base: '1', lg: '3' }}>
<MotionBox
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
position="sticky"
top="100px"
>
<EventTimeline events={events} />
</MotionBox>
</Box>
</SimpleGrid>
</Container>
</Box>
);
};
export default PostDetail;

View File

@@ -0,0 +1,318 @@
/**
* 评论区组件
* 支持发布评论、嵌套回复、点赞等功能
*/
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Textarea,
Button,
Flex,
IconButton,
Divider,
useToast,
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Send } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import {
getCommentsByPostId,
createComment,
likeComment,
} from '@services/elasticsearchService';
import { useAuth } from '@contexts/AuthContext';
const MotionBox = motion(Box);
const CommentItem = ({ comment, postId, onReply }) => {
const [isLiked, setIsLiked] = useState(false);
const [likes, setLikes] = useState(comment.likes_count || 0);
const [showReply, setShowReply] = useState(false);
// 处理点赞
const handleLike = async () => {
try {
if (!isLiked) {
await likeComment(comment.id);
setLikes((prev) => prev + 1);
setIsLiked(true);
}
} catch (error) {
console.error('点赞失败:', error);
}
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
};
return (
<MotionBox
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Flex gap="3" py="4">
{/* 头像 */}
<Avatar
size="sm"
name={comment.author_name}
src={comment.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
{/* 评论内容 */}
<VStack align="stretch" flex="1" spacing="2">
{/* 用户名和时间 */}
<HStack justify="space-between">
<HStack spacing="2">
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
{comment.author_name}
</Text>
<Text fontSize="xs" color={forumColors.text.muted}>
{formatTime(comment.created_at)}
</Text>
</HStack>
</HStack>
{/* 评论正文 */}
<Text fontSize="sm" color={forumColors.text.secondary} lineHeight="1.6">
{comment.content}
</Text>
{/* 操作按钮 */}
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
<HStack
spacing="1"
cursor="pointer"
onClick={handleLike}
_hover={{ color: forumColors.primary[500] }}
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
>
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
<Text>{likes > 0 ? likes : '点赞'}</Text>
</HStack>
<HStack
spacing="1"
cursor="pointer"
onClick={() => setShowReply(!showReply)}
_hover={{ color: forumColors.primary[500] }}
>
<MessageCircle size={14} />
<Text>回复</Text>
</HStack>
</HStack>
{/* 回复输入框 */}
{showReply && (
<MotionBox
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
mt="2"
>
<ReplyInput
postId={postId}
parentId={comment.id}
placeholder={`回复 @${comment.author_name}`}
onSubmit={() => {
setShowReply(false);
if (onReply) onReply();
}}
/>
</MotionBox>
)}
</VStack>
</Flex>
</MotionBox>
);
};
const ReplyInput = ({ postId, parentId = null, placeholder, onSubmit }) => {
const toast = useToast();
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!content.trim()) {
toast({
title: '请输入评论内容',
status: 'warning',
duration: 2000,
});
return;
}
setIsSubmitting(true);
try {
await createComment({
post_id: postId,
parent_id: parentId,
content: content.trim(),
author_id: user?.id || 'anonymous',
author_name: user?.name || '匿名用户',
author_avatar: user?.avatar || '',
});
toast({
title: '评论成功',
status: 'success',
duration: 2000,
});
setContent('');
if (onSubmit) onSubmit();
} catch (error) {
console.error('评论失败:', error);
toast({
title: '评论失败',
description: error.message,
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Flex gap="2" align="end">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder || '写下你的评论...'}
size="sm"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
minH="80px"
resize="vertical"
/>
<IconButton
icon={<Send size={18} />}
onClick={handleSubmit}
isLoading={isSubmitting}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
size="sm"
h="40px"
/>
</Flex>
);
};
const CommentSection = ({ postId }) => {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
// 加载评论
const loadComments = async () => {
try {
setLoading(true);
const result = await getCommentsByPostId(postId);
setComments(result.comments);
setTotal(result.total);
} catch (error) {
console.error('加载评论失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadComments();
}, [postId]);
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="6"
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb="6">
<HStack spacing="2">
<MessageCircle size={20} color={forumColors.primary[500]} />
<Text fontSize="lg" fontWeight="bold" color={forumColors.text.primary}>
评论
</Text>
</HStack>
<Text fontSize="sm" color={forumColors.text.tertiary}>
{total}
</Text>
</Flex>
{/* 发表评论 */}
<Box mb="6">
<ReplyInput postId={postId} onSubmit={loadComments} />
</Box>
<Divider borderColor={forumColors.border.default} mb="4" />
{/* 评论列表 */}
{loading ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
加载中...
</Text>
) : comments.length === 0 ? (
<Text color={forumColors.text.secondary} textAlign="center" py="8">
暂无评论快来抢沙发吧
</Text>
) : (
<VStack align="stretch" spacing="0" divider={<Divider borderColor={forumColors.border.default} />}>
<AnimatePresence>
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
postId={postId}
onReply={loadComments}
/>
))}
</AnimatePresence>
</VStack>
)}
</Box>
);
};
export default CommentSection;

View File

@@ -0,0 +1,419 @@
/**
* 发帖模态框组件
* 用于创建新帖子
*/
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
Input,
Textarea,
VStack,
HStack,
Text,
Box,
Image,
IconButton,
Tag,
TagLabel,
TagCloseButton,
useToast,
FormControl,
FormLabel,
FormErrorMessage,
} from '@chakra-ui/react';
import { ImagePlus, X, Hash } from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
import { createPost } from '@services/elasticsearchService';
import { useAuth } from '@contexts/AuthContext';
const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => {
const toast = useToast();
const { user } = useAuth();
const [formData, setFormData] = useState({
title: '',
content: '',
images: [],
tags: [],
category: '',
});
const [currentTag, setCurrentTag] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 表单验证
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = '请输入标题';
} else if (formData.title.length > 100) {
newErrors.title = '标题不能超过100个字符';
}
if (!formData.content.trim()) {
newErrors.content = '请输入内容';
} else if (formData.content.length > 5000) {
newErrors.content = '内容不能超过5000个字符';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理图片上传
const handleImageUpload = (e) => {
const files = Array.from(e.target.files);
files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
images: [...prev.images, reader.result],
}));
};
reader.readAsDataURL(file);
});
};
// 移除图片
const removeImage = (index) => {
setFormData((prev) => ({
...prev,
images: prev.images.filter((_, i) => i !== index),
}));
};
// 添加标签
const addTag = () => {
if (currentTag.trim() && !formData.tags.includes(currentTag.trim())) {
if (formData.tags.length >= 5) {
toast({
title: '标签数量已达上限',
description: '最多只能添加5个标签',
status: 'warning',
duration: 2000,
});
return;
}
setFormData((prev) => ({
...prev,
tags: [...prev.tags, currentTag.trim()],
}));
setCurrentTag('');
}
};
// 移除标签
const removeTag = (tag) => {
setFormData((prev) => ({
...prev,
tags: prev.tags.filter((t) => t !== tag),
}));
};
// 提交帖子
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
const postData = {
...formData,
author_id: user?.id || 'anonymous',
author_name: user?.name || '匿名用户',
author_avatar: user?.avatar || '',
};
const newPost = await createPost(postData);
toast({
title: '发布成功',
description: '帖子已成功发布到论坛',
status: 'success',
duration: 3000,
});
// 重置表单
setFormData({
title: '',
content: '',
images: [],
tags: [],
category: '',
});
onClose();
// 通知父组件刷新
if (onPostCreated) {
onPostCreated(newPost);
}
} catch (error) {
console.error('发布帖子失败:', error);
toast({
title: '发布失败',
description: error.message || '发布帖子时出错,请稍后重试',
status: 'error',
duration: 3000,
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay bg="blackAlpha.800" />
<ModalContent
bg={forumColors.background.elevated}
borderColor={forumColors.border.gold}
borderWidth="1px"
maxH="90vh"
>
<ModalHeader
color={forumColors.text.primary}
borderBottomWidth="1px"
borderBottomColor={forumColors.border.default}
>
<Text
bgGradient={forumColors.text.goldGradient}
bgClip="text"
fontWeight="bold"
fontSize="xl"
>
发布新帖
</Text>
</ModalHeader>
<ModalCloseButton color={forumColors.text.secondary} />
<ModalBody py="6" overflowY="auto">
<VStack spacing="5" align="stretch">
{/* 标题输入 */}
<FormControl isInvalid={errors.title}>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
标题
</FormLabel>
<Input
placeholder="给你的帖子起个吸引人的标题..."
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
}
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
<FormErrorMessage>{errors.title}</FormErrorMessage>
</FormControl>
{/* 内容输入 */}
<FormControl isInvalid={errors.content}>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
内容
</FormLabel>
<Textarea
placeholder="分享你的投资见解、市场观点或交易心得..."
value={formData.content}
onChange={(e) =>
setFormData((prev) => ({ ...prev, content: e.target.value }))
}
minH="200px"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
resize="vertical"
/>
<FormErrorMessage>{errors.content}</FormErrorMessage>
<Text
fontSize="xs"
color={forumColors.text.muted}
mt="2"
textAlign="right"
>
{formData.content.length} / 5000
</Text>
</FormControl>
{/* 图片上传 */}
<Box>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
图片最多9张
</FormLabel>
<HStack spacing="3" flexWrap="wrap">
{formData.images.map((img, index) => (
<Box key={index} position="relative" w="100px" h="100px">
<Image
src={img}
alt={`预览 ${index + 1}`}
w="100%"
h="100%"
objectFit="cover"
borderRadius="md"
border="1px solid"
borderColor={forumColors.border.default}
/>
<IconButton
icon={<X size={14} />}
size="xs"
position="absolute"
top="-2"
right="-2"
borderRadius="full"
bg={forumColors.background.main}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.gold}
onClick={() => removeImage(index)}
_hover={{ bg: forumColors.background.hover }}
/>
</Box>
))}
{formData.images.length < 9 && (
<Box
as="label"
w="100px"
h="100px"
display="flex"
alignItems="center"
justifyContent="center"
bg={forumColors.background.secondary}
border="2px dashed"
borderColor={forumColors.border.default}
borderRadius="md"
cursor="pointer"
_hover={{ borderColor: forumColors.border.gold }}
>
<Input
type="file"
accept="image/*"
multiple
display="none"
onChange={handleImageUpload}
/>
<ImagePlus size={24} color={forumColors.text.tertiary} />
</Box>
)}
</HStack>
</Box>
{/* 标签输入 */}
<Box>
<FormLabel color={forumColors.text.secondary} fontSize="sm">
标签最多5个
</FormLabel>
<HStack mb="3">
<Input
placeholder="输入标签后按回车"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
<IconButton
icon={<Hash size={18} />}
onClick={addTag}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
_hover={{ opacity: 0.9 }}
/>
</HStack>
<HStack spacing="2" flexWrap="wrap">
{formData.tags.map((tag) => (
<Tag
key={tag}
size="md"
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
>
<TagLabel>#{tag}</TagLabel>
<TagCloseButton onClick={() => removeTag(tag)} />
</Tag>
))}
</HStack>
</Box>
</VStack>
</ModalBody>
<ModalFooter
borderTopWidth="1px"
borderTopColor={forumColors.border.default}
>
<HStack spacing="3">
<Button
variant="ghost"
onClick={onClose}
color={forumColors.text.secondary}
_hover={{ bg: forumColors.background.hover }}
>
取消
</Button>
<Button
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
fontWeight="bold"
onClick={handleSubmit}
isLoading={isSubmitting}
loadingText="发布中..."
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CreatePostModal;

View File

@@ -0,0 +1,347 @@
/**
* 事件时间轴组件
* 展示帖子相关事件的时间线发展
*/
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Flex,
Badge,
Link,
Icon,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import {
TrendingUp,
AlertCircle,
FileText,
ExternalLink,
Clock,
Zap,
} from 'lucide-react';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
// 事件类型配置
const EVENT_TYPES = {
news: {
label: '新闻',
icon: FileText,
color: forumColors.semantic.info,
},
price_change: {
label: '价格变动',
icon: TrendingUp,
color: forumColors.semantic.warning,
},
announcement: {
label: '公告',
icon: AlertCircle,
color: forumColors.semantic.error,
},
analysis: {
label: '分析',
icon: Zap,
color: forumColors.primary[500],
},
};
// 重要性配置
const IMPORTANCE_LEVELS = {
high: {
label: '重要',
color: forumColors.semantic.error,
dotSize: '16px',
},
medium: {
label: '一般',
color: forumColors.semantic.warning,
dotSize: '12px',
},
low: {
label: '提示',
color: forumColors.text.tertiary,
dotSize: '10px',
},
};
const EventTimeline = ({ events = [] }) => {
// 格式化时间
const formatEventTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (!events || events.length === 0) {
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="8"
textAlign="center"
>
<VStack spacing="3">
<Clock size={48} color={forumColors.text.tertiary} />
<Text color={forumColors.text.secondary} fontSize="md">
暂无事件追踪
</Text>
<Text color={forumColors.text.muted} fontSize="sm">
AI 将自动追踪与本帖相关的市场事件
</Text>
</VStack>
</Box>
);
}
return (
<Box
bg={forumColors.background.card}
borderRadius="lg"
border="1px solid"
borderColor={forumColors.border.default}
p="6"
>
{/* 标题 */}
<Flex justify="space-between" align="center" mb="6">
<HStack spacing="2">
<Clock size={20} color={forumColors.primary[500]} />
<Text
fontSize="lg"
fontWeight="bold"
color={forumColors.text.primary}
>
事件时间轴
</Text>
</HStack>
<Badge
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
>
{events.length} 个事件
</Badge>
</Flex>
{/* 时间轴列表 */}
<VStack align="stretch" spacing="4" position="relative">
{/* 连接线 */}
<Box
position="absolute"
left="7px"
top="20px"
bottom="20px"
w="2px"
bg={forumColors.border.default}
zIndex="0"
/>
{events.map((event, index) => {
const eventType = EVENT_TYPES[event.event_type] || EVENT_TYPES.news;
const importance =
IMPORTANCE_LEVELS[event.importance] || IMPORTANCE_LEVELS.medium;
const EventIcon = eventType.icon;
return (
<MotionBox
key={event.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
position="relative"
zIndex="1"
>
<Flex gap="4">
{/* 时间轴节点 */}
<Box position="relative" flexShrink="0">
{/* 外圈光晕 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="24px"
h="24px"
borderRadius="full"
bg={importance.color}
opacity="0.2"
animation={
index === 0 ? 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite' : 'none'
}
/>
{/* 节点圆点 */}
<Flex
w={importance.dotSize}
h={importance.dotSize}
borderRadius="full"
bg={importance.color}
border="3px solid"
borderColor={forumColors.background.card}
align="center"
justify="center"
>
<Icon
as={EventIcon}
boxSize="8px"
color={forumColors.background.main}
/>
</Flex>
</Box>
{/* 事件内容卡片 */}
<Box
flex="1"
bg={forumColors.background.secondary}
border="1px solid"
borderColor={forumColors.border.default}
borderRadius="md"
p="4"
_hover={{
borderColor: forumColors.border.light,
bg: forumColors.background.hover,
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing="2">
{/* 标题和标签 */}
<Flex justify="space-between" align="start" gap="2">
<Text
fontSize="sm"
fontWeight="600"
color={forumColors.text.primary}
flex="1"
>
{event.title}
</Text>
<HStack spacing="2" flexShrink="0">
<Badge
size="sm"
bg="transparent"
color={eventType.color}
border="1px solid"
borderColor={eventType.color}
fontSize="xs"
>
{eventType.label}
</Badge>
{event.importance === 'high' && (
<Badge
size="sm"
bg={forumColors.semantic.error}
color="white"
fontSize="xs"
>
{importance.label}
</Badge>
)}
</HStack>
</Flex>
{/* 描述 */}
{event.description && (
<Text
fontSize="xs"
color={forumColors.text.secondary}
lineHeight="1.6"
>
{event.description}
</Text>
)}
{/* 相关股票 */}
{event.related_stocks && event.related_stocks.length > 0 && (
<HStack spacing="2" flexWrap="wrap">
{event.related_stocks.map((stock) => (
<Badge
key={stock}
size="sm"
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
fontSize="xs"
>
{stock}
</Badge>
))}
</HStack>
)}
{/* 底部信息 */}
<Flex justify="space-between" align="center" pt="2">
<Text fontSize="xs" color={forumColors.text.muted}>
{formatEventTime(event.occurred_at)}
</Text>
{event.source_url && (
<Link
href={event.source_url}
isExternal
fontSize="xs"
color={forumColors.primary[500]}
display="flex"
alignItems="center"
gap="1"
_hover={{ textDecoration: 'underline' }}
>
查看来源
<ExternalLink size={12} />
</Link>
)}
</Flex>
</VStack>
</Box>
</Flex>
</MotionBox>
);
})}
</VStack>
{/* CSS 动画 */}
<style>
{`
@keyframes pulse {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.4;
}
}
`}
</style>
</Box>
);
};
export default EventTimeline;

View File

@@ -0,0 +1,203 @@
/**
* 帖子卡片组件 - 类似小红书风格
* 用于论坛主页的帖子展示
*/
import React from 'react';
import {
Box,
Image,
Text,
HStack,
VStack,
Avatar,
Badge,
IconButton,
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { Heart, MessageCircle, Eye, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { forumColors } from '@theme/forumTheme';
const MotionBox = motion(Box);
const PostCard = ({ post }) => {
const navigate = useNavigate();
// 处理卡片点击
const handleCardClick = () => {
navigate(`/value-forum/post/${post.id}`);
};
// 格式化数字1000 -> 1k
const formatNumber = (num) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num;
};
// 格式化时间
const formatTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
};
return (
<MotionBox
bg={forumColors.background.card}
borderRadius="xl"
overflow="hidden"
border="1px solid"
borderColor={forumColors.border.default}
cursor="pointer"
onClick={handleCardClick}
whileHover={{ y: -8, scale: 1.02 }}
transition={{ duration: 0.3 }}
_hover={{
borderColor: forumColors.border.gold,
boxShadow: forumColors.shadows.gold,
}}
>
{/* 封面图片区域 */}
{post.images && post.images.length > 0 && (
<Box position="relative" overflow="hidden" h="200px">
<Image
src={post.images[0]}
alt={post.title}
w="100%"
h="100%"
objectFit="cover"
transition="transform 0.3s"
_groupHover={{ transform: 'scale(1.1)' }}
/>
{/* 置顶标签 */}
{post.is_pinned && (
<Badge
position="absolute"
top="12px"
right="12px"
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
px="3"
py="1"
borderRadius="full"
fontWeight="bold"
fontSize="xs"
display="flex"
alignItems="center"
gap="1"
>
<TrendingUp size={12} />
置顶
</Badge>
)}
</Box>
)}
{/* 内容区域 */}
<VStack align="stretch" p="4" spacing="3">
{/* 标题 */}
<Text
fontSize="md"
fontWeight="600"
color={forumColors.text.primary}
noOfLines={2}
lineHeight="1.4"
>
{post.title}
</Text>
{/* 内容预览 */}
{post.content && (
<Text
fontSize="sm"
color={forumColors.text.secondary}
noOfLines={2}
lineHeight="1.6"
>
{post.content}
</Text>
)}
{/* 标签 */}
{post.tags && post.tags.length > 0 && (
<HStack spacing="2" flexWrap="wrap">
{post.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
bg={forumColors.gradients.goldSubtle}
color={forumColors.primary[500]}
border="1px solid"
borderColor={forumColors.border.gold}
borderRadius="full"
px="3"
py="1"
fontSize="xs"
fontWeight="500"
>
#{tag}
</Badge>
))}
</HStack>
)}
{/* 底部信息栏 */}
<Flex justify="space-between" align="center" pt="2">
{/* 作者信息 */}
<HStack spacing="2">
<Avatar
size="xs"
name={post.author_name}
src={post.author_avatar}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
/>
<Text fontSize="xs" color={forumColors.text.tertiary}>
{post.author_name}
</Text>
</HStack>
{/* 互动数据 */}
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
<HStack spacing="1">
<Heart size={14} />
<Text>{formatNumber(post.likes_count || 0)}</Text>
</HStack>
<HStack spacing="1">
<MessageCircle size={14} />
<Text>{formatNumber(post.comments_count || 0)}</Text>
</HStack>
<HStack spacing="1">
<Eye size={14} />
<Text>{formatNumber(post.views_count || 0)}</Text>
</HStack>
</HStack>
</Flex>
{/* 时间 */}
<Text fontSize="xs" color={forumColors.text.muted} textAlign="right">
{formatTime(post.created_at)}
</Text>
</VStack>
</MotionBox>
);
};
export default PostCard;

View File

@@ -0,0 +1,311 @@
/**
* 价值论坛主页面
* 类似小红书/X的帖子广场
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
Button,
HStack,
VStack,
SimpleGrid,
Input,
InputGroup,
InputLeftElement,
Select,
Spinner,
Center,
useDisclosure,
Flex,
Badge,
} from '@chakra-ui/react';
import { Search, PenSquare, TrendingUp, Clock, Heart } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { forumColors } from '@theme/forumTheme';
import { getPosts, searchPosts } from '@services/elasticsearchService';
import PostCard from './components/PostCard';
import CreatePostModal from './components/CreatePostModal';
const MotionBox = motion(Box);
const ValueForum = () => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(true);
const { isOpen, onOpen, onClose } = useDisclosure();
// 获取帖子列表
const fetchPosts = async (currentPage = 1, reset = false) => {
try {
setLoading(true);
let result;
if (searchKeyword.trim()) {
result = await searchPosts(searchKeyword, {
page: currentPage,
size: 20,
});
} else {
result = await getPosts({
page: currentPage,
size: 20,
sort: sortBy,
order: 'desc',
});
}
if (reset) {
setPosts(result.posts);
} else {
setPosts((prev) => [...prev, ...result.posts]);
}
setTotal(result.total);
setHasMore(result.posts.length === 20);
} catch (error) {
console.error('获取帖子列表失败:', error);
} finally {
setLoading(false);
}
};
// 初始化加载
useEffect(() => {
fetchPosts(1, true);
}, [sortBy]);
// 搜索处理
const handleSearch = () => {
setPage(1);
fetchPosts(1, true);
};
// 加载更多
const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchPosts(nextPage, false);
};
// 发帖成功回调
const handlePostCreated = () => {
setPage(1);
fetchPosts(1, true);
};
// 排序选项
const sortOptions = [
{ value: 'created_at', label: '最新发布', icon: Clock },
{ value: 'likes_count', label: '最多点赞', icon: Heart },
{ value: 'views_count', label: '最多浏览', icon: TrendingUp },
];
return (
<Box
minH="100vh"
bg={forumColors.background.main}
pt="80px"
pb="20"
>
<Container maxW="container.xl">
{/* 顶部横幅 */}
<MotionBox
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
mb="10"
>
<VStack spacing="4" align="stretch">
{/* 标题区域 */}
<Flex justify="space-between" align="center">
<VStack align="start" spacing="2">
<Heading
as="h1"
fontSize="4xl"
fontWeight="bold"
bgGradient={forumColors.text.goldGradient}
bgClip="text"
>
价值论坛
</Heading>
<Text color={forumColors.text.secondary} fontSize="md">
分享投资见解追踪市场热点共同发现价值
</Text>
</VStack>
{/* 发帖按钮 */}
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
size="lg"
fontWeight="bold"
onClick={onOpen}
_hover={{
transform: 'translateY(-2px)',
boxShadow: forumColors.shadows.goldHover,
}}
_active={{ transform: 'translateY(0)' }}
>
发布帖子
</Button>
</Flex>
{/* 搜索和筛选栏 */}
<Flex gap="4" align="center" flexWrap="wrap">
{/* 搜索框 */}
<InputGroup maxW="400px" flex="1">
<InputLeftElement pointerEvents="none">
<Search size={18} color={forumColors.text.tertiary} />
</InputLeftElement>
<Input
placeholder="搜索帖子标题、内容、标签..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
bg={forumColors.background.card}
border="1px solid"
borderColor={forumColors.border.default}
color={forumColors.text.primary}
_placeholder={{ color: forumColors.text.tertiary }}
_hover={{ borderColor: forumColors.border.light }}
_focus={{
borderColor: forumColors.border.gold,
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
}}
/>
</InputGroup>
{/* 排序选项 */}
<HStack spacing="2">
{sortOptions.map((option) => {
const Icon = option.icon;
const isActive = sortBy === option.value;
return (
<Button
key={option.value}
leftIcon={<Icon size={16} />}
size="md"
variant={isActive ? 'solid' : 'outline'}
bg={isActive ? forumColors.gradients.goldPrimary : 'transparent'}
color={isActive ? forumColors.background.main : forumColors.text.secondary}
borderColor={forumColors.border.default}
onClick={() => setSortBy(option.value)}
_hover={{
bg: isActive
? forumColors.gradients.goldPrimary
: forumColors.background.hover,
borderColor: forumColors.border.gold,
}}
>
{option.label}
</Button>
);
})}
</HStack>
</Flex>
{/* 统计信息 */}
<HStack spacing="6" color={forumColors.text.tertiary} fontSize="sm">
<Text>
<Text as="span" color={forumColors.primary[500]} fontWeight="bold">{total}</Text>
</Text>
</HStack>
</VStack>
</MotionBox>
{/* 帖子网格 */}
{loading && page === 1 ? (
<Center py="20">
<VStack spacing="4">
<Spinner
size="xl"
thickness="4px"
speed="0.8s"
color={forumColors.primary[500]}
/>
<Text color={forumColors.text.secondary}>加载中...</Text>
</VStack>
</Center>
) : posts.length === 0 ? (
<Center py="20">
<VStack spacing="4">
<Text color={forumColors.text.secondary} fontSize="lg">
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
</Text>
{!searchKeyword && (
<Button
leftIcon={<PenSquare size={18} />}
bg={forumColors.gradients.goldPrimary}
color={forumColors.background.main}
onClick={onOpen}
_hover={{ opacity: 0.9 }}
>
发布帖子
</Button>
)}
</VStack>
</Center>
) : (
<>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
<AnimatePresence>
{posts.map((post, index) => (
<MotionBox
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<PostCard post={post} />
</MotionBox>
))}
</AnimatePresence>
</SimpleGrid>
{/* 加载更多按钮 */}
{hasMore && (
<Center mt="10">
<Button
onClick={loadMore}
isLoading={loading}
loadingText="加载中..."
bg={forumColors.background.card}
color={forumColors.text.primary}
border="1px solid"
borderColor={forumColors.border.default}
_hover={{
borderColor: forumColors.border.gold,
bg: forumColors.background.hover,
}}
>
加载更多
</Button>
</Center>
)}
</>
)}
</Container>
{/* 发帖模态框 */}
<CreatePostModal
isOpen={isOpen}
onClose={onClose}
onPostCreated={handlePostCreated}
/>
</Box>
);
};
export default ValueForum;

528
valuefrontier Normal file
View File

@@ -0,0 +1,528 @@
# /etc/nginx/sites-available/valuefrontier
# 完整配置 - Next.js只处理特定页面其他全部导向React应用
# WebSocket 连接升级映射
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP (端口 80) 服务器块: 负责将所有HTTP请求重定向到HTTPS
server {
listen 80;
server_name valuefrontier.cn www.valuefrontier.cn;
# 这一段是为了让Certbot自动续期时能正常工作
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location ~ \.txt$ {
root /var/www/valuefrontier.cn;
add_header Content-Type "text/plain";
}
location / {
# 301永久重定向到安全的HTTPS版本
return 301 https://$host$request_uri;
}
}
# HTTPS (端口 443) 服务器块: 处理所有加密流量和实际的应用逻辑
server {
listen 443 ssl http2;
server_name valuefrontier.cn www.valuefrontier.cn;
# --- SSL 证书配置 ---
ssl_certificate /etc/letsencrypt/live/valuefrontier.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/valuefrontier.cn/privkey.pem;
# --- 为React应用提供静态资源 ---
location /static/ {
alias /var/www/valuefrontier.cn/static/;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin *;
}
location ~ \.txt$ {
root /var/www/valuefrontier.cn;
add_header Content-Type "text/plain";
}
location /manifest.json {
alias /var/www/valuefrontier.cn/manifest.json;
add_header Content-Type "application/json";
}
location /favicon.ico {
alias /var/www/valuefrontier.cn/favicon.ico;
}
# --- Next.js 专用静态资源 (已废弃,保留以防需要回滚) ---
# location /_next/ {
# proxy_pass http://127.0.0.1:3000;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# expires 1y;
# add_header Cache-Control "public, immutable";
# }
# --- 后端API反向代理 ---
location /api/ {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
location /mcp/ {
proxy_pass http://127.0.0.1:8900/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
gzip off;
add_header X-Accel-Buffering no;
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# 概念板块API代理
location /concept-api/ {
proxy_pass http://222.128.1.157:16801/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
proxy_set_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Elasticsearch API代理价值论坛
location /es-api/ {
proxy_pass http://222.128.1.157:19200/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 配置
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, HEAD' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 禁用缓冲以支持流式响应
proxy_buffering off;
}
# 拜特桌面API代理 (bytedesk)
location /bytedesk/ {
proxy_pass http://43.143.189.195/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# 2. WebSocket代理必需
location /websocket {
proxy_pass http://43.143.189.195/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 86400s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
}
# iframe 内部资源代理Bytedesk 聊天窗口的 CSS/JS
location /chat/ {
proxy_pass http://43.143.189.195/chat/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# ✨ 自动替换响应内容中的 IP 地址为域名(解决 Mixed Content
sub_filter 'http://43.143.189.195' 'https://valuefrontier.cn';
sub_filter_once off; # 替换所有匹配项
sub_filter_types text/css text/javascript application/javascript application/json;
# CORS 头部
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers'
'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# Bytedesk 配置接口代理
location /config/ {
proxy_pass http://43.143.189.195/config/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 头部
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers'
'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
if ($request_method = 'OPTIONS') {
return 204;
}
# 超时设置
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# Bytedesk 上传文件代理
location ^~ /uploads/ {
proxy_pass http://43.143.189.195/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 缓存配置
proxy_cache_valid 200 1d;
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Visitor API 代理Bytedesk 初始化接口)
location /visitor/ {
proxy_pass http://43.143.189.195/visitor/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# ✨ 禁用压缩(让 sub_filter 能处理 JSON
proxy_set_header Accept-Encoding "";
# ✨ 替换 JSON 响应中的 IP 地址为域名
sub_filter 'http://43.143.189.195' 'https://valuefrontier.cn';
sub_filter_once off;
sub_filter_types application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location = /stomp {
proxy_pass http://43.143.189.195/api/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
location /stomp/ {
proxy_pass http://43.143.189.195/api/websocket/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
proxy_buffering off;
}
# 6. 头像和静态资源解决404问题
location ^~ /avatars/ {
proxy_pass http://43.143.189.195/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 缓存静态资源
proxy_cache_valid 200 1d;
proxy_cache_bypass $http_cache_control;
}
# 7. Bytedesk所有其他资源
location /assets/ {
proxy_pass http://43.143.189.195/assets/;
proxy_set_header Host $host;
expires 1d;
add_header Cache-Control "public, immutable";
}
# 新闻搜索API代理
location /news-api/ {
proxy_pass http://222.128.1.157:21891/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
proxy_set_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_read_timeout 90s;
}
# 研报搜索API代理
location /report-api/ {
proxy_pass http://222.128.1.157:8811/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Access-Control-Allow-Origin *;
proxy_set_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
proxy_set_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# --- 新的静态官网静态资源(优先级最高) ---
# 使用 ^~ 前缀确保优先匹配,不被后面的规则覆盖
location ^~ /css/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /js/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /img/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /videos/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
location ^~ /fonts/ {
root /var/www/valuefrontier;
expires 1y;
add_header Cache-Control "public, immutable";
}
# 官网HTML页面
location ~ ^/(conversational-ai|customizable-workflows|integration|docs|sign-in|sign-up|reset-password)\.html$ {
root /var/www/valuefrontier;
try_files $uri =404;
}
# 官网首页 - 静态HTML
location = / {
root /var/www/valuefrontier;
try_files /index.html =404;
}
# --- React 应用 (默认 catch-all) ---
# 所有其他路径都导向React应用
# 包括: /home, /community, /concepts, /limit-analyse, /stocks 等
location / {
root /var/www/valuefrontier.cn;
index index.html index.htm;
# 所有SPA路由都返回index.html让React处理
try_files $uri $uri/ /index.html;
# 为静态资源添加缓存头
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin *;
}
}
}
# ==========================================================
# api.valuefrontier.cn - 服务器配置块 (START)
# ==========================================================
server {
if ($host = api.valuefrontier.cn) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name api.valuefrontier.cn; # <--- 明确指定域名
# 这一段是为了让Certbot自动续期时能正常工作
location /.well-known/acme-challenge/ {
root /var/www/html;
}
# 将所有到 api 域名的 HTTP 请求重定向到 HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.valuefrontier.cn; # <--- 明确指定域名
# --- SSL 证书配置 ---
# Certbot 会自动填充这里,但我们可以先写上占位符
ssl_certificate /etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem; # managed by Certbot
# (推荐) 包含通用的SSL安全配置
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# --- API 反向代理 ---
location / {
# 假设你的API后端运行在本地的8080端口
# 请修改为你API服务的真实地址和端口
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ==========================================================
# api.valuefrontier.cn - 服务器配置块 (END)
# ==========================================================