Compare commits
33 Commits
feature_bu
...
65c16d65ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65c16d65ac | ||
|
|
13a291b979 | ||
|
|
4d6da77aeb | ||
|
|
fc1f667700 | ||
|
|
46639030bb | ||
|
|
f747a0bdb2 | ||
|
|
9b55610167 | ||
|
|
a93fcfa9b9 | ||
|
|
8914a46c40 | ||
|
|
678eb6838e | ||
|
|
c06d3a88ae | ||
|
|
307c308739 | ||
|
|
cbb6517bb1 | ||
|
|
f33489f5d7 | ||
|
|
9ff77b570d | ||
|
|
de37546ddb | ||
|
|
163c55f819 | ||
|
|
990d1ca0bc | ||
|
|
3fe2d2bdc9 | ||
|
|
a9f0c5ced2 | ||
|
|
9b355b402d | ||
|
|
3cadd02492 | ||
|
|
d69a32a320 | ||
|
|
8d3327e4dd | ||
|
|
3a02c13dfe | ||
| d28915ac90 | |||
| b2f3a8f140 | |||
| 3014317c12 | |||
| 2013a0f868 | |||
| 05b497de29 | |||
|
|
d9013d1e85 | ||
| 2753fbc37f | |||
| 43de7f7a52 |
69
app.py
69
app.py
@@ -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():
|
||||
"""获取当前用户关注的未来事件"""
|
||||
|
||||
12556
app.py.backup
12556
app.py.backup
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@ module.exports = {
|
||||
},
|
||||
// 日期/日历库
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
name: 'calendar-lib',
|
||||
priority: 18,
|
||||
reuseExistingChunk: true,
|
||||
@@ -161,13 +161,8 @@ module.exports = {
|
||||
);
|
||||
}
|
||||
|
||||
// 忽略 moment 的语言包(如果项目使用了 moment)
|
||||
webpackConfig.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^\.\/locale$/,
|
||||
contextRegExp: /moment$/,
|
||||
})
|
||||
);
|
||||
// Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
|
||||
// 如果需要优化,可以只导入需要的语言包
|
||||
|
||||
// ============== Loader 优化 ==============
|
||||
const babelLoaderRule = webpackConfig.module.rules.find(
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
# 通知系统增强功能 - 使用指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本指南介绍通知系统的三大增强功能:
|
||||
1. **智能桌面通知** - 自动请求权限,系统级通知
|
||||
2. **性能监控** - 追踪推送效果,数据驱动优化
|
||||
3. **历史记录** - 持久化存储,随时查询
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能 1:智能桌面通知
|
||||
|
||||
### 功能说明
|
||||
|
||||
首次收到重要/紧急通知时,自动请求浏览器通知权限,确保用户不错过关键信息。
|
||||
|
||||
### 工作原理
|
||||
|
||||
```javascript
|
||||
// 在 NotificationContext 中的逻辑
|
||||
if (priority === URGENT || priority === IMPORTANT) {
|
||||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
||||
// 首次遇到重要通知,自动请求权限
|
||||
await requestBrowserPermission();
|
||||
setHasRequestedPermission(true); // 避免重复请求
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 权限状态
|
||||
|
||||
- **granted**: 已授权,可以发送桌面通知
|
||||
- **denied**: 已拒绝,无法发送桌面通知
|
||||
- **default**: 未请求,首次重要通知时会自动请求
|
||||
|
||||
### 使用示例
|
||||
|
||||
**自动触发**(推荐)
|
||||
```javascript
|
||||
// 无需任何代码,系统自动处理
|
||||
// 首次收到重要/紧急通知时会自动弹出权限请求
|
||||
```
|
||||
|
||||
**手动请求**
|
||||
```javascript
|
||||
import { useNotification } from 'contexts/NotificationContext';
|
||||
|
||||
function SettingsPage() {
|
||||
const { requestBrowserPermission, browserPermission } = useNotification();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>当前状态: {browserPermission}</p>
|
||||
<button onClick={requestBrowserPermission}>
|
||||
开启桌面通知
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 通知分发策略
|
||||
|
||||
| 优先级 | 页面在前台 | 页面在后台 |
|
||||
|-------|----------|----------|
|
||||
| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
|
||||
| 重要 | 网页通知 | 桌面通知 |
|
||||
| 普通 | 网页通知 | 网页通知 |
|
||||
|
||||
### 测试步骤
|
||||
|
||||
1. **清除已保存的权限状态**
|
||||
```javascript
|
||||
localStorage.removeItem('browser_notification_requested');
|
||||
```
|
||||
|
||||
2. **刷新页面**
|
||||
|
||||
3. **触发一个重要/紧急通知**
|
||||
- Mock 模式:等待自动推送
|
||||
- Real 模式:创建测试事件
|
||||
|
||||
4. **观察权限请求弹窗**
|
||||
- 浏览器会弹出通知权限请求
|
||||
- 点击"允许"授权
|
||||
|
||||
5. **验证桌面通知**
|
||||
- 切换到其他标签页
|
||||
- 收到重要通知时应该看到桌面通知
|
||||
|
||||
---
|
||||
|
||||
## 📊 功能 2:性能监控
|
||||
|
||||
### 功能说明
|
||||
|
||||
追踪通知推送的各项指标,包括:
|
||||
- **到达率**: 发送 vs 接收
|
||||
- **点击率**: 点击 vs 接收
|
||||
- **响应时间**: 收到通知到点击的平均时间
|
||||
- **类型分布**: 各类型通知的数量和效果
|
||||
- **时段分布**: 每小时推送量
|
||||
|
||||
### API 参考
|
||||
|
||||
#### 获取汇总统计
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
|
||||
const summary = notificationMetricsService.getSummary();
|
||||
console.log(summary);
|
||||
/* 输出:
|
||||
{
|
||||
totalSent: 100,
|
||||
totalReceived: 98,
|
||||
totalClicked: 45,
|
||||
totalDismissed: 53,
|
||||
avgResponseTime: 5200, // 毫秒
|
||||
clickRate: '45.92', // 百分比
|
||||
deliveryRate: '98.00' // 百分比
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取按类型统计
|
||||
|
||||
```javascript
|
||||
const byType = notificationMetricsService.getByType();
|
||||
console.log(byType);
|
||||
/* 输出:
|
||||
{
|
||||
announcement: { sent: 20, received: 20, clicked: 15, dismissed: 5, clickRate: '75.00' },
|
||||
stock_alert: { sent: 30, received: 30, clicked: 20, dismissed: 10, clickRate: '66.67' },
|
||||
event_alert: { sent: 40, received: 38, clicked: 10, dismissed: 28, clickRate: '26.32' },
|
||||
analysis_report: { sent: 10, received: 10, clicked: 0, dismissed: 10, clickRate: '0.00' }
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取按优先级统计
|
||||
|
||||
```javascript
|
||||
const byPriority = notificationMetricsService.getByPriority();
|
||||
console.log(byPriority);
|
||||
/* 输出:
|
||||
{
|
||||
urgent: { sent: 10, received: 10, clicked: 9, dismissed: 1, clickRate: '90.00' },
|
||||
important: { sent: 40, received: 39, clicked: 25, dismissed: 14, clickRate: '64.10' },
|
||||
normal: { sent: 50, received: 49, clicked: 11, dismissed: 38, clickRate: '22.45' }
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取每日数据
|
||||
|
||||
```javascript
|
||||
const dailyData = notificationMetricsService.getDailyData(7); // 最近 7 天
|
||||
console.log(dailyData);
|
||||
/* 输出:
|
||||
[
|
||||
{ date: '2025-01-15', sent: 15, received: 14, clicked: 6, dismissed: 8, clickRate: '42.86' },
|
||||
{ date: '2025-01-16', sent: 20, received: 20, clicked: 10, dismissed: 10, clickRate: '50.00' },
|
||||
...
|
||||
]
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取完整指标
|
||||
|
||||
```javascript
|
||||
const allMetrics = notificationMetricsService.getAllMetrics();
|
||||
console.log(allMetrics);
|
||||
```
|
||||
|
||||
#### 导出数据
|
||||
|
||||
```javascript
|
||||
// 导出为 JSON
|
||||
const json = notificationMetricsService.exportToJSON();
|
||||
console.log(json);
|
||||
|
||||
// 导出为 CSV
|
||||
const csv = notificationMetricsService.exportToCSV();
|
||||
console.log(csv);
|
||||
```
|
||||
|
||||
#### 重置指标
|
||||
|
||||
```javascript
|
||||
notificationMetricsService.reset();
|
||||
```
|
||||
|
||||
### 在控制台查看实时指标
|
||||
|
||||
打开浏览器控制台,执行:
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
||||
|
||||
// 查看汇总
|
||||
console.table(notificationMetricsService.getSummary());
|
||||
|
||||
// 查看按类型分布
|
||||
console.table(notificationMetricsService.getByType());
|
||||
|
||||
// 查看最近 7 天数据
|
||||
console.table(notificationMetricsService.getDailyData(7));
|
||||
```
|
||||
|
||||
### 监控埋点(自动)
|
||||
|
||||
监控服务已自动集成到 `NotificationContext`,无需手动调用:
|
||||
|
||||
- **trackReceived**: 收到通知时自动调用
|
||||
- **trackClicked**: 点击通知时自动调用
|
||||
- **trackDismissed**: 关闭通知时自动调用
|
||||
|
||||
### 可视化展示(可选)
|
||||
|
||||
你可以基于监控数据创建仪表板:
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
import { PieChart, LineChart } from 'recharts';
|
||||
|
||||
function MetricsDashboard() {
|
||||
const summary = notificationMetricsService.getSummary();
|
||||
const dailyData = notificationMetricsService.getDailyData(7);
|
||||
const byType = notificationMetricsService.getByType();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 汇总卡片 */}
|
||||
<StatsCard title="总推送数" value={summary.totalSent} />
|
||||
<StatsCard title="点击率" value={`${summary.clickRate}%`} />
|
||||
<StatsCard title="平均响应时间" value={`${summary.avgResponseTime}ms`} />
|
||||
|
||||
{/* 类型分布饼图 */}
|
||||
<PieChart data={Object.entries(byType).map(([type, data]) => ({
|
||||
name: type,
|
||||
value: data.received
|
||||
}))} />
|
||||
|
||||
{/* 每日趋势折线图 */}
|
||||
<LineChart data={dailyData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 功能 3:历史记录
|
||||
|
||||
### 功能说明
|
||||
|
||||
持久化存储所有接收到的通知,支持:
|
||||
- 查询和筛选
|
||||
- 搜索关键词
|
||||
- 标记已读/已点击
|
||||
- 批量删除
|
||||
- 导出(JSON/CSV)
|
||||
|
||||
### API 参考
|
||||
|
||||
#### 获取历史记录(支持筛选和分页)
|
||||
|
||||
```javascript
|
||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
||||
|
||||
const result = notificationHistoryService.getHistory({
|
||||
type: 'event_alert', // 可选:筛选类型
|
||||
priority: 'urgent', // 可选:筛选优先级
|
||||
readStatus: 'unread', // 可选:'read' | 'unread' | 'all'
|
||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000, // 可选:开始日期
|
||||
endDate: Date.now(), // 可选:结束日期
|
||||
page: 1, // 页码
|
||||
pageSize: 20, // 每页数量
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
/* 输出:
|
||||
{
|
||||
records: [...], // 当前页的记录
|
||||
total: 150, // 总记录数
|
||||
page: 1, // 当前页
|
||||
pageSize: 20, // 每页数量
|
||||
totalPages: 8 // 总页数
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 搜索历史记录
|
||||
|
||||
```javascript
|
||||
const results = notificationHistoryService.searchHistory('降准');
|
||||
console.log(results); // 返回标题/内容中包含"降准"的所有记录
|
||||
```
|
||||
|
||||
#### 标记已读/已点击
|
||||
|
||||
```javascript
|
||||
// 标记已读
|
||||
notificationHistoryService.markAsRead('notification_id');
|
||||
|
||||
// 标记已点击
|
||||
notificationHistoryService.markAsClicked('notification_id');
|
||||
```
|
||||
|
||||
#### 删除记录
|
||||
|
||||
```javascript
|
||||
// 删除单条
|
||||
notificationHistoryService.deleteRecord('notification_id');
|
||||
|
||||
// 批量删除
|
||||
notificationHistoryService.deleteRecords(['id1', 'id2', 'id3']);
|
||||
|
||||
// 清空所有
|
||||
notificationHistoryService.clearHistory();
|
||||
```
|
||||
|
||||
#### 获取统计数据
|
||||
|
||||
```javascript
|
||||
const stats = notificationHistoryService.getStats();
|
||||
console.log(stats);
|
||||
/* 输出:
|
||||
{
|
||||
total: 500, // 总记录数
|
||||
read: 320, // 已读数
|
||||
unread: 180, // 未读数
|
||||
clicked: 150, // 已点击数
|
||||
clickRate: '30.00', // 点击率
|
||||
byType: { // 按类型统计
|
||||
announcement: 100,
|
||||
stock_alert: 150,
|
||||
event_alert: 200,
|
||||
analysis_report: 50
|
||||
},
|
||||
byPriority: { // 按优先级统计
|
||||
urgent: 50,
|
||||
important: 200,
|
||||
normal: 250
|
||||
}
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 导出历史记录
|
||||
|
||||
```javascript
|
||||
// 导出为 JSON 字符串
|
||||
const json = notificationHistoryService.exportToJSON({
|
||||
type: 'event_alert' // 可选:只导出特定类型
|
||||
});
|
||||
|
||||
// 导出为 CSV 字符串
|
||||
const csv = notificationHistoryService.exportToCSV();
|
||||
|
||||
// 直接下载 JSON 文件
|
||||
notificationHistoryService.downloadJSON();
|
||||
|
||||
// 直接下载 CSV 文件
|
||||
notificationHistoryService.downloadCSV();
|
||||
```
|
||||
|
||||
### 在控制台使用
|
||||
|
||||
打开浏览器控制台,执行:
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
||||
|
||||
// 查看所有历史
|
||||
console.table(notificationHistoryService.getHistory().records);
|
||||
|
||||
// 搜索
|
||||
const results = notificationHistoryService.searchHistory('央行');
|
||||
console.table(results);
|
||||
|
||||
// 查看统计
|
||||
console.table(notificationHistoryService.getStats());
|
||||
|
||||
// 导出并下载
|
||||
notificationHistoryService.downloadJSON();
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
每条历史记录包含:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 'notif_123', // 通知 ID
|
||||
notification: { // 完整通知对象
|
||||
type: 'event_alert',
|
||||
priority: 'urgent',
|
||||
title: '...',
|
||||
content: '...',
|
||||
...
|
||||
},
|
||||
receivedAt: 1737459600000, // 接收时间戳
|
||||
readAt: 1737459650000, // 已读时间戳(null 表示未读)
|
||||
clickedAt: null, // 已点击时间戳(null 表示未点击)
|
||||
}
|
||||
```
|
||||
|
||||
### 存储限制
|
||||
|
||||
- **最大数量**: 500 条(超过后自动删除最旧的)
|
||||
- **存储位置**: localStorage
|
||||
- **容量估算**: 约 2-5MB(取决于通知内容长度)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── browserNotificationService.js [已存在] 浏览器通知服务
|
||||
│ ├── notificationMetricsService.js [新建] 性能监控服务
|
||||
│ └── notificationHistoryService.js [新建] 历史记录服务
|
||||
├── contexts/
|
||||
│ └── NotificationContext.js [修改] 集成所有功能
|
||||
└── components/
|
||||
└── NotificationContainer/
|
||||
└── index.js [修改] 添加点击追踪
|
||||
```
|
||||
|
||||
### 修改清单
|
||||
|
||||
| 文件 | 修改内容 | 状态 |
|
||||
|------|---------|------|
|
||||
| `NotificationContext.js` | 添加智能权限请求、监控埋点、历史保存 | ✅ 已完成 |
|
||||
| `NotificationContainer/index.js` | 添加点击追踪 | ✅ 已完成 |
|
||||
| `notificationMetricsService.js` | 性能监控服务 | ✅ 已创建 |
|
||||
| `notificationHistoryService.js` | 历史记录服务 | ✅ 已创建 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户收到通知
|
||||
↓
|
||||
NotificationContext.addWebNotification()
|
||||
├─ notificationMetricsService.trackReceived() [监控埋点]
|
||||
├─ notificationHistoryService.saveNotification() [历史保存]
|
||||
├─ 首次重要通知 → requestBrowserPermission() [智能权限]
|
||||
└─ 显示网页通知或桌面通知
|
||||
|
||||
用户点击通知
|
||||
↓
|
||||
NotificationContainer.handleClick()
|
||||
├─ notificationMetricsService.trackClicked() [监控埋点]
|
||||
├─ notificationHistoryService.markAsClicked() [历史标记]
|
||||
└─ 跳转到目标页面
|
||||
|
||||
用户关闭通知
|
||||
↓
|
||||
NotificationContext.removeNotification()
|
||||
└─ notificationMetricsService.trackDismissed() [监控埋点]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试智能桌面通知
|
||||
|
||||
```bash
|
||||
# 1. 清除已保存的权限状态
|
||||
localStorage.removeItem('browser_notification_requested');
|
||||
|
||||
# 2. 刷新页面
|
||||
|
||||
# 3. 等待或触发一个重要/紧急通知
|
||||
|
||||
# 4. 观察浏览器弹出权限请求
|
||||
|
||||
# 5. 授权后验证桌面通知功能
|
||||
```
|
||||
|
||||
### 2. 测试性能监控
|
||||
|
||||
```javascript
|
||||
// 在控制台执行
|
||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
||||
|
||||
// 查看实时统计
|
||||
console.table(notificationMetricsService.getSummary());
|
||||
|
||||
// 模拟推送几条通知,再次查看
|
||||
console.table(notificationMetricsService.getAllMetrics());
|
||||
|
||||
// 导出数据
|
||||
console.log(notificationMetricsService.exportToJSON());
|
||||
```
|
||||
|
||||
### 3. 测试历史记录
|
||||
|
||||
```javascript
|
||||
// 在控制台执行
|
||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
||||
|
||||
// 查看历史
|
||||
console.table(notificationHistoryService.getHistory().records);
|
||||
|
||||
// 搜索
|
||||
console.table(notificationHistoryService.searchHistory('降准'));
|
||||
|
||||
// 查看统计
|
||||
console.table(notificationHistoryService.getStats());
|
||||
|
||||
// 导出
|
||||
notificationHistoryService.downloadJSON();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 数据导出示例
|
||||
|
||||
### 导出性能监控数据
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
|
||||
// 导出 JSON
|
||||
const json = notificationMetricsService.exportToJSON();
|
||||
// 复制到剪贴板或保存
|
||||
|
||||
// 导出 CSV
|
||||
const csv = notificationMetricsService.exportToCSV();
|
||||
// 可以在 Excel 中打开
|
||||
```
|
||||
|
||||
### 导出历史记录
|
||||
|
||||
```javascript
|
||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
||||
|
||||
// 导出最近 7 天的事件动向通知
|
||||
const json = notificationHistoryService.exportToJSON({
|
||||
type: 'event_alert',
|
||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// 直接下载为文件
|
||||
notificationHistoryService.downloadJSON({
|
||||
type: 'event_alert'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. localStorage 容量限制
|
||||
|
||||
- 大多数浏览器限制为 5-10MB
|
||||
- 建议定期清理历史记录和监控数据
|
||||
- 使用导出功能备份数据
|
||||
|
||||
### 2. 浏览器兼容性
|
||||
|
||||
- **桌面通知**: 需要 HTTPS 或 localhost
|
||||
- **localStorage**: 所有现代浏览器支持
|
||||
- **权限请求**: 需要用户交互(不能自动授权)
|
||||
|
||||
### 3. 隐私和数据安全
|
||||
|
||||
- 所有数据存储在本地(localStorage)
|
||||
- 不会上传到服务器
|
||||
- 用户可以随时清空数据
|
||||
|
||||
### 4. 性能影响
|
||||
|
||||
- 监控埋点非常轻量,几乎无性能影响
|
||||
- 历史记录保存异步进行,不阻塞 UI
|
||||
- 数据查询在客户端完成,不增加服务器负担
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 已实现的功能
|
||||
|
||||
✅ **智能桌面通知**
|
||||
- 首次重要通知时自动请求权限
|
||||
- 智能分发策略(前台/后台)
|
||||
- localStorage 持久化权限状态
|
||||
|
||||
✅ **性能监控**
|
||||
- 到达率、点击率、响应时间追踪
|
||||
- 按类型、优先级、时段统计
|
||||
- 数据导出(JSON/CSV)
|
||||
|
||||
✅ **历史记录**
|
||||
- 持久化存储(最多 500 条)
|
||||
- 筛选、搜索、分页
|
||||
- 已读/已点击标记
|
||||
- 数据导出(JSON/CSV)
|
||||
|
||||
### 未实现的功能(备份,待上线)
|
||||
|
||||
⏸️ 历史记录页面 UI(代码已备份,随时可上线)
|
||||
⏸️ 监控仪表板 UI(可选,暂未实现)
|
||||
|
||||
### 下一步建议
|
||||
|
||||
1. **用户设置页面**: 允许用户自定义通知偏好
|
||||
2. **声音提示**: 为紧急通知添加音效
|
||||
3. **数据同步**: 将历史和监控数据同步到服务器
|
||||
4. **高级筛选**: 添加更多筛选维度(如关键词、股票代码等)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2025-01-21
|
||||
**维护者**: Claude Code
|
||||
@@ -1,371 +0,0 @@
|
||||
# 消息推送系统整合 - 测试指南
|
||||
|
||||
## 📋 整合完成清单
|
||||
|
||||
✅ **统一事件名称**
|
||||
- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
|
||||
- 移除了 `trade_notification` 事件名
|
||||
|
||||
✅ **数据适配器**
|
||||
- 创建了 `adaptEventToNotification` 函数
|
||||
- 自动识别后端事件格式并转换为前端通知格式
|
||||
- 重要性映射:S → urgent, A → important, B/C → normal
|
||||
|
||||
✅ **NotificationContext 升级**
|
||||
- 监听 `new_event` 事件
|
||||
- 自动使用适配器转换事件数据
|
||||
- 支持 Mock 和 Real 模式无缝切换
|
||||
|
||||
✅ **EventList 实时推送**
|
||||
- 集成 `useEventNotifications` Hook
|
||||
- 实时更新事件列表
|
||||
- Toast 通知提示
|
||||
- WebSocket 连接状态指示器
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试 Mock 模式(开发环境)
|
||||
|
||||
#### 1.1 配置环境变量
|
||||
确保 `.env` 文件包含以下配置:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=true
|
||||
# 或者
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
```
|
||||
|
||||
#### 1.2 启动应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 1.3 验证功能
|
||||
|
||||
**a) 右下角通知卡片**
|
||||
- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
|
||||
- 每隔 60 秒会自动推送 1-2 条模拟消息
|
||||
- 通知类型包括:
|
||||
- 📢 公告通知(蓝色)
|
||||
- 📈 股票动向(红/绿色,根据涨跌)
|
||||
- 📰 事件动向(橙色)
|
||||
- 📊 分析报告(紫色)
|
||||
|
||||
**b) 事件列表页面**
|
||||
- 访问事件列表页面(Community/Events)
|
||||
- 顶部应显示 "🟢 实时推送已开启"
|
||||
- 收到新事件时:
|
||||
- 右上角显示 Toast 通知
|
||||
- 事件自动添加到列表顶部
|
||||
- 无重复添加
|
||||
|
||||
**c) 控制台日志**
|
||||
打开浏览器控制台,应该看到:
|
||||
```
|
||||
[Socket Service] Using MOCK Socket Service
|
||||
NotificationContext: Socket connected
|
||||
EventList: 收到新事件推送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 测试 Real 模式(生产环境)
|
||||
|
||||
#### 2.1 配置环境变量
|
||||
修改 `.env` 文件:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=false
|
||||
# 或删除该配置项
|
||||
```
|
||||
|
||||
#### 2.2 启动后端 Flask 服务
|
||||
```bash
|
||||
python app_2.py
|
||||
```
|
||||
|
||||
确保后端已启动 Socket.IO 服务并监听事件推送。
|
||||
|
||||
#### 2.3 启动前端应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 2.4 创建测试事件(后端)
|
||||
使用后端提供的测试脚本:
|
||||
```bash
|
||||
python test_create_event.py
|
||||
```
|
||||
|
||||
#### 2.5 验证功能
|
||||
|
||||
**a) WebSocket 连接**
|
||||
- 检查控制台:`[Socket Service] Using REAL Socket Service`
|
||||
- 事件列表顶部显示 "🟢 实时推送已开启"
|
||||
|
||||
**b) 事件推送流程**
|
||||
1. 运行 `test_create_event.py` 创建新事件
|
||||
2. 后端轮询检测到新事件(最多等待 30 秒)
|
||||
3. 后端通过 Socket.IO 推送 `new_event`
|
||||
4. 前端接收事件并转换格式
|
||||
5. 同时显示:
|
||||
- 右下角通知卡片
|
||||
- 事件列表 Toast 提示
|
||||
- 事件添加到列表顶部
|
||||
|
||||
**c) 数据格式验证**
|
||||
在控制台查看事件对象,应包含:
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
type: "event_alert", // 适配器转换后
|
||||
priority: "urgent", // importance: S → urgent
|
||||
title: "事件标题",
|
||||
content: "事件描述",
|
||||
clickable: true,
|
||||
link: "/event-detail/123",
|
||||
extra: {
|
||||
eventType: "tech",
|
||||
importance: "S",
|
||||
// ... 更多后端字段
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证清单
|
||||
|
||||
### 功能验证
|
||||
|
||||
- [ ] Mock 模式下收到模拟通知
|
||||
- [ ] Real 模式下收到真实后端推送
|
||||
- [ ] 通知卡片正确显示(类型、颜色、内容)
|
||||
- [ ] 事件列表实时更新
|
||||
- [ ] Toast 通知正常弹出
|
||||
- [ ] 连接状态指示器正确显示
|
||||
- [ ] 点击通知可跳转到详情页
|
||||
- [ ] 无重复事件添加
|
||||
|
||||
### 数据验证
|
||||
|
||||
- [ ] 后端事件格式正确转换
|
||||
- [ ] 重要性映射正确(S/A/B/C → urgent/important/normal)
|
||||
- [ ] 时间戳正确显示
|
||||
- [ ] 链接路径正确生成
|
||||
- [ ] 所有字段完整保留在 extra 中
|
||||
|
||||
### 性能验证
|
||||
|
||||
- [ ] 事件列表最多保留 100 条
|
||||
- [ ] 通知自动关闭(紧急=不关闭,重要=30s,普通=15s)
|
||||
- [ ] WebSocket 自动重连
|
||||
- [ ] 无内存泄漏
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题排查
|
||||
|
||||
### Q1: Mock 模式下没有收到通知?
|
||||
**A:** 检查:
|
||||
1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
|
||||
2. 控制台是否显示 "Using MOCK Socket Service"
|
||||
3. 是否等待了 3 秒(首次通知延迟)
|
||||
|
||||
### Q2: Real 模式下无法连接?
|
||||
**A:** 检查:
|
||||
1. Flask 后端是否启动:`python app_2.py`
|
||||
2. API_BASE_URL 是否正确配置
|
||||
3. CORS 设置是否包含前端域名
|
||||
4. 控制台是否有连接错误
|
||||
|
||||
### Q3: 收到重复通知?
|
||||
**A:** 检查:
|
||||
1. 是否多次渲染了 EventList 组件
|
||||
2. 是否在多个地方调用了 `useEventNotifications`
|
||||
3. 控制台日志中是否有 "事件已存在,跳过添加"
|
||||
|
||||
### Q4: 通知卡片样式异常?
|
||||
**A:** 检查:
|
||||
1. 事件的 `type` 字段是否正确
|
||||
2. 是否缺少必要的字段(title, content)
|
||||
3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
|
||||
|
||||
### Q5: 事件列表不更新?
|
||||
**A:** 检查:
|
||||
1. WebSocket 连接状态(顶部 Badge)
|
||||
2. `onNewEvent` 回调是否触发(控制台日志)
|
||||
3. `setLocalEvents` 是否正确执行
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试数据示例
|
||||
|
||||
### Mock 模拟数据类型
|
||||
|
||||
**公告通知**
|
||||
```javascript
|
||||
{
|
||||
type: "announcement",
|
||||
priority: "urgent",
|
||||
title: "贵州茅台发布2024年度财报公告",
|
||||
content: "2024年度营收同比增长15.2%..."
|
||||
}
|
||||
```
|
||||
|
||||
**股票动向**
|
||||
```javascript
|
||||
{
|
||||
type: "stock_alert",
|
||||
priority: "urgent",
|
||||
title: "您关注的股票触发预警",
|
||||
extra: {
|
||||
stockCode: "300750",
|
||||
priceChange: "+5.2%"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**事件动向**
|
||||
```javascript
|
||||
{
|
||||
type: "event_alert",
|
||||
priority: "important",
|
||||
title: "央行宣布降准0.5个百分点",
|
||||
extra: {
|
||||
eventId: "evt001",
|
||||
sectors: ["银行", "地产", "基建"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析报告**
|
||||
```javascript
|
||||
{
|
||||
type: "analysis_report",
|
||||
priority: "important",
|
||||
title: "医药行业深度报告:创新药迎来政策拐点",
|
||||
author: {
|
||||
name: "李明",
|
||||
organization: "中信证券"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 真实后端事件格式
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
title: "新能源汽车补贴政策延期",
|
||||
description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
|
||||
event_type: "policy",
|
||||
importance: "S",
|
||||
status: "active",
|
||||
created_at: "2025-01-21T14:30:00",
|
||||
hot_score: 95.5,
|
||||
view_count: 1234,
|
||||
related_avg_chg: 5.2,
|
||||
related_max_chg: 15.8,
|
||||
keywords: ["新能源", "补贴", "政策"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步建议
|
||||
|
||||
### 1. 用户设置
|
||||
允许用户控制通知偏好:
|
||||
```jsx
|
||||
<Switch
|
||||
isChecked={enableNotifications}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
启用实时通知
|
||||
</Switch>
|
||||
```
|
||||
|
||||
### 2. 通知过滤
|
||||
按重要性、类型过滤通知:
|
||||
```javascript
|
||||
useEventNotifications({
|
||||
eventType: 'tech', // 只订阅科技类
|
||||
importance: 'S', // 只订阅 S 级
|
||||
enabled: true
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 声音提示
|
||||
添加音效提醒:
|
||||
```javascript
|
||||
onNewEvent: (event) => {
|
||||
if (event.priority === 'urgent') {
|
||||
new Audio('/alert.mp3').play();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 桌面通知
|
||||
利用浏览器通知 API:
|
||||
```javascript
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(event.title, {
|
||||
body: event.content,
|
||||
icon: '/logo.png'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 技术说明
|
||||
|
||||
### 架构优势
|
||||
|
||||
1. **统一接口**:Mock 和 Real 完全相同的 API
|
||||
2. **自动适配**:智能识别数据格式并转换
|
||||
3. **解耦设计**:通知系统和事件列表独立工作
|
||||
4. **向后兼容**:不影响现有功能
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `src/services/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
后端创建事件
|
||||
↓
|
||||
后端轮询检测(30秒)
|
||||
↓
|
||||
Socket.IO 推送 new_event
|
||||
↓
|
||||
前端 socketService 接收
|
||||
↓
|
||||
NotificationContext 监听并适配
|
||||
↓
|
||||
同时触发:
|
||||
├─ NotificationContainer(右下角卡片)
|
||||
└─ EventList onNewEvent(Toast + 列表更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 整合完成
|
||||
|
||||
所有代码和功能已经就绪!你现在可以:
|
||||
|
||||
1. ✅ 在 Mock 模式下测试实时推送
|
||||
2. ✅ 在 Real 模式下连接后端
|
||||
3. ✅ 查看右下角通知卡片
|
||||
4. ✅ 体验事件列表实时更新
|
||||
5. ✅ 随时切换 Mock/Real 模式
|
||||
|
||||
**祝测试顺利!🎉**
|
||||
@@ -1,280 +0,0 @@
|
||||
# 消息推送系统优化总结
|
||||
|
||||
## 优化目标
|
||||
1. 简化通知信息密度,通过视觉层次(边框+背景色)表达优先级
|
||||
2. 增强紧急通知的视觉冲击力(红色脉冲边框动画)
|
||||
3. 采用智能显示策略,降低普通通知的视觉干扰
|
||||
|
||||
## 实施内容
|
||||
|
||||
### 1. 优先级配置更新 (src/constants/notificationTypes.js)
|
||||
|
||||
#### 新增配置项
|
||||
- `borderWidth`: 边框宽度
|
||||
- 紧急 (urgent): 6px
|
||||
- 重要 (important): 4px
|
||||
- 普通 (normal): 2px
|
||||
|
||||
- `bgOpacity`: 背景色透明度(亮色模式)
|
||||
- 紧急: 0.25 (深色背景)
|
||||
- 重要: 0.15 (中色背景)
|
||||
- 普通: 0.08 (浅色背景)
|
||||
|
||||
- `darkBgOpacity`: 背景色透明度(暗色模式)
|
||||
- 紧急: 0.30
|
||||
- 重要: 0.20
|
||||
- 普通: 0.12
|
||||
|
||||
#### 新增辅助函数
|
||||
- `getPriorityBgOpacity(priority, isDark)`: 获取优先级对应的背景色透明度
|
||||
- `getPriorityBorderWidth(priority)`: 获取优先级对应的边框宽度
|
||||
|
||||
### 2. 紧急通知脉冲动画 (src/components/NotificationContainer/index.js)
|
||||
|
||||
#### 动画效果
|
||||
- 使用 `@emotion/react` 的 `keyframes` 创建脉冲动画
|
||||
- 仅紧急通知 (urgent) 应用动画效果
|
||||
- 动画特性:
|
||||
- 边框颜色脉冲效果
|
||||
- 阴影扩散效果(0 → 12px)
|
||||
- 持续时间:2秒
|
||||
- 缓动函数:ease-in-out
|
||||
- 无限循环
|
||||
|
||||
```javascript
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### 3. 背景色优先级优化
|
||||
|
||||
#### 亮色模式
|
||||
- **紧急通知**:`${colorScheme}.200` - 深色背景 + 脉冲动画
|
||||
- **重要通知**:`${colorScheme}.100` - 中色背景
|
||||
- **普通通知**:`white` - 极淡背景(降低视觉干扰)
|
||||
|
||||
#### 暗色模式
|
||||
- **紧急通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
||||
- **重要通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
||||
- **普通通知**:`gray.800` - 暗灰背景(降低视觉干扰)
|
||||
|
||||
### 4. 可点击性视觉提示
|
||||
|
||||
#### 问题
|
||||
- 用户需要 hover 才能知道通知是否可点击
|
||||
- cursor: pointer 不够直观
|
||||
|
||||
#### 解决方案
|
||||
- **可点击的通知**:
|
||||
- 添加完整边框(四周 1px solid)
|
||||
- 保持左侧优先级边框宽度
|
||||
- 使用更明显的阴影(md 级别)
|
||||
- 产生微妙的悬浮感
|
||||
|
||||
- **不可点击的通知**:
|
||||
- 仅左侧边框
|
||||
- 使用较淡的阴影(sm 级别)
|
||||
|
||||
```javascript
|
||||
// 可点击的通知添加完整边框
|
||||
{...(isActuallyClickable && {
|
||||
border: '1px solid',
|
||||
borderLeftWidth: priorityBorderWidth, // 保持优先级
|
||||
})}
|
||||
|
||||
// 可点击的通知使用更明显的阴影
|
||||
boxShadow={isActuallyClickable
|
||||
? (isNewest ? '2xl' : 'md')
|
||||
: (isNewest ? 'xl' : 'sm')}
|
||||
```
|
||||
|
||||
### 5. 通知组件简化 (src/components/NotificationContainer/index.js)
|
||||
|
||||
#### 显示元素分级
|
||||
|
||||
**LV1 - 必需元素(始终显示)**
|
||||
- ✅ 标题 (title)
|
||||
- ✅ 内容 (content, 最多3行)
|
||||
- ✅ 时间 (publishTime/pushTime)
|
||||
- ✅ 查看详情 (仅当 clickable=true 时)
|
||||
- ✅ 关闭按钮
|
||||
|
||||
**LV2 - 可选元素(数据存在时显示)**
|
||||
- ✅ 图标:仅在紧急/重要通知时显示
|
||||
- ❌ 优先级标签:已移除,改用边框+背景色表示
|
||||
- ✅ 状态提示:仅当 `extra?.statusHint` 存在时显示
|
||||
|
||||
**LV3 - 可选元素(数据存在时显示)**
|
||||
- ✅ AI 标识:仅当 `isAIGenerated = true` 时显示
|
||||
- ✅ 预测标识:仅当 `isPrediction = true` 时显示
|
||||
|
||||
**其他**
|
||||
- ✅ 作者信息:移除屏幕尺寸限制,仅当 `author` 存在时显示
|
||||
|
||||
#### 优先级视觉样式
|
||||
- ✅ 边框宽度:根据优先级动态调整 (2px/4px/6px)
|
||||
- ✅ 背景色深度:根据优先级使用不同深度的颜色
|
||||
- 亮色模式: .50 (普通) / .100 (重要) / .200 (紧急)
|
||||
- 暗色模式: 使用 typeConfig 的 darkBg 配置
|
||||
|
||||
#### 布局优化
|
||||
- ✅ 内容和元数据区域的左侧填充根据图标显示状态自适应
|
||||
- ✅ 无图标时不添加额外的左侧间距
|
||||
|
||||
## 预期效果
|
||||
|
||||
### 视觉改进
|
||||
- **清晰度提升**:移除冗余的优先级标签,视觉更整洁
|
||||
- **优先级强化**:
|
||||
- 紧急通知:6px 粗边框 + 深色背景 + **红色脉冲动画** → 视觉冲击力极强
|
||||
- 重要通知:4px 中等边框 + 中色背景 + 图标 → 醒目但不打扰
|
||||
- 普通通知:2px 细边框 + 白色/极淡背景 → 低视觉干扰
|
||||
- **可点击性一目了然**:
|
||||
- 可点击:完整边框 + 明显阴影 → 卡片悬浮感
|
||||
- 不可点击:仅左侧边框 + 淡阴影 → 平面感
|
||||
- **信息密度降低**:减少不必要的视觉元素,关键信息更突出
|
||||
|
||||
### 用户体验
|
||||
- **紧急通知引起注意**:脉冲动画确保用户不会错过紧急信息
|
||||
- **快速识别优先级**:
|
||||
- 动画 = 紧急(需要立即关注)
|
||||
- 图标 + 粗边框 = 重要(需要关注)
|
||||
- 细边框 + 淡背景 = 普通(可稍后查看)
|
||||
- **可点击性无需 hover**:
|
||||
- 完整边框 + 悬浮感 = 可以点击查看详情
|
||||
- 仅左侧边框 = 信息已完整,无需跳转
|
||||
- **智能显示**:可选信息只在数据存在时显示,避免空白占位
|
||||
- **响应式优化**:所有设备上保持一致的显示逻辑
|
||||
|
||||
### 向后兼容
|
||||
- ✅ 完全兼容现有通知数据结构
|
||||
- ✅ 可选字段不存在时自动隐藏
|
||||
- ✅ 不影响现有功能(点击、关闭、自动消失等)
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 功能测试
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm start
|
||||
|
||||
# 观察不同优先级通知的显示效果
|
||||
# - 紧急通知:粗边框 (6px) + 深色背景 + 红色脉冲动画 + 图标 + 不自动关闭
|
||||
# - 重要通知:中等边框 (4px) + 中色背景 + 图标 + 30秒后关闭
|
||||
# - 普通通知:细边框 (2px) + 白色背景 + 无图标 + 15秒后关闭
|
||||
```
|
||||
|
||||
### 1.1 动画测试
|
||||
- [ ] 紧急通知的脉冲动画流畅无卡顿
|
||||
- [ ] 动画周期为 2 秒
|
||||
- [ ] 动画在紧急通知显示期间持续循环
|
||||
- [ ] 阴影扩散效果清晰可见
|
||||
|
||||
### 2. 边界测试
|
||||
- [ ] 仅必需字段的通知(无作者、无 AI 标识、无预测标识)
|
||||
- [ ] 包含所有可选字段的通知
|
||||
- [ ] 不同类型的通知(公告、股票、事件、分析报告)
|
||||
- [ ] 不同优先级的通知(紧急、重要、普通)
|
||||
|
||||
### 3. 响应式测试
|
||||
- [ ] 移动设备 (< 480px)
|
||||
- [ ] 平板设备 (480px - 768px)
|
||||
- [ ] 桌面设备 (> 768px)
|
||||
|
||||
### 4. 暗色模式测试
|
||||
- [ ] 切换到暗色模式,确认背景色对比度合适
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 关键代码变更
|
||||
|
||||
#### 1. 脉冲动画实现
|
||||
```javascript
|
||||
// 导入 keyframes
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
// 定义脉冲动画
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
// 应用到紧急通知
|
||||
<Box
|
||||
animation={priority === PRIORITY_LEVELS.URGENT
|
||||
? `${pulseAnimation} 2s ease-in-out infinite`
|
||||
: undefined}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. 优先级标签自动隐藏
|
||||
```javascript
|
||||
// PRIORITY_CONFIGS 中所有 show 属性设置为 false
|
||||
show: false, // 不再显示标签,改用边框+背景色表示
|
||||
```
|
||||
|
||||
#### 3. 背景色优先级优化
|
||||
```javascript
|
||||
const getPriorityBgColor = () => {
|
||||
const colorScheme = typeConfig.colorScheme;
|
||||
if (!isDark) {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return `${colorScheme}.200`; // 深色背景 + 脉冲动画
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return `${colorScheme}.100`; // 中色背景
|
||||
} else {
|
||||
return 'white'; // 极淡背景(降低视觉干扰)
|
||||
}
|
||||
} else {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else {
|
||||
return 'gray.800'; // 暗灰背景(降低视觉干扰)
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 图标条件显示
|
||||
```javascript
|
||||
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT ||
|
||||
priority === PRIORITY_LEVELS.IMPORTANT;
|
||||
|
||||
{shouldShowIcon && (
|
||||
<Icon as={typeConfig.icon} ... />
|
||||
)}
|
||||
};
|
||||
```
|
||||
|
||||
## 后续改进建议
|
||||
|
||||
### 短期
|
||||
- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
|
||||
- [ ] 提供配置选项让用户自定义显示元素
|
||||
|
||||
### 长期
|
||||
- [ ] 支持通知分组(按类型或优先级)
|
||||
- [ ] 添加通知搜索和筛选功能
|
||||
- [ ] 通知历史记录可视化统计
|
||||
|
||||
## 构建状态
|
||||
✅ 构建成功 (npm run build)
|
||||
✅ 无语法错误
|
||||
✅ 无 TypeScript 错误
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,338 +0,0 @@
|
||||
# 崩溃修复测试指南
|
||||
|
||||
> 测试时间:2025-10-14
|
||||
> 测试范围:SignInIllustration.js + SignUpIllustration.js
|
||||
> 服务器地址:http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证以下修复是否有效:
|
||||
- ✅ 响应对象崩溃(6处)
|
||||
- ✅ 组件卸载后 setState(6处)
|
||||
- ✅ 定时器内存泄漏(2处)
|
||||
|
||||
---
|
||||
|
||||
## 📋 测试清单
|
||||
|
||||
### ✅ 关键测试(必做)
|
||||
|
||||
#### 1. **网络异常测试** - 验证响应对象修复
|
||||
|
||||
**登录页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-in
|
||||
2. 切换到"验证码登录"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 打开浏览器开发者工具 (F12) → Network 标签
|
||||
5. 点击 Offline 模拟断网
|
||||
6. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送验证码失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
|
||||
修复前:
|
||||
❌ 页面白屏崩溃
|
||||
❌ Console 报错:Cannot read property 'json' of null
|
||||
```
|
||||
|
||||
**登录页面 - 微信登录**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面,保持断网状态
|
||||
2. 点击"扫码登录"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"获取微信授权失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
```
|
||||
|
||||
**注册页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-up
|
||||
2. 切换到"验证码注册"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 保持断网状态
|
||||
5. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送失败 - 网络请求失败..."
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. **组件卸载测试** - 验证内存泄漏修复
|
||||
|
||||
**倒计时中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 恢复网络连接
|
||||
2. 在登录页面输入手机号并发送验证码
|
||||
3. 等待倒计时开始(60秒倒计时)
|
||||
4. 立即点击浏览器后退按钮或切换到其他页面
|
||||
5. 打开 Console 查看是否有警告
|
||||
|
||||
预期结果:
|
||||
✅ 无警告:"Can't perform a React state update on an unmounted component"
|
||||
✅ 倒计时定时器正确清理
|
||||
✅ 无内存泄漏
|
||||
|
||||
修复前:
|
||||
❌ Console 警告:Memory leak warning
|
||||
❌ setState 在组件卸载后仍被调用
|
||||
```
|
||||
|
||||
**请求进行中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在注册页面填写完整信息
|
||||
2. 点击"注册"按钮
|
||||
3. 在请求响应前(loading 状态)快速刷新页面或关闭标签页
|
||||
4. 打开新标签页查看 Console
|
||||
|
||||
预期结果:
|
||||
✅ 无崩溃
|
||||
✅ 无警告信息
|
||||
✅ 请求被正确取消或忽略
|
||||
```
|
||||
|
||||
**注册成功跳转前离开**
|
||||
```
|
||||
测试步骤:
|
||||
1. 完成注册提交
|
||||
2. 在显示"注册成功"提示后
|
||||
3. 立即关闭标签页(不等待2秒自动跳转)
|
||||
|
||||
预期结果:
|
||||
✅ 无警告
|
||||
✅ navigate 不会在组件卸载后执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. **边界情况测试** - 验证数据完整性检查
|
||||
|
||||
**后端返回空响应**
|
||||
```
|
||||
测试步骤(需要模拟后端):
|
||||
1. 使用 Chrome DevTools → Network → 右键请求 → Edit and Resend
|
||||
2. 修改响应为空对象 {}
|
||||
3. 观察页面反应
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"服务器响应为空"
|
||||
✅ 不会尝试访问 undefined 属性
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
**后端返回 500 错误**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面点击"扫码登录"
|
||||
2. 如果后端返回 500 错误
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"获取二维码失败:HTTP 500"
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🧪 进阶测试(推荐)
|
||||
|
||||
#### 4. **弱网环境测试**
|
||||
|
||||
**慢速网络模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. Chrome DevTools → Network → Throttling → Slow 3G
|
||||
2. 尝试发送验证码
|
||||
3. 等待 10 秒(超时时间)
|
||||
|
||||
预期结果:
|
||||
✅ 10秒后显示超时错误
|
||||
✅ 不会无限等待
|
||||
✅ 用户可以重试
|
||||
```
|
||||
|
||||
**丢包模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. 使用 Chrome DevTools 模拟丢包
|
||||
2. 连续点击"发送验证码"多次
|
||||
|
||||
预期结果:
|
||||
✅ 每次请求都有适当的错误提示
|
||||
✅ 不会因为并发请求而崩溃
|
||||
✅ 按钮在请求期间正确禁用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. **定时器清理测试**
|
||||
|
||||
**倒计时清理验证**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面发送验证码
|
||||
2. 等待倒计时到 50 秒
|
||||
3. 快速切换到注册页面
|
||||
4. 再切换回登录页面
|
||||
5. 观察倒计时是否重置
|
||||
|
||||
预期结果:
|
||||
✅ 定时器在页面切换时正确清理
|
||||
✅ 返回登录页面时倒计时重新开始(如果再次发送)
|
||||
✅ 没有多个定时器同时运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. **并发请求测试**
|
||||
|
||||
**快速连续点击**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面输入手机号
|
||||
2. 快速连续点击"发送验证码"按钮 5 次
|
||||
|
||||
预期结果:
|
||||
✅ 只发送一次请求(按钮在请求期间禁用)
|
||||
✅ 不会因为并发而崩溃
|
||||
✅ 正确显示 loading 状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 监控指标
|
||||
|
||||
### Console 检查清单
|
||||
|
||||
在测试过程中,打开 Console (F12) 监控以下内容:
|
||||
|
||||
```
|
||||
✅ 无红色错误(Error)
|
||||
✅ 无内存泄漏警告(Memory leak warning)
|
||||
✅ 无 setState 警告(Can't perform a React state update...)
|
||||
✅ 无 undefined 访问错误(Cannot read property of undefined)
|
||||
```
|
||||
|
||||
### Network 检查清单
|
||||
|
||||
打开 Network 标签监控:
|
||||
|
||||
```
|
||||
✅ 请求超时时间:10秒
|
||||
✅ 失败请求有正确的错误处理
|
||||
✅ 没有重复的请求
|
||||
✅ 请求被正确取消(如果页面卸载)
|
||||
```
|
||||
|
||||
### Performance 检查清单
|
||||
|
||||
打开 Performance 标签(可选):
|
||||
|
||||
```
|
||||
✅ 无内存泄漏(Memory 不会持续增长)
|
||||
✅ 定时器正确清理(Timer count 正确)
|
||||
✅ EventListener 正确清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试记录表
|
||||
|
||||
请在测试时填写以下表格:
|
||||
|
||||
| 测试项 | 状态 | 问题描述 | 截图 |
|
||||
|--------|------|---------|------|
|
||||
| 登录页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 登录页 - 断网微信登录 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 倒计时中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 请求进行中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册成功跳转前离开 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 后端返回空响应 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 慢速网络超时 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 定时器清理 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 并发请求 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 如何报告问题
|
||||
|
||||
如果发现问题,请提供:
|
||||
|
||||
1. **测试场景**:具体的测试步骤
|
||||
2. **预期结果**:应该发生什么
|
||||
3. **实际结果**:实际发生了什么
|
||||
4. **Console 错误**:完整的错误信息
|
||||
5. **截图/录屏**:问题的视觉证明
|
||||
6. **环境信息**:
|
||||
- 浏览器版本
|
||||
- 操作系统
|
||||
- 网络状态
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试完成检查
|
||||
|
||||
测试完成后,确认以下内容:
|
||||
|
||||
```
|
||||
□ 所有关键测试通过
|
||||
□ Console 无错误
|
||||
□ Network 请求正常
|
||||
□ 无内存泄漏警告
|
||||
□ 用户体验流畅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速测试命令
|
||||
|
||||
```bash
|
||||
# 1. 确认服务器运行
|
||||
curl http://localhost:3000
|
||||
|
||||
# 2. 打开浏览器测试
|
||||
open http://localhost:3000/auth/sign-in
|
||||
|
||||
# 3. 查看编译日志
|
||||
tail -f /tmp/react-build.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 测试页面链接
|
||||
|
||||
- **登录页面**: http://localhost:3000/auth/sign-in
|
||||
- **注册页面**: http://localhost:3000/auth/sign-up
|
||||
- **首页**: http://localhost:3000/home
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发者工具快捷键
|
||||
|
||||
```
|
||||
F12 - 打开开发者工具
|
||||
Ctrl/Cmd+R - 刷新页面
|
||||
Ctrl/Cmd+Shift+R - 强制刷新(清除缓存)
|
||||
Ctrl/Cmd+Shift+C - 元素选择器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**测试时间**:2025-10-14
|
||||
**预计测试时长**:15-30 分钟
|
||||
**建议测试人员**:开发者 + QA
|
||||
|
||||
祝测试顺利!如发现问题请及时反馈。
|
||||
File diff suppressed because it is too large
Load Diff
145
init-forum-es.js
Normal file
145
init-forum-es.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 初始化价值论坛 Elasticsearch 索引
|
||||
* 运行方式:node init-forum-es.js
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// Elasticsearch 配置
|
||||
const ES_BASE_URL = 'http://222.128.1.157:19200';
|
||||
|
||||
// 创建 axios 实例
|
||||
const esClient = axios.create({
|
||||
baseURL: ES_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 索引名称
|
||||
const INDICES = {
|
||||
POSTS: 'forum_posts',
|
||||
COMMENTS: 'forum_comments',
|
||||
EVENTS: 'forum_events',
|
||||
};
|
||||
|
||||
async function initializeIndices() {
|
||||
try {
|
||||
console.log('开始初始化 Elasticsearch 索引...\n');
|
||||
|
||||
// 1. 创建帖子索引
|
||||
console.log('创建帖子索引 (forum_posts)...');
|
||||
try {
|
||||
await esClient.put(`/${INDICES.POSTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
title: { type: 'text' },
|
||||
content: { type: 'text' },
|
||||
images: { type: 'keyword' },
|
||||
tags: { type: 'keyword' },
|
||||
category: { type: 'keyword' },
|
||||
likes_count: { type: 'integer' },
|
||||
comments_count: { type: 'integer' },
|
||||
views_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
updated_at: { type: 'date' },
|
||||
is_pinned: { type: 'boolean' },
|
||||
status: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ 帖子索引创建成功\n');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('⚠️ 帖子索引已存在,跳过创建\n');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建评论索引
|
||||
console.log('创建评论索引 (forum_comments)...');
|
||||
try {
|
||||
await esClient.put(`/${INDICES.COMMENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
content: { type: 'text' },
|
||||
parent_id: { type: 'keyword' },
|
||||
likes_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
status: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ 评论索引创建成功\n');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('⚠️ 评论索引已存在,跳过创建\n');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建事件时间轴索引
|
||||
console.log('创建事件时间轴索引 (forum_events)...');
|
||||
try {
|
||||
await esClient.put(`/${INDICES.EVENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
event_type: { type: 'keyword' },
|
||||
title: { type: 'text' },
|
||||
description: { type: 'text' },
|
||||
source: { type: 'keyword' },
|
||||
source_url: { type: 'keyword' },
|
||||
related_stocks: { type: 'keyword' },
|
||||
occurred_at: { type: 'date' },
|
||||
created_at: { type: 'date' },
|
||||
importance: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ 事件时间轴索引创建成功\n');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('⚠️ 事件时间轴索引已存在,跳过创建\n');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 验证索引
|
||||
console.log('验证索引...');
|
||||
const indices = await esClient.get('/_cat/indices/forum_*?v&format=json');
|
||||
console.log('已创建的论坛索引:');
|
||||
indices.data.forEach(index => {
|
||||
console.log(` - ${index.index} (docs: ${index['docs.count']}, size: ${index['store.size']})`);
|
||||
});
|
||||
|
||||
console.log('\n🎉 所有索引初始化完成!');
|
||||
console.log('\n下一步:');
|
||||
console.log('1. 访问 https://valuefrontier.cn/value-forum');
|
||||
console.log('2. 点击"发布帖子"按钮创建第一篇帖子');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应数据:', JSON.stringify(error.response.data, null, 2));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initializeIndices();
|
||||
@@ -29,6 +29,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.23.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"draft-js": "^0.11.7",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -39,7 +40,6 @@
|
||||
"history": "^5.3.0",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"react": "18.3.1",
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
96
scripts/init-forum-indices.sh
Normal file
96
scripts/init-forum-indices.sh
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# 初始化价值论坛 Elasticsearch 索引
|
||||
# 使用 Nginx 代理或直连 ES
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 开始初始化价值论坛 Elasticsearch 索引..."
|
||||
echo ""
|
||||
|
||||
# ES 地址(根据环境选择)
|
||||
if [ -n "$USE_PROXY" ]; then
|
||||
ES_URL="https://valuefrontier.cn/es-api"
|
||||
echo "📡 使用 Nginx 代理: $ES_URL"
|
||||
else
|
||||
ES_URL="http://222.128.1.157:19200"
|
||||
echo "📡 直连 Elasticsearch: $ES_URL"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 1. 创建帖子索引
|
||||
echo "📝 创建帖子索引 (forum_posts)..."
|
||||
curl -X PUT "$ES_URL/forum_posts" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"author_id": { "type": "keyword" },
|
||||
"author_name": { "type": "text" },
|
||||
"author_avatar": { "type": "keyword" },
|
||||
"title": { "type": "text" },
|
||||
"content": { "type": "text" },
|
||||
"images": { "type": "keyword" },
|
||||
"tags": { "type": "keyword" },
|
||||
"category": { "type": "keyword" },
|
||||
"likes_count": { "type": "integer" },
|
||||
"comments_count": { "type": "integer" },
|
||||
"views_count": { "type": "integer" },
|
||||
"created_at": { "type": "date" },
|
||||
"updated_at": { "type": "date" },
|
||||
"is_pinned": { "type": "boolean" },
|
||||
"status": { "type": "keyword" }
|
||||
}
|
||||
}
|
||||
}' 2>/dev/null && echo "✅ 帖子索引创建成功" || echo "⚠️ 帖子索引已存在或创建失败"
|
||||
echo ""
|
||||
|
||||
# 2. 创建评论索引
|
||||
echo "💬 创建评论索引 (forum_comments)..."
|
||||
curl -X PUT "$ES_URL/forum_comments" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"post_id": { "type": "keyword" },
|
||||
"author_id": { "type": "keyword" },
|
||||
"author_name": { "type": "text" },
|
||||
"author_avatar": { "type": "keyword" },
|
||||
"content": { "type": "text" },
|
||||
"parent_id": { "type": "keyword" },
|
||||
"likes_count": { "type": "integer" },
|
||||
"created_at": { "type": "date" },
|
||||
"status": { "type": "keyword" }
|
||||
}
|
||||
}
|
||||
}' 2>/dev/null && echo "✅ 评论索引创建成功" || echo "⚠️ 评论索引已存在或创建失败"
|
||||
echo ""
|
||||
|
||||
# 3. 创建事件时间轴索引
|
||||
echo "⏰ 创建事件时间轴索引 (forum_events)..."
|
||||
curl -X PUT "$ES_URL/forum_events" -H 'Content-Type: application/json' -d '{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"id": { "type": "keyword" },
|
||||
"post_id": { "type": "keyword" },
|
||||
"event_type": { "type": "keyword" },
|
||||
"title": { "type": "text" },
|
||||
"description": { "type": "text" },
|
||||
"source": { "type": "keyword" },
|
||||
"source_url": { "type": "keyword" },
|
||||
"related_stocks": { "type": "keyword" },
|
||||
"occurred_at": { "type": "date" },
|
||||
"created_at": { "type": "date" },
|
||||
"importance": { "type": "keyword" }
|
||||
}
|
||||
}
|
||||
}' 2>/dev/null && echo "✅ 事件时间轴索引创建成功" || echo "⚠️ 事件时间轴索引已存在或创建失败"
|
||||
echo ""
|
||||
|
||||
# 4. 验证索引
|
||||
echo "🔍 验证已创建的索引..."
|
||||
curl -X GET "$ES_URL/_cat/indices/forum_*?v" 2>/dev/null
|
||||
echo ""
|
||||
|
||||
echo "🎉 初始化完成!"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 访问 https://valuefrontier.cn/value-forum"
|
||||
echo " 2. 点击"发布帖子"按钮创建第一篇帖子"
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const now = dayjs();
|
||||
const time = dayjs(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
@@ -9,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
|
||||
|
||||
@@ -264,15 +264,20 @@ const MobileDrawer = memo(({
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => handleNavigate('/value-forum')}
|
||||
py={1}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{}}
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
pointerEvents="none"
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">价值论坛</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="xs" colorScheme="yellow">黑金</Badge>
|
||||
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
py={1}
|
||||
|
||||
@@ -239,11 +239,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
onClick={() => {
|
||||
navEvents.trackMenuItemClicked('价值论坛', 'dropdown', '/value-forum');
|
||||
navigate('/value-forum');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/value-forum') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/value-forum') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">价值论坛</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="yellow">黑金</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
|
||||
@@ -155,8 +155,21 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/value-forum');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">价值论坛</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="yellow">黑金</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
// src/components/NotificationTestTool/index.js
|
||||
/**
|
||||
* 金融资讯通知测试工具 - 仅在开发环境显示
|
||||
* 用于手动测试4种通知类型
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
Divider,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Code,
|
||||
UnorderedList,
|
||||
ListItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
|
||||
|
||||
const NotificationTestTool = () => {
|
||||
// 只在开发环境显示 - 必须在所有 Hooks 调用之前检查
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
|
||||
const [testCount, setTestCount] = useState(0);
|
||||
|
||||
// 测试状态
|
||||
const [isTestingNotification, setIsTestingNotification] = useState(false);
|
||||
const [testCountdown, setTestCountdown] = useState(0);
|
||||
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
|
||||
|
||||
// 系统环境检测
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
|
||||
// 故障排查面板
|
||||
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
|
||||
|
||||
// 检测系统环境
|
||||
useEffect(() => {
|
||||
// 检测是否为 macOS
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
setIsMacOS(platform.includes('mac'));
|
||||
|
||||
// 检测全屏状态
|
||||
const checkFullscreen = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', checkFullscreen);
|
||||
checkFullscreen();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', checkFullscreen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
if (testCountdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setTestCountdown(testCountdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (testCountdown === 0 && isTestingNotification) {
|
||||
// 倒计时结束,询问用户
|
||||
setIsTestingNotification(false);
|
||||
|
||||
// 延迟一下再询问,确保用户有时间看到通知
|
||||
setTimeout(() => {
|
||||
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
|
||||
setNotificationShown(sawNotification);
|
||||
|
||||
if (!sawNotification) {
|
||||
// 没看到通知,展开故障排查面板
|
||||
if (!isTroubleshootOpen) {
|
||||
onTroubleshootToggle();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
|
||||
|
||||
// 浏览器权限状态标签
|
||||
const getPermissionLabel = () => {
|
||||
switch (browserPermission) {
|
||||
case 'granted':
|
||||
return '已授权';
|
||||
case 'denied':
|
||||
return '已拒绝';
|
||||
case 'default':
|
||||
return '未授权';
|
||||
default:
|
||||
return '不支持';
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionColor = () => {
|
||||
switch (browserPermission) {
|
||||
case 'granted':
|
||||
return 'green';
|
||||
case 'denied':
|
||||
return 'red';
|
||||
case 'default':
|
||||
return 'gray';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
// 请求浏览器权限
|
||||
const handleRequestPermission = async () => {
|
||||
await requestBrowserPermission();
|
||||
};
|
||||
|
||||
// 公告通知测试数据
|
||||
const testAnnouncement = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】贵州茅台发布2024年度财报公告',
|
||||
content: '2024年度营收同比增长15.2%,净利润创历史新高,董事会建议每10股派息180元',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/test001',
|
||||
extra: {
|
||||
announcementType: '财报',
|
||||
companyCode: '600519',
|
||||
companyName: '贵州茅台',
|
||||
},
|
||||
autoClose: 10000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
|
||||
// 事件动向测试数据
|
||||
const testEventAlert = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】央行宣布降准0.5个百分点',
|
||||
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点,释放长期资金约1万亿元,利好股市',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/test003',
|
||||
extra: {
|
||||
eventId: 'test003',
|
||||
relatedStocks: 12,
|
||||
impactLevel: '重大利好',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 分析报告测试数据(非AI)
|
||||
const testAnalysisReport = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】医药行业深度报告:创新药迎来政策拐点',
|
||||
content: 'CXO板块持续受益于全球创新药研发外包需求,建议关注药明康德、凯莱英等龙头企业',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '李明',
|
||||
organization: '中信证券',
|
||||
},
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=test004',
|
||||
extra: {
|
||||
reportType: '行业研报',
|
||||
industry: '医药',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
|
||||
// 预测通知测试数据(不可跳转)
|
||||
const testPrediction = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【测试】【预测】央行可能宣布降准政策',
|
||||
content: '基于最新宏观数据分析,预计央行将在本周宣布降准0.5个百分点,释放长期资金',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false, // ❌ 不可点击
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
},
|
||||
autoClose: 15000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 预测→详情流程测试(先推预测,5秒后推详情)
|
||||
const testPredictionFlow = () => {
|
||||
// 阶段 1: 推送预测
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【测试】【预测】新能源汽车补贴政策将延期',
|
||||
content: '根据政策趋势分析,预计财政部将宣布新能源汽车购置补贴政策延长至2025年底',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false,
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
relatedPredictionId: 'pred_test_001',
|
||||
},
|
||||
autoClose: 15000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
|
||||
// 阶段 2: 5秒后推送详情
|
||||
setTimeout(() => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】新能源汽车补贴政策延期至2025年底',
|
||||
content: '财政部宣布新能源汽车购置补贴政策延长至2025年底,涉及比亚迪、理想汽车等5家龙头企业',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true, // ✅ 可点击
|
||||
link: '/event-detail/test_pred_001',
|
||||
extra: {
|
||||
isPrediction: false,
|
||||
relatedPredictionId: 'pred_test_001',
|
||||
eventId: 'test_pred_001',
|
||||
relatedStocks: 5,
|
||||
impactLevel: '重大利好',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="116px"
|
||||
right={4}
|
||||
zIndex={9998}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 折叠按钮 */}
|
||||
<HStack
|
||||
p={2}
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
spacing={2}
|
||||
>
|
||||
<MdNotifications size={20} />
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
金融资讯测试工具
|
||||
</Text>
|
||||
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
REAL
|
||||
</Badge>
|
||||
<Badge colorScheme={getPermissionColor()}>
|
||||
浏览器: {getPermissionLabel()}
|
||||
</Badge>
|
||||
<IconButton
|
||||
icon={isOpen ? <MdClose /> : <MdNotifications />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
aria-label={isOpen ? '关闭' : '打开'}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 工具面板 */}
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<VStack p={4} spacing={3} align="stretch" minW="280px">
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="bold">
|
||||
通知类型测试
|
||||
</Text>
|
||||
|
||||
{/* 公告通知 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<MdCampaign />}
|
||||
onClick={testAnnouncement}
|
||||
>
|
||||
公告通知
|
||||
</Button>
|
||||
|
||||
{/* 事件动向 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="orange"
|
||||
leftIcon={<MdArticle />}
|
||||
onClick={testEventAlert}
|
||||
>
|
||||
事件动向
|
||||
</Button>
|
||||
|
||||
{/* 分析报告 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<MdAssessment />}
|
||||
onClick={testAnalysisReport}
|
||||
>
|
||||
分析报告
|
||||
</Button>
|
||||
|
||||
{/* 预测通知 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="gray"
|
||||
leftIcon={<MdArticle />}
|
||||
onClick={testPrediction}
|
||||
>
|
||||
预测通知(不可跳转)
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="bold">
|
||||
组合测试
|
||||
</Text>
|
||||
|
||||
{/* 预测→详情流程测试 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="cyan"
|
||||
onClick={testPredictionFlow}
|
||||
>
|
||||
预测→详情流程(5秒延迟)
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="bold">
|
||||
浏览器通知
|
||||
</Text>
|
||||
|
||||
{/* 请求权限按钮 */}
|
||||
{browserPermission !== 'granted' && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme={browserPermission === 'denied' ? 'red' : 'blue'}
|
||||
onClick={handleRequestPermission}
|
||||
isDisabled={browserPermission === 'denied'}
|
||||
>
|
||||
{browserPermission === 'denied' ? '权限已拒绝' : '请求浏览器权限'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 测试浏览器通知按钮 */}
|
||||
{browserPermission === 'granted' && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<MdNotifications />}
|
||||
onClick={() => {
|
||||
console.log('测试浏览器通知按钮被点击');
|
||||
console.log('Notification support:', 'Notification' in window);
|
||||
console.log('Notification permission:', Notification?.permission);
|
||||
console.log('Platform:', navigator.platform);
|
||||
console.log('Fullscreen:', !!document.fullscreenElement);
|
||||
|
||||
// 直接使用原生 Notification API 测试
|
||||
if (!('Notification' in window)) {
|
||||
alert('您的浏览器不支持桌面通知');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
alert('浏览器通知权限未授予\n当前权限状态:' + Notification.permission);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setNotificationShown(null);
|
||||
setIsTestingNotification(true);
|
||||
setTestCountdown(8); // 8秒倒计时
|
||||
|
||||
try {
|
||||
console.log('正在创建浏览器通知...');
|
||||
const notification = new Notification('【测试】浏览器通知测试', {
|
||||
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
|
||||
icon: '/logo192.png',
|
||||
badge: '/badge.png',
|
||||
tag: 'test_notification_' + Date.now(),
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
console.log('浏览器通知创建成功:', notification);
|
||||
|
||||
// 监听通知显示(成功显示)
|
||||
notification.onshow = () => {
|
||||
console.log('✅ 浏览器通知已显示(onshow 事件触发)');
|
||||
setNotificationShown(true);
|
||||
};
|
||||
|
||||
// 监听通知错误
|
||||
notification.onerror = (error) => {
|
||||
console.error('❌ 浏览器通知错误:', error);
|
||||
setNotificationShown(false);
|
||||
};
|
||||
|
||||
// 监听通知关闭
|
||||
notification.onclose = () => {
|
||||
console.log('浏览器通知已关闭');
|
||||
};
|
||||
|
||||
// 8秒后自动关闭
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
console.log('浏览器通知已自动关闭');
|
||||
}, 8000);
|
||||
|
||||
// 点击通知时聚焦窗口
|
||||
notification.onclick = () => {
|
||||
console.log('浏览器通知被点击');
|
||||
window.focus();
|
||||
notification.close();
|
||||
setNotificationShown(true);
|
||||
};
|
||||
|
||||
setTestCount(prev => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('创建浏览器通知失败:', error);
|
||||
alert('创建浏览器通知失败:' + error.message);
|
||||
setIsTestingNotification(false);
|
||||
setNotificationShown(false);
|
||||
}
|
||||
}}
|
||||
isLoading={isTestingNotification}
|
||||
loadingText={`等待通知... ${testCountdown}s`}
|
||||
>
|
||||
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 浏览器通知状态说明 */}
|
||||
{browserPermission === 'granted' && (
|
||||
<Text fontSize="xs" color="green.500">
|
||||
✅ 浏览器通知已启用
|
||||
</Text>
|
||||
)}
|
||||
{browserPermission === 'denied' && (
|
||||
<Text fontSize="xs" color="red.500">
|
||||
❌ 请在浏览器设置中允许通知
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 实时权限状态 */}
|
||||
<HStack spacing={2} justify="center">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
实际权限:
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
('Notification' in window && Notification.permission === 'granted') ? 'green' :
|
||||
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
|
||||
}
|
||||
>
|
||||
{('Notification' in window) ? Notification.permission : '不支持'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 环境警告 */}
|
||||
{isFullscreen && (
|
||||
<Alert status="warning" size="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<Text fontWeight="bold">全屏模式</Text>
|
||||
<Text>某些浏览器在全屏模式下不显示通知</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isMacOS && notificationShown === false && (
|
||||
<Alert status="error" size="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<Text fontWeight="bold">未检测到通知显示</Text>
|
||||
<Text>可能是专注模式阻止了通知</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 故障排查面板 */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="orange"
|
||||
leftIcon={<MdWarning />}
|
||||
onClick={onTroubleshootToggle}
|
||||
>
|
||||
{isTroubleshootOpen ? '收起' : '故障排查指南'}
|
||||
</Button>
|
||||
|
||||
<Collapse in={isTroubleshootOpen} animateOpacity>
|
||||
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
|
||||
<Text fontSize="xs" fontWeight="bold" color="orange.800">
|
||||
如果看不到浏览器通知,请检查:
|
||||
</Text>
|
||||
|
||||
{/* macOS 专注模式 */}
|
||||
{isMacOS && (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>点击右上角控制中心</ListItem>
|
||||
<ListItem>关闭「专注模式」或「勿扰模式」</ListItem>
|
||||
<ListItem>或者:系统设置 → 专注模式 → 关闭</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* macOS 系统通知设置 */}
|
||||
{isMacOS && (
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>系统设置 → 通知</ListItem>
|
||||
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> 或 <Code fontSize="xs">Microsoft Edge</Code></ListItem>
|
||||
<ListItem>确保「允许通知」已开启</ListItem>
|
||||
<ListItem>通知样式设置为「横幅」或「提醒」</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Chrome 浏览器设置 */}
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
|
||||
<ListItem>确保「网站可以请求发送通知」已开启</ListItem>
|
||||
<ListItem>检查本站点是否在「允许」列表中</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{/* 全屏模式提示 */}
|
||||
{isFullscreen && (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
|
||||
<AlertDescription>
|
||||
按 <Code fontSize="xs">ESC</Code> 键退出全屏,然后重新测试
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 测试结果反馈 */}
|
||||
{notificationShown === true && (
|
||||
<Alert status="success" size="sm">
|
||||
<AlertIcon />
|
||||
<Text fontSize="xs">✅ 通知功能正常!</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 功能按钮 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
onClick={clearAllNotifications}
|
||||
flex={1}
|
||||
>
|
||||
清空全部
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
|
||||
colorScheme={soundEnabled ? 'blue' : 'gray'}
|
||||
onClick={toggleSound}
|
||||
aria-label="切换音效"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<VStack spacing={1}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
当前队列:
|
||||
</Text>
|
||||
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
|
||||
{notifications.length} / 5
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center">
|
||||
已测试: {testCount} 条通知
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTestTool;
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
|
||||
// 计算事件标记线位置
|
||||
let markLineData = [];
|
||||
if (eventTime && times.length > 0) {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
if (activeChartType === 'timeline') {
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
@@ -50,7 +50,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -111,7 +111,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -182,7 +182,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && times.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
const eventTime = eventMoment.format('HH:mm');
|
||||
|
||||
@@ -357,7 +357,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置(重要修复)
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && dates.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
// 找到事件发生日期或最接近的交易日
|
||||
|
||||
204
src/constants/tracking.js
Normal file
204
src/constants/tracking.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/constants/tracking.js
|
||||
// PostHog 事件追踪优先级配置
|
||||
|
||||
/**
|
||||
* 事件优先级枚举
|
||||
*
|
||||
* 用于决定事件的追踪时机,优化性能和用户体验。
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const EVENT_PRIORITY = {
|
||||
/**
|
||||
* 关键事件 - 立即发送,不可延迟
|
||||
* 示例:登录、注册、支付、订阅购买
|
||||
*/
|
||||
CRITICAL: 'critical',
|
||||
|
||||
/**
|
||||
* 高优先级事件 - 立即发送
|
||||
* 示例:详情打开、搜索提交、关注操作、分享操作
|
||||
*/
|
||||
HIGH: 'high',
|
||||
|
||||
/**
|
||||
* 普通优先级事件 - 空闲时发送
|
||||
* 示例:列表查看、筛选应用、排序变更
|
||||
*/
|
||||
NORMAL: 'normal',
|
||||
|
||||
/**
|
||||
* 低优先级事件 - 空闲时发送,可批量合并
|
||||
* 示例:鼠标移动、滚动事件、hover 事件
|
||||
*/
|
||||
LOW: 'low',
|
||||
};
|
||||
|
||||
/**
|
||||
* Community 页面(新闻催化分析)事件优先级映射
|
||||
*
|
||||
* 映射规则:
|
||||
* - CRITICAL: 无(Community 页面无关键业务操作)
|
||||
* - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转)
|
||||
* - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序)
|
||||
* - LOW: 暂未使用
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
export const COMMUNITY_EVENT_PRIORITIES = {
|
||||
// ==================== 普通优先级(空闲时追踪)====================
|
||||
|
||||
/**
|
||||
* 页面浏览事件 - NORMAL
|
||||
* 触发时机:用户进入 Community 页面
|
||||
* 延迟原因:页面加载时避免阻塞渲染
|
||||
*/
|
||||
'Community Page Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻列表查看 - NORMAL
|
||||
* 触发时机:新闻列表加载完成
|
||||
* 延迟原因:避免阻塞列表渲染
|
||||
*/
|
||||
'News List Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻筛选应用 - NORMAL
|
||||
* 触发时机:用户应用筛选条件(重要性、日期、行业)
|
||||
* 延迟原因:筛选操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Filter Applied': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻排序变更 - NORMAL
|
||||
* 触发时机:用户切换排序方式(最新、最热、收益率)
|
||||
* 延迟原因:排序操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Sorted': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻标签页点击 - NORMAL
|
||||
* 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线)
|
||||
* 延迟原因:标签切换高频,延迟追踪不影响用户体验
|
||||
*/
|
||||
'News Tab Clicked': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
// ==================== 高优先级(立即追踪)====================
|
||||
|
||||
/**
|
||||
* 新闻文章点击 - HIGH
|
||||
* 触发时机:用户点击新闻卡片
|
||||
* 立即追踪原因:关键交互操作,需要准确记录点击位置和时间
|
||||
*/
|
||||
'News Article Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 新闻详情打开 - HIGH
|
||||
* 触发时机:打开新闻详情弹窗或页面
|
||||
* 立即追踪原因:关键交互操作,需要准确记录查看时间
|
||||
*/
|
||||
'News Detail Opened': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索查询提交 - HIGH
|
||||
* 触发时机:用户提交搜索关键词
|
||||
* 立即追踪原因:用户明确操作,需要准确记录搜索意图
|
||||
*/
|
||||
'Search Query Submitted': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索无结果 - HIGH
|
||||
* 触发时机:搜索返回 0 个结果
|
||||
* 立即追踪原因:重要的用户体验指标,需要及时发现问题
|
||||
*/
|
||||
'Search No Results': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关股票点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关股票
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Stock Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关概念点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关概念
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Concept Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件关注操作 - HIGH
|
||||
* 触发时机:用户点击关注按钮
|
||||
* 立即追踪原因:关键业务操作,需要准确记录关注行为
|
||||
*/
|
||||
'Event Followed': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件取消关注 - HIGH
|
||||
* 触发时机:用户取消关注事件
|
||||
* 立即追踪原因:关键业务操作,需要准确记录取关原因
|
||||
*/
|
||||
'Event Unfollowed': EVENT_PRIORITY.HIGH,
|
||||
};
|
||||
|
||||
/**
|
||||
* requestIdleCallback 配置
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const IDLE_CALLBACK_CONFIG = {
|
||||
/**
|
||||
* 超时时间(毫秒)
|
||||
* 即使浏览器不空闲,也会在此时间后强制执行追踪
|
||||
*
|
||||
* 设置为 2000ms 的原因:
|
||||
* - 足够长:避免在用户快速操作时阻塞主线程
|
||||
* - 足够短:确保用户快速关闭页面前也能发送事件
|
||||
* - 平衡点:2 秒是用户注意力的典型持续时间
|
||||
*/
|
||||
timeout: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取事件优先级
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {string} 事件优先级(CRITICAL | HIGH | NORMAL | LOW)
|
||||
*/
|
||||
export const getEventPriority = (eventName) => {
|
||||
return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否需要立即追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否立即追踪
|
||||
*/
|
||||
export const shouldTrackImmediately = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否可以延迟追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否可以延迟追踪
|
||||
*/
|
||||
export const canTrackIdle = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW;
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
EVENT_PRIORITY,
|
||||
COMMUNITY_EVENT_PRIORITIES,
|
||||
IDLE_CALLBACK_CONFIG,
|
||||
getEventPriority,
|
||||
shouldTrackImmediately,
|
||||
canTrackIdle,
|
||||
};
|
||||
@@ -353,13 +353,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
* 发送浏览器通知
|
||||
*/
|
||||
const sendBrowserNotification = useCallback((notificationData) => {
|
||||
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
|
||||
console.log('[NotificationContext] 通知数据:', notificationData);
|
||||
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
|
||||
logger.debug('NotificationContext', 'sendBrowserNotification 被调用', {
|
||||
notificationData,
|
||||
browserPermission
|
||||
});
|
||||
|
||||
if (browserPermission !== 'granted') {
|
||||
logger.warn('NotificationContext', 'Browser permission not granted');
|
||||
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
|
||||
logger.warn('NotificationContext', '浏览器权限未授予,无法发送通知');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 判断是否需要用户交互(紧急通知不自动关闭)
|
||||
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
|
||||
|
||||
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
|
||||
logger.debug('NotificationContext', '准备发送浏览器通知', {
|
||||
title,
|
||||
body: content,
|
||||
tag,
|
||||
@@ -390,12 +390,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
});
|
||||
|
||||
if (notification) {
|
||||
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
|
||||
logger.info('NotificationContext', '通知对象创建成功', { notification });
|
||||
|
||||
// 设置点击处理(聚焦窗口并跳转)
|
||||
if (link) {
|
||||
notification.onclick = () => {
|
||||
console.log('[NotificationContext] 通知被点击,跳转到:', link);
|
||||
logger.info('NotificationContext', '通知被点击,跳转到', { link });
|
||||
window.focus();
|
||||
// 使用 window.location 跳转(不需要 React Router)
|
||||
window.location.hash = link;
|
||||
@@ -405,7 +405,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ 通知对象创建失败!');
|
||||
logger.error('NotificationContext', '通知对象创建失败');
|
||||
}
|
||||
}, [browserPermission]);
|
||||
|
||||
@@ -640,19 +640,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
addNotificationRef.current = addNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
|
||||
logger.debug('NotificationContext', '已更新 addNotificationRef');
|
||||
}, [addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
adaptEventToNotificationRef.current = adaptEventToNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
|
||||
logger.debug('NotificationContext', '已更新 adaptEventToNotificationRef');
|
||||
}, [adaptEventToNotification]);
|
||||
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接(方案2:只注册一次)', 'color: #673AB7; font-weight: bold;');
|
||||
logger.info('NotificationContext', '初始化 Socket 连接(方案2:只注册一次)');
|
||||
|
||||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||||
socket.on('connect', () => {
|
||||
@@ -661,15 +660,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// 判断是首次连接还是重连
|
||||
if (isFirstConnect.current) {
|
||||
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
|
||||
logger.info('NotificationContext', '首次连接成功', {
|
||||
socketId: socket.getSocketId?.()
|
||||
});
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
isFirstConnect.current = false;
|
||||
logger.info('NotificationContext', 'Socket connected (first time)');
|
||||
} else {
|
||||
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
|
||||
logger.info('NotificationContext', '重连成功');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
|
||||
logger.info('NotificationContext', 'Socket reconnected');
|
||||
|
||||
// 清除之前的定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -684,20 +682,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
logger.info('NotificationContext', '重新订阅事件推送');
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] 订阅确认:', data);
|
||||
logger.info('NotificationContext', 'Events subscribed', data);
|
||||
logger.info('NotificationContext', '订阅成功', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
|
||||
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -705,8 +701,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket disconnected', { reason });
|
||||
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
|
||||
logger.warn('NotificationContext', 'Socket 已断开', { reason });
|
||||
});
|
||||
|
||||
// ========== 监听连接错误 ==========
|
||||
@@ -716,15 +711,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
|
||||
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
|
||||
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
|
||||
});
|
||||
|
||||
// ========== 监听重连失败 ==========
|
||||
socket.on('reconnect_failed', () => {
|
||||
logger.error('NotificationContext', 'Socket reconnect_failed');
|
||||
logger.error('NotificationContext', '重连失败');
|
||||
setConnectionStatus(CONNECTION_STATUS.FAILED);
|
||||
console.error('[NotificationContext] ❌ 重连失败');
|
||||
|
||||
toast({
|
||||
title: '连接失败',
|
||||
@@ -737,21 +730,17 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
|
||||
socket.on('new_event', (data) => {
|
||||
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('[NotificationContext] 原始事件数据:', data);
|
||||
console.log('[NotificationContext] 事件 ID:', data?.id);
|
||||
console.log('[NotificationContext] 事件标题:', data?.title);
|
||||
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
|
||||
console.log('[NotificationContext] 事件重要性:', data?.importance);
|
||||
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
logger.info('NotificationContext', '收到 new_event 事件', {
|
||||
id: data?.id,
|
||||
title: data?.title,
|
||||
eventType: data?.event_type || data?.type,
|
||||
importance: data?.importance
|
||||
});
|
||||
logger.debug('NotificationContext', '原始事件数据', data);
|
||||
|
||||
// ⚠️ 防御性检查:确保 ref 已初始化
|
||||
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
|
||||
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
|
||||
logger.error('NotificationContext', 'Refs not initialized', {
|
||||
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
|
||||
addNotificationRef: !!addNotificationRef.current,
|
||||
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
|
||||
});
|
||||
@@ -770,14 +759,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
|
||||
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
|
||||
return;
|
||||
}
|
||||
|
||||
processedEventIds.current.add(eventId);
|
||||
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
|
||||
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
|
||||
|
||||
// 限制 Set 大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
@@ -790,45 +777,41 @@ export const NotificationProvider = ({ children }) => {
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
|
||||
console.log('[NotificationContext] 正在转换事件格式...');
|
||||
logger.debug('NotificationContext', '正在转换事件格式');
|
||||
const notification = adaptEventToNotificationRef.current(data);
|
||||
console.log('[NotificationContext] 转换后的通知对象:', notification);
|
||||
logger.debug('NotificationContext', '转换后的通知对象', notification);
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的 addNotification 函数
|
||||
console.log('[NotificationContext] 准备添加通知到队列...');
|
||||
logger.debug('NotificationContext', '准备添加通知到队列');
|
||||
addNotificationRef.current(notification);
|
||||
console.log('[NotificationContext] ✅ 通知已添加到队列');
|
||||
logger.info('NotificationContext', '通知已添加到队列');
|
||||
|
||||
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
|
||||
if (eventUpdateCallbacks.current.size > 0) {
|
||||
console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`);
|
||||
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
|
||||
eventUpdateCallbacks.current.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', 'Event update callback error', error);
|
||||
console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
|
||||
logger.error('NotificationContext', '事件更新回调执行失败', error);
|
||||
}
|
||||
});
|
||||
console.log('[NotificationContext] ✅ 所有事件更新回调已触发');
|
||||
logger.debug('NotificationContext', '所有事件更新回调已触发');
|
||||
}
|
||||
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
});
|
||||
|
||||
// ========== 监听系统通知(兼容性) ==========
|
||||
socket.on('system_notification', (data) => {
|
||||
logger.info('NotificationContext', 'Received system notification', data);
|
||||
console.log('[NotificationContext] 📢 收到系统通知:', data);
|
||||
logger.info('NotificationContext', '收到系统通知', data);
|
||||
|
||||
if (addNotificationRef.current) {
|
||||
addNotificationRef.current(data);
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
|
||||
logger.error('NotificationContext', 'addNotificationRef 未初始化');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
|
||||
logger.info('NotificationContext', '所有监听器已注册(只注册一次)');
|
||||
|
||||
// ========== 获取最大重连次数 ==========
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
@@ -836,13 +819,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ========== 启动连接 ==========
|
||||
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
logger.info('NotificationContext', '调用 socket.connect()');
|
||||
socket.connect();
|
||||
|
||||
// ========== 清理函数(组件卸载时) ==========
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
|
||||
logger.info('NotificationContext', '清理 Socket 连接');
|
||||
|
||||
// 清理 reconnected 状态定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -868,7 +850,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 断开连接
|
||||
socket.disconnect();
|
||||
|
||||
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
|
||||
logger.info('NotificationContext', '清理完成');
|
||||
};
|
||||
}, []); // ⚠️ 空依赖数组,确保只执行一次
|
||||
|
||||
@@ -984,92 +966,6 @@ export const NotificationProvider = ({ children }) => {
|
||||
};
|
||||
}, [browserPermission, toast]);
|
||||
|
||||
// 🔧 开发环境调试:暴露方法到 window
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__TEST_NOTIFICATION__ = {
|
||||
// 手动触发网页通知
|
||||
testWebNotification: (type = 'event_alert', priority = 'normal') => {
|
||||
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
const testData = {
|
||||
id: `test_${Date.now()}`,
|
||||
type: type,
|
||||
priority: priority,
|
||||
title: '🧪 测试网页通知',
|
||||
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
|
||||
timestamp: Date.now(),
|
||||
clickable: true,
|
||||
link: '/home',
|
||||
};
|
||||
|
||||
console.log('测试数据:', testData);
|
||||
addNotification(testData);
|
||||
console.log('✅ 通知已添加到队列');
|
||||
},
|
||||
|
||||
// 测试所有类型
|
||||
testAllTypes: () => {
|
||||
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
|
||||
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
|
||||
types.forEach((type, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
|
||||
}, i * 2000); // 每 2 秒一个
|
||||
});
|
||||
},
|
||||
|
||||
// 测试所有优先级
|
||||
testAllPriorities: () => {
|
||||
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
|
||||
const priorities = ['normal', 'important', 'urgent'];
|
||||
priorities.forEach((priority, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
|
||||
}, i * 2000);
|
||||
});
|
||||
},
|
||||
|
||||
// 帮助
|
||||
help: () => {
|
||||
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
|
||||
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' type (通知类型):');
|
||||
console.log(' - "announcement" 公告通知(蓝色)');
|
||||
console.log(' - "stock_alert" 股票动向(红色/绿色)');
|
||||
console.log(' - "event_alert" 事件动向(橙色)');
|
||||
console.log(' - "analysis_report" 分析报告(紫色)');
|
||||
console.log('\n priority (优先级):');
|
||||
console.log(' - "normal" 普通(15秒自动关闭)');
|
||||
console.log(' - "important" 重要(30秒自动关闭)');
|
||||
console.log(' - "urgent" 紧急(不自动关闭)');
|
||||
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' // 测试紧急事件通知');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
|
||||
console.log('\n // 测试所有类型');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
|
||||
console.log('\n // 测试所有优先级');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
|
||||
console.log('\n');
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
|
||||
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
delete window.__TEST_NOTIFICATION__;
|
||||
}
|
||||
};
|
||||
}, [addNotification]); // 依赖 addNotification 函数
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
isConnected,
|
||||
|
||||
@@ -10,20 +10,17 @@
|
||||
* 全局 API:
|
||||
* - window.__DEBUG__ - 调试 API 主对象
|
||||
* - window.__DEBUG__.api - API 调试工具
|
||||
* - window.__DEBUG__.notification - 通知调试工具
|
||||
* - window.__DEBUG__.socket - Socket 调试工具
|
||||
* - window.__DEBUG__.help() - 显示帮助信息
|
||||
* - window.__DEBUG__.exportAll() - 导出所有日志
|
||||
*/
|
||||
|
||||
import { apiDebugger } from './apiDebugger';
|
||||
import { notificationDebugger } from './notificationDebugger';
|
||||
import { socketDebugger } from './socketDebugger';
|
||||
|
||||
class DebugToolkit {
|
||||
constructor() {
|
||||
this.api = apiDebugger;
|
||||
this.notification = notificationDebugger;
|
||||
this.socket = socketDebugger;
|
||||
}
|
||||
|
||||
@@ -47,7 +44,6 @@ class DebugToolkit {
|
||||
|
||||
// 初始化各个调试工具
|
||||
this.api.init();
|
||||
this.notification.init();
|
||||
this.socket.init();
|
||||
|
||||
// 暴露到全局
|
||||
@@ -69,22 +65,13 @@ class DebugToolkit {
|
||||
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
|
||||
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
|
||||
console.log('');
|
||||
console.log('%c2️⃣ 通知调试:', 'color: #9C27B0; font-weight: bold;');
|
||||
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
|
||||
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
|
||||
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
|
||||
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
|
||||
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
|
||||
console.log('');
|
||||
console.log('%c3️⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log('%c2️⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
|
||||
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
|
||||
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
|
||||
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
|
||||
console.log('');
|
||||
console.log('%c4️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('%c3️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' __DEBUG__.help() - 显示帮助信息');
|
||||
console.log(' __DEBUG__.exportAll() - 导出所有日志');
|
||||
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
|
||||
@@ -113,7 +100,6 @@ class DebugToolkit {
|
||||
const allLogs = {
|
||||
timestamp: new Date().toISOString(),
|
||||
api: this.api.getLogs(),
|
||||
notification: this.notification.getLogs(),
|
||||
socket: this.socket.getLogs(),
|
||||
};
|
||||
|
||||
@@ -138,15 +124,11 @@ class DebugToolkit {
|
||||
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
|
||||
const apiStats = this.api.printStats();
|
||||
|
||||
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStats = this.notification.printStats();
|
||||
|
||||
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStats = this.socket.printStats();
|
||||
|
||||
return {
|
||||
api: apiStats,
|
||||
notification: notificationStats,
|
||||
socket: socketStats,
|
||||
};
|
||||
}
|
||||
@@ -157,7 +139,6 @@ class DebugToolkit {
|
||||
clearAll() {
|
||||
console.log('[Debug Toolkit] Clearing all logs...');
|
||||
this.api.clearLogs();
|
||||
this.notification.clearLogs();
|
||||
this.socket.clearLogs();
|
||||
console.log('[Debug Toolkit] ✅ All logs cleared');
|
||||
}
|
||||
@@ -169,15 +150,11 @@ class DebugToolkit {
|
||||
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
|
||||
// 1. Socket 状态
|
||||
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log('\n%c[1/2] Socket 状态', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStatus = this.socket.getStatus();
|
||||
|
||||
// 2. 通知权限
|
||||
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStatus = this.notification.checkPermission();
|
||||
|
||||
// 3. API 错误
|
||||
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
|
||||
// 2. API 错误
|
||||
console.log('\n%c[2/2] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
|
||||
const recentErrors = this.api.getRecentErrors(5);
|
||||
if (recentErrors.length > 0) {
|
||||
console.table(
|
||||
@@ -193,11 +170,10 @@ class DebugToolkit {
|
||||
console.log('✅ 没有 API 错误');
|
||||
}
|
||||
|
||||
// 4. 汇总报告
|
||||
// 3. 汇总报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
socket: socketStatus,
|
||||
notification: notificationStatus,
|
||||
apiErrors: recentErrors.length,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
// src/debug/notificationDebugger.js
|
||||
/**
|
||||
* 通知系统调试工具
|
||||
* 扩展现有的 window.__NOTIFY_DEBUG__,添加更多生产环境调试能力
|
||||
*/
|
||||
|
||||
import { browserNotificationService } from '@services/browserNotificationService';
|
||||
|
||||
class NotificationDebugger {
|
||||
constructor() {
|
||||
this.eventLog = [];
|
||||
this.maxLogSize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调试工具
|
||||
*/
|
||||
init() {
|
||||
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录通知事件
|
||||
*/
|
||||
logEvent(eventType, data) {
|
||||
const logEntry = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
this.eventLog.unshift(logEntry);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`%c[Notification Event] ${eventType}`,
|
||||
'color: #9C27B0; font-weight: bold;',
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件日志
|
||||
*/
|
||||
getLogs() {
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.eventLog = [];
|
||||
console.log('[Notification Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `notification-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[Notification Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制发送浏览器通知(测试用)
|
||||
*/
|
||||
forceNotification(options = {}) {
|
||||
const defaultOptions = {
|
||||
title: '🧪 测试通知',
|
||||
body: `测试时间: ${new Date().toLocaleString()}`,
|
||||
tag: `test_${Date.now()}`,
|
||||
requireInteraction: false,
|
||||
autoClose: 5000,
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('[Notification Debugger] Sending test notification:', finalOptions);
|
||||
|
||||
const notification = browserNotificationService.sendNotification(finalOptions);
|
||||
|
||||
if (notification) {
|
||||
console.log('[Notification Debugger] ✅ Notification sent successfully');
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ Failed to send notification');
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查通知权限状态
|
||||
*/
|
||||
checkPermission() {
|
||||
const permission = browserNotificationService.getPermissionStatus();
|
||||
const isSupported = browserNotificationService.isSupported();
|
||||
|
||||
const status = {
|
||||
supported: isSupported,
|
||||
permission,
|
||||
canSend: isSupported && permission === 'granted',
|
||||
};
|
||||
|
||||
console.table(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
*/
|
||||
async requestPermission() {
|
||||
console.log('[Notification Debugger] Requesting notification permission...');
|
||||
const result = await browserNotificationService.requestPermission();
|
||||
console.log(`[Notification Debugger] Permission result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印事件统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.eventLog.length,
|
||||
byType: {},
|
||||
};
|
||||
|
||||
this.eventLog.forEach((log) => {
|
||||
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('=== Notification Stats ===');
|
||||
console.table(stats.byType);
|
||||
console.log(`Total events: ${stats.total}`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型过滤日志
|
||||
*/
|
||||
getLogsByType(eventType) {
|
||||
return this.eventLog.filter((log) => log.type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
getRecentEvents(count = 10) {
|
||||
return this.eventLog.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
|
||||
*/
|
||||
testWebNotification(type = 'event_alert', priority = 'normal') {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
console.log('[Notification Debugger] 调用测试 API');
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
console.error('💡 请确保:');
|
||||
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
|
||||
console.error(' 2. NotificationContext 已加载');
|
||||
console.error(' 3. 页面已刷新');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有通知类型
|
||||
*/
|
||||
testAllNotificationTypes() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllTypes();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有优先级
|
||||
*/
|
||||
testAllNotificationPriorities() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllPriorities();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const notificationDebugger = new NotificationDebugger();
|
||||
export default notificationDebugger;
|
||||
@@ -120,7 +120,7 @@ export function usePagination<T>(
|
||||
loadData(1, false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoLoad]);
|
||||
}, [autoLoad, loadFunction]);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -150,6 +150,28 @@ export const routeConfig = [
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 价值论坛模块 ====================
|
||||
{
|
||||
path: 'value-forum',
|
||||
component: lazyComponents.ValueForum,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '价值论坛',
|
||||
description: '投资者价值讨论社区'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'value-forum/post/:postId',
|
||||
component: lazyComponents.ForumPostDetail,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '帖子详情',
|
||||
description: '论坛帖子详细内容'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Agent模块 ====================
|
||||
{
|
||||
path: 'agent-chat',
|
||||
|
||||
@@ -144,8 +144,8 @@ export const WECHAT_STATUS = {
|
||||
WAITING: 'waiting',
|
||||
SCANNED: 'scanned',
|
||||
AUTHORIZED: 'authorized',
|
||||
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
LOGIN_SUCCESS: 'login_ready', // ✅ 修复:与后端返回的状态一致
|
||||
REGISTER_SUCCESS: 'register_ready', // ✅ 修复:与后端返回的状态一致
|
||||
EXPIRED: 'expired',
|
||||
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
|
||||
AUTH_FAILED: 'auth_failed', // 授权失败
|
||||
|
||||
442
src/services/elasticsearchService.js
Normal file
442
src/services/elasticsearchService.js
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Elasticsearch 服务层
|
||||
* 用于价值论坛的帖子、评论存储和搜索
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
// Elasticsearch 配置
|
||||
// 使用 Nginx 代理路径避免 Mixed Content 问题
|
||||
const ES_CONFIG = {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? '/es-api' // 生产环境使用 Nginx 代理
|
||||
: 'http://222.128.1.157:19200', // 开发环境直连
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
// 创建 axios 实例
|
||||
const esClient = axios.create(ES_CONFIG);
|
||||
|
||||
// 索引名称
|
||||
const INDICES = {
|
||||
POSTS: 'forum_posts',
|
||||
COMMENTS: 'forum_comments',
|
||||
EVENTS: 'forum_events',
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化索引(创建索引和映射)
|
||||
*/
|
||||
export const initializeIndices = async () => {
|
||||
try {
|
||||
// 创建帖子索引
|
||||
await esClient.put(`/${INDICES.POSTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
title: { type: 'text', analyzer: 'ik_max_word' },
|
||||
content: { type: 'text', analyzer: 'ik_max_word' },
|
||||
images: { type: 'keyword' },
|
||||
tags: { type: 'keyword' },
|
||||
category: { type: 'keyword' },
|
||||
likes_count: { type: 'integer' },
|
||||
comments_count: { type: 'integer' },
|
||||
views_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
updated_at: { type: 'date' },
|
||||
is_pinned: { type: 'boolean' },
|
||||
status: { type: 'keyword' }, // active, deleted, hidden
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 创建评论索引
|
||||
await esClient.put(`/${INDICES.COMMENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
author_id: { type: 'keyword' },
|
||||
author_name: { type: 'text' },
|
||||
author_avatar: { type: 'keyword' },
|
||||
content: { type: 'text', analyzer: 'ik_max_word' },
|
||||
parent_id: { type: 'keyword' }, // 用于嵌套评论
|
||||
likes_count: { type: 'integer' },
|
||||
created_at: { type: 'date' },
|
||||
status: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 创建事件时间轴索引
|
||||
await esClient.put(`/${INDICES.EVENTS}`, {
|
||||
mappings: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
post_id: { type: 'keyword' },
|
||||
event_type: { type: 'keyword' }, // news, price_change, announcement, etc.
|
||||
title: { type: 'text' },
|
||||
description: { type: 'text', analyzer: 'ik_max_word' },
|
||||
source: { type: 'keyword' },
|
||||
source_url: { type: 'keyword' },
|
||||
related_stocks: { type: 'keyword' },
|
||||
occurred_at: { type: 'date' },
|
||||
created_at: { type: 'date' },
|
||||
importance: { type: 'keyword' }, // high, medium, low
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Elasticsearch 索引初始化成功');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||
console.log('索引已存在,跳过创建');
|
||||
} else {
|
||||
console.error('初始化索引失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 帖子相关操作 ====================
|
||||
|
||||
/**
|
||||
* 创建新帖子
|
||||
*/
|
||||
export const createPost = async (postData) => {
|
||||
try {
|
||||
const post = {
|
||||
id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...postData,
|
||||
likes_count: 0,
|
||||
comments_count: 0,
|
||||
views_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
is_pinned: false,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_doc/${post.id}`, post);
|
||||
return { ...post, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('创建帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子列表(支持分页、排序、筛选)
|
||||
*/
|
||||
export const getPosts = async ({ page = 1, size = 20, sort = 'created_at', order = 'desc', category = null, tags = [] }) => {
|
||||
try {
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const query = {
|
||||
bool: {
|
||||
must: [{ match: { status: 'active' } }],
|
||||
},
|
||||
};
|
||||
|
||||
if (category) {
|
||||
query.bool.must.push({ term: { category } });
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
query.bool.must.push({ terms: { tags } });
|
||||
}
|
||||
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||||
from,
|
||||
size,
|
||||
query,
|
||||
sort: [
|
||||
{ is_pinned: { order: 'desc' } },
|
||||
{ [sort]: { order } },
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.data.hits.total.value,
|
||||
posts: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||||
page,
|
||||
size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个帖子详情
|
||||
*/
|
||||
export const getPostById = async (postId) => {
|
||||
try {
|
||||
const response = await esClient.get(`/${INDICES.POSTS}/_doc/${postId}`);
|
||||
|
||||
// 增加浏览量
|
||||
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||
script: {
|
||||
source: 'ctx._source.views_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...response.data._source, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('获取帖子详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新帖子
|
||||
*/
|
||||
export const updatePost = async (postId, updateData) => {
|
||||
try {
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||
doc: {
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('更新帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除帖子(软删除)
|
||||
*/
|
||||
export const deletePost = async (postId) => {
|
||||
try {
|
||||
await updatePost(postId, { status: 'deleted' });
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 点赞帖子
|
||||
*/
|
||||
export const likePost = async (postId) => {
|
||||
try {
|
||||
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||
script: {
|
||||
source: 'ctx._source.likes_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索帖子
|
||||
*/
|
||||
export const searchPosts = async (keyword, { page = 1, size = 20 }) => {
|
||||
try {
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||||
from,
|
||||
size,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
multi_match: {
|
||||
query: keyword,
|
||||
fields: ['title^3', 'content', 'tags^2'],
|
||||
type: 'best_fields',
|
||||
},
|
||||
},
|
||||
{ match: { status: 'active' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
highlight: {
|
||||
fields: {
|
||||
title: {},
|
||||
content: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.data.hits.total.value,
|
||||
posts: response.data.hits.hits.map((hit) => ({
|
||||
...hit._source,
|
||||
_id: hit._id,
|
||||
highlight: hit.highlight,
|
||||
})),
|
||||
page,
|
||||
size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('搜索帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 评论相关操作 ====================
|
||||
|
||||
/**
|
||||
* 创建评论
|
||||
*/
|
||||
export const createComment = async (commentData) => {
|
||||
try {
|
||||
const comment = {
|
||||
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...commentData,
|
||||
likes_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const response = await esClient.post(`/${INDICES.COMMENTS}/_doc/${comment.id}`, comment);
|
||||
|
||||
// 增加帖子评论数
|
||||
await esClient.post(`/${INDICES.POSTS}/_update/${commentData.post_id}`, {
|
||||
script: {
|
||||
source: 'ctx._source.comments_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
|
||||
return { ...comment, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('创建评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子的评论列表
|
||||
*/
|
||||
export const getCommentsByPostId = async (postId, { page = 1, size = 50 }) => {
|
||||
try {
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const response = await esClient.post(`/${INDICES.COMMENTS}/_search`, {
|
||||
from,
|
||||
size,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { post_id: postId } },
|
||||
{ match: { status: 'active' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
sort: [{ created_at: { order: 'asc' } }],
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.data.hits.total.value,
|
||||
comments: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||||
};
|
||||
} catch (error) {
|
||||
// 如果索引不存在(404),返回空结果
|
||||
if (error.response?.status === 404) {
|
||||
console.warn('评论索引不存在,返回空结果:', INDICES.COMMENTS);
|
||||
return { total: 0, comments: [] };
|
||||
}
|
||||
console.error('获取评论列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 点赞评论
|
||||
*/
|
||||
export const likeComment = async (commentId) => {
|
||||
try {
|
||||
await esClient.post(`/${INDICES.COMMENTS}/_update/${commentId}`, {
|
||||
script: {
|
||||
source: 'ctx._source.likes_count += 1',
|
||||
lang: 'painless',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('点赞评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 事件时间轴相关操作 ====================
|
||||
|
||||
/**
|
||||
* 创建事件
|
||||
*/
|
||||
export const createEvent = async (eventData) => {
|
||||
try {
|
||||
const event = {
|
||||
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...eventData,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await esClient.post(`/${INDICES.EVENTS}/_doc/${event.id}`, event);
|
||||
return { ...event, _id: response.data._id };
|
||||
} catch (error) {
|
||||
console.error('创建事件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取帖子的事件时间轴
|
||||
*/
|
||||
export const getEventsByPostId = async (postId) => {
|
||||
try {
|
||||
const response = await esClient.post(`/${INDICES.EVENTS}/_search`, {
|
||||
size: 100,
|
||||
query: {
|
||||
term: { post_id: postId },
|
||||
},
|
||||
sort: [{ occurred_at: { order: 'desc' } }],
|
||||
});
|
||||
|
||||
return response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id }));
|
||||
} catch (error) {
|
||||
// 如果索引不存在(404),返回空数组而不是抛出错误
|
||||
if (error.response?.status === 404) {
|
||||
console.warn('事件索引不存在,返回空数组:', INDICES.EVENTS);
|
||||
return [];
|
||||
}
|
||||
console.error('获取事件时间轴失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
initializeIndices,
|
||||
// 帖子操作
|
||||
createPost,
|
||||
getPosts,
|
||||
getPostById,
|
||||
updatePost,
|
||||
deletePost,
|
||||
likePost,
|
||||
searchPosts,
|
||||
// 评论操作
|
||||
createComment,
|
||||
getCommentsByPostId,
|
||||
likeComment,
|
||||
// 事件操作
|
||||
createEvent,
|
||||
getEventsByPostId,
|
||||
};
|
||||
@@ -166,7 +166,27 @@ export const eventService = {
|
||||
// 帖子相关API
|
||||
getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => {
|
||||
try {
|
||||
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
|
||||
const result = await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
|
||||
|
||||
// ⚡ 数据转换:将后端的 user 字段映射为前端期望的 author 字段
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
result.data = result.data.map(post => ({
|
||||
...post,
|
||||
author: post.user ? {
|
||||
id: post.user.id,
|
||||
username: post.user.username,
|
||||
avatar: post.user.avatar_url || post.user.avatar // 兼容 avatar_url 和 avatar
|
||||
} : {
|
||||
id: 'anonymous',
|
||||
username: 'Anonymous',
|
||||
avatar: null
|
||||
}
|
||||
// 保留原始的 user 字段(如果其他地方需要)
|
||||
// user: post.user
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
|
||||
return { success: false, data: [], pagination: {} };
|
||||
|
||||
227
src/theme/forumTheme.js
Normal file
227
src/theme/forumTheme.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 价值论坛黑金主题配置
|
||||
* 采用深色背景 + 金色点缀的高端配色方案
|
||||
*/
|
||||
|
||||
export const forumColors = {
|
||||
// 主色调 - 黑金渐变
|
||||
primary: {
|
||||
50: '#FFF9E6',
|
||||
100: '#FFEEBA',
|
||||
200: '#FFE38D',
|
||||
300: '#FFD860',
|
||||
400: '#FFCD33',
|
||||
500: '#FFC107', // 主金色
|
||||
600: '#FFB300',
|
||||
700: '#FFA000',
|
||||
800: '#FF8F00',
|
||||
900: '#FF6F00',
|
||||
},
|
||||
|
||||
// 背景色系 - 深黑渐变
|
||||
background: {
|
||||
main: '#0A0A0A', // 主背景 - 极黑
|
||||
secondary: '#121212', // 次级背景
|
||||
card: '#1A1A1A', // 卡片背景
|
||||
hover: '#222222', // 悬停背景
|
||||
elevated: '#2A2A2A', // 提升背景(模态框等)
|
||||
},
|
||||
|
||||
// 文字色系
|
||||
text: {
|
||||
primary: '#FFFFFF', // 主文字 - 纯白
|
||||
secondary: '#B8B8B8', // 次要文字 - 灰色
|
||||
tertiary: '#808080', // 三级文字 - 深灰
|
||||
muted: '#5A5A5A', // 弱化文字
|
||||
gold: '#FFC107', // 金色强调文字
|
||||
goldGradient: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', // 金色渐变
|
||||
},
|
||||
|
||||
// 边框色系
|
||||
border: {
|
||||
default: '#333333',
|
||||
light: '#404040',
|
||||
gold: '#FFC107',
|
||||
goldGlow: 'rgba(255, 193, 7, 0.3)',
|
||||
},
|
||||
|
||||
// 功能色
|
||||
semantic: {
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#F44336',
|
||||
info: '#2196F3',
|
||||
},
|
||||
|
||||
// 金色渐变系列
|
||||
gradients: {
|
||||
goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
|
||||
goldSecondary: 'linear-gradient(135deg, #FFC107 0%, #FF8F00 100%)',
|
||||
goldSubtle: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 165, 0, 0.05) 100%)',
|
||||
blackGold: 'linear-gradient(135deg, #0A0A0A 0%, #1A1A1A 50%, #2A2020 100%)',
|
||||
cardHover: 'linear-gradient(135deg, #1A1A1A 0%, #252525 100%)',
|
||||
},
|
||||
|
||||
// 阴影色系
|
||||
shadows: {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.5)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.4)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.7), 0 4px 6px -2px rgba(0, 0, 0, 0.5)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 10px 10px -5px rgba(0, 0, 0, 0.6)',
|
||||
gold: '0 0 20px rgba(255, 193, 7, 0.3), 0 0 40px rgba(255, 193, 7, 0.1)',
|
||||
goldHover: '0 0 30px rgba(255, 193, 7, 0.5), 0 0 60px rgba(255, 193, 7, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 论坛组件样式配置
|
||||
*/
|
||||
export const forumComponentStyles = {
|
||||
// 按钮样式
|
||||
Button: {
|
||||
baseStyle: {
|
||||
fontWeight: '600',
|
||||
borderRadius: 'md',
|
||||
transition: 'all 0.3s ease',
|
||||
},
|
||||
variants: {
|
||||
gold: {
|
||||
bg: forumColors.gradients.goldPrimary,
|
||||
color: '#0A0A0A',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: forumColors.shadows.goldHover,
|
||||
_disabled: {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
goldOutline: {
|
||||
bg: 'transparent',
|
||||
color: forumColors.primary[500],
|
||||
border: '2px solid',
|
||||
borderColor: forumColors.primary[500],
|
||||
_hover: {
|
||||
bg: forumColors.gradients.goldSubtle,
|
||||
boxShadow: forumColors.shadows.gold,
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
bg: forumColors.background.card,
|
||||
color: forumColors.text.primary,
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.default,
|
||||
_hover: {
|
||||
bg: forumColors.background.hover,
|
||||
borderColor: forumColors.border.light,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 卡片样式
|
||||
Card: {
|
||||
baseStyle: {
|
||||
container: {
|
||||
bg: forumColors.background.card,
|
||||
borderRadius: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.default,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: forumColors.border.gold,
|
||||
boxShadow: forumColors.shadows.gold,
|
||||
transform: 'translateY(-4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 输入框样式
|
||||
Input: {
|
||||
variants: {
|
||||
forum: {
|
||||
field: {
|
||||
bg: forumColors.background.secondary,
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.default,
|
||||
color: forumColors.text.primary,
|
||||
_placeholder: {
|
||||
color: forumColors.text.tertiary,
|
||||
},
|
||||
_hover: {
|
||||
borderColor: forumColors.border.light,
|
||||
},
|
||||
_focus: {
|
||||
borderColor: forumColors.border.gold,
|
||||
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 标签样式
|
||||
Tag: {
|
||||
variants: {
|
||||
gold: {
|
||||
container: {
|
||||
bg: forumColors.gradients.goldSubtle,
|
||||
color: forumColors.primary[500],
|
||||
border: '1px solid',
|
||||
borderColor: forumColors.border.gold,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 论坛专用动画配置
|
||||
*/
|
||||
export const forumAnimations = {
|
||||
fadeIn: {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
|
||||
slideIn: {
|
||||
initial: { opacity: 0, x: -20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: 20 },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
|
||||
scaleIn: {
|
||||
initial: { opacity: 0, scale: 0.9 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.9 },
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
|
||||
goldGlow: {
|
||||
animate: {
|
||||
boxShadow: [
|
||||
forumColors.shadows.gold,
|
||||
forumColors.shadows.goldHover,
|
||||
forumColors.shadows.gold,
|
||||
],
|
||||
},
|
||||
transition: {
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
colors: forumColors,
|
||||
components: forumComponentStyles,
|
||||
animations: forumAnimations,
|
||||
};
|
||||
@@ -53,3 +53,13 @@ export type {
|
||||
CommentAuthor,
|
||||
CreateCommentParams,
|
||||
} from './comment';
|
||||
|
||||
// 投资规划相关类型
|
||||
export type {
|
||||
EventType,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
InvestmentEvent,
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
148
src/types/investment.ts
Normal file
148
src/types/investment.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 投资规划相关类型定义
|
||||
* 用于 InvestmentPlanningCenter 组件及其子组件
|
||||
*/
|
||||
|
||||
import { UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件类型枚举
|
||||
*/
|
||||
export type EventType = 'plan' | 'review' | 'reminder' | 'analysis';
|
||||
|
||||
/**
|
||||
* 事件来源
|
||||
*/
|
||||
export type EventSource = 'user' | 'future' | 'system';
|
||||
|
||||
/**
|
||||
* 事件状态
|
||||
*/
|
||||
export type EventStatus = 'active' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* 投资事件接口
|
||||
* 表示日历中的投资计划、复盘或其他事件
|
||||
*/
|
||||
export interface InvestmentEvent {
|
||||
/** 事件唯一标识符 */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 事件描述/详细内容 */
|
||||
description?: string;
|
||||
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
event_date: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 事件来源(用户创建/系统生成/未来事件) */
|
||||
source?: EventSource;
|
||||
|
||||
/** 重要度 (1-5) */
|
||||
importance?: number;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks?: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status?: EventStatus;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
|
||||
/** 事件内容(用于计划/复盘的详细内容) */
|
||||
content?: string;
|
||||
|
||||
/** 日期字段(兼容旧数据) */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据类型
|
||||
* 用于创建/编辑投资计划或复盘
|
||||
*/
|
||||
export interface PlanFormData {
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
date: string;
|
||||
|
||||
/** 标题 */
|
||||
title: string;
|
||||
|
||||
/** 内容/描述 */
|
||||
content: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning Context 值类型
|
||||
* 用于在 InvestmentPlanningCenter 的子组件间共享数据
|
||||
*/
|
||||
export interface PlanningContextValue {
|
||||
/** 所有事件列表 */
|
||||
allEvents: InvestmentEvent[];
|
||||
|
||||
/** 设置事件列表 */
|
||||
setAllEvents: React.Dispatch<React.SetStateAction<InvestmentEvent[]>>;
|
||||
|
||||
/** 重新加载所有数据 */
|
||||
loadAllData: () => Promise<void>;
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 设置加载状态 */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
/** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */
|
||||
activeTab: number;
|
||||
|
||||
/** 设置激活的标签页 */
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
/** Chakra UI Toast 实例 */
|
||||
toast: {
|
||||
(options?: UseToastOptions): string | number | undefined;
|
||||
close: (id: string | number) => void;
|
||||
closeAll: (options?: { positions?: Array<'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'> }) => void;
|
||||
update: (id: string | number, options: Omit<UseToastOptions, 'id'>) => void;
|
||||
isActive: (id: string | number) => boolean;
|
||||
};
|
||||
|
||||
// 颜色主题变量(基于当前主题模式)
|
||||
/** 背景色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// src/utils/logger.js
|
||||
// 统一日志工具
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
// 支持开发环境或显式开启调试模式
|
||||
// 生产环境下可以通过设置 REACT_APP_ENABLE_DEBUG=true 来开启调试日志
|
||||
const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.REACT_APP_ENABLE_DEBUG === 'true';
|
||||
|
||||
// ========== 日志限流配置 ==========
|
||||
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
|
||||
|
||||
337
src/utils/trackingHelpers.js
Normal file
337
src/utils/trackingHelpers.js
Normal file
@@ -0,0 +1,337 @@
|
||||
// src/utils/trackingHelpers.js
|
||||
// PostHog 追踪性能优化工具 - 使用 requestIdleCallback 延迟非关键事件
|
||||
|
||||
import { shouldTrackImmediately } from '../constants/tracking';
|
||||
|
||||
/**
|
||||
* requestIdleCallback Polyfill
|
||||
* Safari 和旧浏览器不支持 requestIdleCallback,使用 setTimeout 降级
|
||||
*
|
||||
* @param {Function} callback - 回调函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.timeout - 超时时间(毫秒)
|
||||
* @returns {number} 定时器 ID
|
||||
*/
|
||||
const requestIdleCallbackPolyfill = (callback, options = {}) => {
|
||||
const timeout = options.timeout || 2000;
|
||||
const start = Date.now();
|
||||
|
||||
return setTimeout(() => {
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
|
||||
});
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* cancelIdleCallback Polyfill
|
||||
*
|
||||
* @param {number} id - 定时器 ID
|
||||
*/
|
||||
const cancelIdleCallbackPolyfill = (id) => {
|
||||
clearTimeout(id);
|
||||
};
|
||||
|
||||
// 使用原生 API 或 polyfill
|
||||
const requestIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.requestIdleCallback
|
||||
? window.requestIdleCallback.bind(window)
|
||||
: requestIdleCallbackPolyfill;
|
||||
|
||||
const cancelIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.cancelIdleCallback
|
||||
? window.cancelIdleCallback.bind(window)
|
||||
: cancelIdleCallbackPolyfill;
|
||||
|
||||
// ==================== 待发送事件队列 ====================
|
||||
|
||||
/**
|
||||
* 待发送事件队列(用于批量发送优化)
|
||||
* @type {Array<{trackFn: Function, args: Array}>}
|
||||
*/
|
||||
let pendingEvents = [];
|
||||
|
||||
/**
|
||||
* 已调度的 idle callback ID(防止重复调度)
|
||||
* @type {number|null}
|
||||
*/
|
||||
let scheduledCallbackId = null;
|
||||
|
||||
/**
|
||||
* 刷新待发送事件队列
|
||||
* 立即执行所有待发送的追踪事件
|
||||
*/
|
||||
const flushPendingEvents = () => {
|
||||
if (pendingEvents.length === 0) return;
|
||||
|
||||
const eventsToFlush = [...pendingEvents];
|
||||
pendingEvents = [];
|
||||
|
||||
eventsToFlush.forEach(({ trackFn, args }) => {
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to flush event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c✅ [trackingHelpers] Flushed ${eventsToFlush.length} pending event(s)`,
|
||||
'color: #10B981; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理空闲时执行待发送事件
|
||||
*
|
||||
* @param {IdleDeadline} deadline - 空闲时间信息
|
||||
*/
|
||||
const processIdleEvents = (deadline) => {
|
||||
scheduledCallbackId = null;
|
||||
|
||||
// 如果超时或队列为空,强制刷新
|
||||
if (deadline.didTimeout || pendingEvents.length === 0) {
|
||||
flushPendingEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// 在空闲时间内尽可能多地处理事件
|
||||
while (pendingEvents.length > 0 && deadline.timeRemaining() > 0) {
|
||||
const { trackFn, args } = pendingEvents.shift();
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有未处理的事件,继续调度
|
||||
if (pendingEvents.length > 0) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 在浏览器空闲时追踪事件(非关键事件优化)
|
||||
*
|
||||
* 使用 requestIdleCallback API 延迟事件追踪到浏览器空闲时执行,
|
||||
* 避免阻塞主线程,提升页面交互响应速度。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 页面浏览事件(page_viewed)
|
||||
* - 列表查看事件(list_viewed)
|
||||
* - 筛选/排序事件(filter_applied, sorted)
|
||||
* - 低优先级交互事件
|
||||
*
|
||||
* **不适用场景**:
|
||||
* - 关键业务事件(登录、支付、关注)
|
||||
* - 用户明确操作事件(按钮点击、详情打开)
|
||||
* - 需要实时追踪的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 track, trackPageView)
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventIdle } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 延迟追踪页面浏览事件
|
||||
* trackEventIdle(trackEvent, 'page_viewed', { page: '/community' });
|
||||
*
|
||||
* // 延迟追踪筛选事件
|
||||
* trackEventIdle(track, 'news_filter_applied', { importance: 'high' });
|
||||
*/
|
||||
export const trackEventIdle = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到待发送队列
|
||||
pendingEvents.push({ trackFn, args });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⏱️ [trackingHelpers] Event queued for idle execution (queue: ${pendingEvents.length})`,
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有已调度的 callback,调度一个新的
|
||||
if (scheduledCallbackId === null) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000, // 2秒超时保护,确保事件不会无限延迟
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即追踪事件(关键事件)
|
||||
*
|
||||
* 同步执行追踪,不延迟。用于需要实时追踪的关键业务事件。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 关键业务事件(登录、注册、支付、订阅)
|
||||
* - 用户明确操作(按钮点击、详情打开、搜索提交)
|
||||
* - 高优先级交互事件(关注、分享、评论)
|
||||
* - 需要准确时序的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventImmediate } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 立即追踪登录事件
|
||||
* trackEventImmediate(trackEvent, 'user_logged_in', { method: 'password' });
|
||||
*
|
||||
* // 立即追踪详情打开事件
|
||||
* trackEventImmediate(track, 'news_detail_opened', { news_id: 123 });
|
||||
*/
|
||||
export const trackEventImmediate = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
trackFn(...args);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⚡ [trackingHelpers] Event tracked immediately`,
|
||||
'color: #F59E0B; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event immediately:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能追踪包装器
|
||||
*
|
||||
* 根据事件优先级自动选择立即追踪或空闲时追踪。
|
||||
* 使用 `shouldTrackImmediately()` 判断事件优先级,简化调用方代码。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 业务代码不需要关心事件优先级细节
|
||||
* - 统一的追踪接口,自动优化性能
|
||||
* - 易于维护和扩展
|
||||
*
|
||||
* **优先级规则**(由 `src/constants/tracking.js` 配置):
|
||||
* - CRITICAL / HIGH → 立即追踪(`trackEventImmediate`)
|
||||
* - NORMAL / LOW → 空闲时追踪(`trackEventIdle`)
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 `track` from `usePostHogTrack`)
|
||||
* @param {string} eventName - 事件名称(需在 `tracking.js` 中定义优先级)
|
||||
* @param {Object} properties - 事件属性
|
||||
*
|
||||
* @example
|
||||
* import { smartTrack } from '@/utils/trackingHelpers';
|
||||
* import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
*
|
||||
* const { track } = usePostHogTrack();
|
||||
*
|
||||
* // 自动根据优先级选择追踪方式
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: 123 });
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: 30 });
|
||||
*/
|
||||
export const smartTrack = (trackFn, eventName, properties = {}) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventName || typeof eventName !== 'string') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: eventName must be a string');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据事件优先级选择追踪方式
|
||||
if (shouldTrackImmediately(eventName)) {
|
||||
// 高优先级事件:立即追踪
|
||||
trackEventImmediate(trackFn, eventName, properties);
|
||||
} else {
|
||||
// 普通优先级事件:空闲时追踪
|
||||
trackEventIdle(trackFn, eventName, properties);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面卸载前刷新所有待发送事件
|
||||
*
|
||||
* 在 beforeunload 事件中调用,确保页面关闭前发送所有待发送的追踪事件。
|
||||
* 防止用户快速关闭页面时丢失事件数据。
|
||||
*
|
||||
* **使用方式**:
|
||||
* ```javascript
|
||||
* import { flushPendingEventsBeforeUnload } from '@utils/trackingHelpers';
|
||||
*
|
||||
* useEffect(() => {
|
||||
* window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* return () => {
|
||||
* window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* };
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export const flushPendingEventsBeforeUnload = () => {
|
||||
// 取消已调度的 idle callback
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
|
||||
// 立即刷新所有待发送事件
|
||||
flushPendingEvents();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
'%c🔄 [trackingHelpers] Flushed pending events before unload',
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前待发送事件数量(调试用)
|
||||
*
|
||||
* @returns {number} 待发送事件数量
|
||||
*/
|
||||
export const getPendingEventsCount = () => {
|
||||
return pendingEvents.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空待发送事件队列(测试用)
|
||||
*/
|
||||
export const clearPendingEvents = () => {
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
pendingEvents = [];
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
trackEventIdle,
|
||||
trackEventImmediate,
|
||||
smartTrack,
|
||||
flushPendingEventsBeforeUnload,
|
||||
getPendingEventsCount,
|
||||
clearPendingEvents,
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
|
||||
// 扩展 Day.js 插件
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
@@ -12,7 +18,7 @@ import moment from 'moment';
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -25,18 +31,18 @@ export const getCurrentTradingTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ export const getCurrentTradingTimeRange = () => {
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -67,13 +73,13 @@ export const getMarketReviewTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
@@ -102,15 +108,15 @@ export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = moment(startTime);
|
||||
const endMoment = moment(endTime);
|
||||
const startMoment = dayjs(startTime);
|
||||
const endMoment = dayjs(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = moment(event.created_at);
|
||||
const eventTime = dayjs(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
@@ -138,8 +144,8 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = moment(startTime).format('MM-DD HH:mm');
|
||||
const endStr = moment(endTime).format('MM-DD HH:mm');
|
||||
const startStr = dayjs(startTime).format('MM-DD HH:mm');
|
||||
const endStr = dayjs(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
@@ -152,7 +158,7 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = moment(date).day();
|
||||
const day = dayjs(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
@@ -164,7 +170,7 @@ export const isTradingDay = (date) => {
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = moment(date).subtract(1, 'day');
|
||||
let prevDay = dayjs(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
|
||||
@@ -63,7 +63,7 @@ let dynamicNewsCardRenderCount = 0;
|
||||
* @param {Object} trackingFunctions - PostHog 追踪函数集合
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
const DynamicNewsCardComponent = forwardRef(({
|
||||
filters = {},
|
||||
popularKeywords = [],
|
||||
lastUpdateTime,
|
||||
@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
'fourRowData.total': fourRowData.total,
|
||||
});
|
||||
|
||||
// 根据模式选择数据源
|
||||
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式:data 是数组 [...]
|
||||
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
|
||||
const modeData = useMemo(
|
||||
() => currentMode === 'four-row' ? fourRowData : verticalData,
|
||||
[currentMode, fourRowData, verticalData]
|
||||
);
|
||||
const {
|
||||
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
|
||||
loading = false,
|
||||
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
cachedPageCount = 0
|
||||
} = modeData;
|
||||
|
||||
// 传递给 usePagination 的数据
|
||||
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
|
||||
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
|
||||
// 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
|
||||
const allCachedEventsByPage = useMemo(
|
||||
() => currentMode === 'vertical' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
const allCachedEvents = useMemo(
|
||||
() => currentMode === 'four-row' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
|
||||
// 🔍 调试:选择的数据源
|
||||
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
|
||||
@@ -227,8 +236,8 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
// ========== 纵向模式 ==========
|
||||
// 只在第1页时刷新,避免打断用户浏览其他页
|
||||
if (state.currentPage === 1) {
|
||||
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表');
|
||||
handlePageChange(1); // 清空缓存并刷新第1页
|
||||
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 强制刷新列表');
|
||||
handlePageChange(1, true); // ⚡ 传递 force = true,强制刷新第1页
|
||||
toast({
|
||||
title: '检测到新事件',
|
||||
status: 'info',
|
||||
@@ -722,6 +731,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
);
|
||||
});
|
||||
|
||||
DynamicNewsCard.displayName = 'DynamicNewsCard';
|
||||
DynamicNewsCardComponent.displayName = 'DynamicNewsCard';
|
||||
|
||||
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
|
||||
const DynamicNewsCard = React.memo(DynamicNewsCardComponent);
|
||||
|
||||
export default DynamicNewsCard;
|
||||
|
||||
@@ -30,7 +30,7 @@ import VerticalModeLayout from './VerticalModeLayout';
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
const EventScrollList = React.memo(({
|
||||
events,
|
||||
displayEvents,
|
||||
loadNextPage,
|
||||
@@ -144,6 +144,6 @@ const EventScrollList = ({
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default EventScrollList;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
* @param {string} mode - 当前模式 'vertical' | 'four-row'
|
||||
* @param {Function} onModeChange - 模式切换回调
|
||||
*/
|
||||
const ModeToggleButtons = ({ mode, onModeChange }) => {
|
||||
const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
|
||||
return (
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
@@ -28,6 +28,6 @@ const ModeToggleButtons = ({ mode, onModeChange }) => {
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default ModeToggleButtons;
|
||||
|
||||
@@ -35,7 +35,7 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel'
|
||||
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
|
||||
* @param {string} borderColor - 边框颜色
|
||||
*/
|
||||
const VerticalModeLayout = ({
|
||||
const VerticalModeLayout = React.memo(({
|
||||
display = 'flex',
|
||||
events,
|
||||
selectedEvent,
|
||||
@@ -182,6 +182,6 @@ const VerticalModeLayout = ({
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default VerticalModeLayout;
|
||||
|
||||
@@ -25,7 +25,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||||
* @param {boolean} props.loading - 加载状态
|
||||
*/
|
||||
const VirtualizedFourRowGrid = forwardRef(({
|
||||
const VirtualizedFourRowGridComponent = forwardRef(({
|
||||
display = 'block',
|
||||
events,
|
||||
columnsPerRow = 4,
|
||||
@@ -387,6 +387,9 @@ const VirtualizedFourRowGrid = forwardRef(({
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid';
|
||||
VirtualizedFourRowGridComponent.displayName = 'VirtualizedFourRowGrid';
|
||||
|
||||
// ⚡ 使用 React.memo 优化性能(减少不必要的重渲染)
|
||||
const VirtualizedFourRowGrid = React.memo(VirtualizedFourRowGridComponent);
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
|
||||
@@ -158,7 +158,11 @@ export const usePagination = ({
|
||||
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
|
||||
|
||||
// 翻页处理(第1页强制刷新 + 其他页缓存)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
const handlePageChange = useCallback(async (newPage, force = false) => {
|
||||
// force 参数:是否强制刷新(绕过"重复点击"检查)
|
||||
// - true: 强制刷新(Socket 新事件触发)
|
||||
// - false: 正常翻页(用户点击分页按钮)
|
||||
|
||||
// 边界检查 1: 检查页码范围
|
||||
if (newPage < 1 || newPage > totalPages) {
|
||||
console.log(`%c⚠️ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
|
||||
@@ -166,13 +170,19 @@ export const usePagination = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 边界检查 2: 检查是否重复点击
|
||||
if (newPage === currentPage) {
|
||||
// 边界检查 2: 检查是否重复点击(强制刷新时绕过此检查)
|
||||
if (!force && newPage === currentPage) {
|
||||
console.log(`%c⚠️ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
|
||||
logger.debug('usePagination', '页码未改变', { newPage });
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚡ 如果是强制刷新(force = true),即使页码相同也继续执行
|
||||
if (force && newPage === currentPage) {
|
||||
console.log(`%c🔄 [翻页] 强制刷新当前页: ${newPage}`, 'color: #10B981; font-weight: bold;');
|
||||
logger.info('usePagination', '强制刷新当前页', { newPage });
|
||||
}
|
||||
|
||||
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
|
||||
if (loadingPage === newPage) {
|
||||
console.log(`%c⚠️ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
@@ -98,7 +98,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{moment(event.created_at).format('YYYY年MM月DD日')}
|
||||
{dayjs(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -26,7 +26,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,9 +105,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
|
||||
const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
@@ -28,9 +28,9 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
<FaCalendarAlt color="gray" size={12} />
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
涨跌幅数据:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
@@ -89,16 +89,16 @@ const RelatedConceptsSection = ({
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -137,7 +137,7 @@ const CompactEventCard = ({
|
||||
<Text>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
<Text>•</Text>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -127,7 +127,7 @@ const DetailedEventCard = ({
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { getChangeColor } from '../../../../utils/colorUtils';
|
||||
|
||||
@@ -33,7 +33,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
|
||||
* @param {Function} props.onToggleFollow - 切换关注事件
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
*/
|
||||
const DynamicNewsEventCard = ({
|
||||
const DynamicNewsEventCard = React.memo(({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = ({
|
||||
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
|
||||
*/
|
||||
const getTradingPeriod = (timestamp) => {
|
||||
const eventTime = moment(timestamp);
|
||||
const eventTime = dayjs(timestamp);
|
||||
const hour = eventTime.hour();
|
||||
const minute = eventTime.minute();
|
||||
const timeInMinutes = hour * 60 + minute;
|
||||
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = ({
|
||||
color={timeLabelStyle.textColor}
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{periodLabel && (
|
||||
<>
|
||||
{' • '}
|
||||
@@ -317,6 +317,6 @@ const DynamicNewsEventCard = ({
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default DynamicNewsEventCard;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/EventCard/EventTimeline.js
|
||||
import React from 'react';
|
||||
import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 事件时间轴组件
|
||||
@@ -56,7 +56,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
color={timelineStyle.textColor}
|
||||
lineHeight="1.2"
|
||||
>
|
||||
{moment(createdAt).format('MM-DD')}
|
||||
{dayjs(createdAt).format('MM-DD')}
|
||||
</Text>
|
||||
{/* 时间 HH:mm */}
|
||||
<Text
|
||||
@@ -66,7 +66,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
lineHeight="1.2"
|
||||
mt={0.5}
|
||||
>
|
||||
{moment(createdAt).format('HH:mm')}
|
||||
{dayjs(createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 时间轴竖线 */}
|
||||
|
||||
@@ -39,7 +39,7 @@ import KeywordsCarousel from './KeywordsCarousel';
|
||||
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
|
||||
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
|
||||
*/
|
||||
const HorizontalDynamicNewsEventCard = ({
|
||||
const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
@@ -227,6 +227,6 @@ const HorizontalDynamicNewsEventCard = ({
|
||||
</Box>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default HorizontalDynamicNewsEventCard;
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -143,7 +143,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
<>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{moment(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
{dayjs(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{eventDetail.creator?.username || 'Anonymous'}
|
||||
@@ -234,7 +234,7 @@ const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<strong>{comment.author?.username || 'Anonymous'}</strong>
|
||||
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
|
||||
{moment(comment.created_at).format('MM-DD HH:mm')}
|
||||
{dayjs(comment.created_at).format('MM-DD HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
@@ -181,9 +181,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">
|
||||
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
<span className="time-date">{dayjs(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
<span className="time-hour">{dayjs(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
|
||||
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
|
||||
} from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
|
||||
@@ -33,7 +33,7 @@ const InvestmentCalendar = () => {
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(moment());
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||
|
||||
// 新增状态
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
|
||||
@@ -344,7 +344,7 @@ const InvestmentCalendar = () => {
|
||||
render: (time) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text>{moment(time).format('HH:mm')}</Text>
|
||||
<Text>{dayjs(time).format('HH:mm')}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import CompactEventCard from './EventCard/CompactEventCard';
|
||||
import EventHeader from './EventCard/EventHeader';
|
||||
import EventStats from './EventCard/EventStats';
|
||||
@@ -160,7 +160,7 @@ const MarketReviewCard = forwardRef(({
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { Drawer, Spin, Button, Alert } from 'antd';
|
||||
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { Tabs as AntdTabs } from 'antd';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Services and Utils
|
||||
import { eventService } from '../../../services/eventService';
|
||||
@@ -167,7 +167,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
if (fixedCharts.length === 0) return null;
|
||||
|
||||
const formattedEventTime = event?.start_time
|
||||
? moment(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
? dayjs(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
: undefined;
|
||||
|
||||
return fixedCharts.map(({ stock }, index) => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -27,7 +27,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
|
||||
// 稳定的事件时间,避免因为格式化导致的重复请求
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(times) && times.length > 0) {
|
||||
try {
|
||||
const eventMinute = moment(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const parseMinuteTime = (timeStr) => {
|
||||
const [h, m] = String(timeStr).split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Button } from 'antd';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -31,7 +31,7 @@ const StockTable = ({
|
||||
|
||||
// 稳定的事件时间,避免重复渲染
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 切换行展开状态
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../../../../services/eventService';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -19,7 +19,7 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
|
||||
const date = eventTime ? dayjs(eventTime).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}|${chartType}`;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const shouldRefreshData = (cacheKey) => {
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
// 如果是今天的数据且交易时间内,允许更频繁的更新
|
||||
const today = moment().format('YYYY-MM-DD');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isToday = cacheKey.includes(today);
|
||||
const currentHour = new Date().getHours();
|
||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
||||
@@ -76,7 +76,7 @@ export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline
|
||||
|
||||
// 3. 发起新请求
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
|
||||
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, chartType, normalizedEventTime)
|
||||
.then((res) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// src/views/Community/hooks/useCommunityEvents.js
|
||||
// 新闻催化分析页面事件追踪 Hook
|
||||
// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { smartTrack } from '@/utils/trackingHelpers';
|
||||
|
||||
/**
|
||||
* 新闻催化分析(Community)事件追踪 Hook
|
||||
@@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger';
|
||||
export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
// 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪)
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useCommunityEvents', '📰 Community Page Viewed');
|
||||
@@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} params.industryFilter - 行业筛选
|
||||
*/
|
||||
const trackNewsListViewed = useCallback((params = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
total_count: params.totalCount || 0,
|
||||
sort_by: params.sortBy || 'new',
|
||||
importance_filter: params.importance || 'all',
|
||||
@@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
tab_name: tabName,
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} filters.industryCode - 行业代码
|
||||
*/
|
||||
const trackNewsFilterApplied = useCallback((filters = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
importance: filters.importance || 'all',
|
||||
date_range: filters.dateRange || 'all',
|
||||
industry_classification: filters.industryClassification || 'all',
|
||||
@@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_SORTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, {
|
||||
sort_by: sortBy,
|
||||
previous_sort: previousSort,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const trackNewsSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
@@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
// 如果没有搜索结果,额外追踪(高优先级,立即发送)
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'community_news',
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'news_related_stocks',
|
||||
@@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_code: concept.code,
|
||||
concept_name: concept.name || '',
|
||||
source: 'news_related_concepts',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
fetchHotEvents
|
||||
} from '../../store/slices/communityDataSlice';
|
||||
} from '@/store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useNotification } from '@/contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme';
|
||||
import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
@@ -96,6 +97,15 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化)
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && !loading) {
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function CenterDashboard() {
|
||||
const [w, e, c] = await Promise.all([
|
||||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
fetch(base + `/api/account/events/comments?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||
]);
|
||||
const jw = await w.json();
|
||||
const je = await e.json();
|
||||
|
||||
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* CalendarPanel - 投资日历面板组件
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { DateClickArg } from '@fullcalendar/interaction';
|
||||
import { EventClickArg } from '@fullcalendar/common';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 新事件表单数据类型
|
||||
*/
|
||||
interface NewEventForm {
|
||||
title: string;
|
||||
description: string;
|
||||
type: EventType;
|
||||
importance: number;
|
||||
stocks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FullCalendar 事件类型
|
||||
*/
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
date: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
extendedProps: InvestmentEvent & {
|
||||
isSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarPanel 组件
|
||||
* 日历视图面板,显示所有投资事件
|
||||
*/
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setActiveTab,
|
||||
toast,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [newEvent, setNewEvent] = useState<NewEventForm>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 转换数据为 FullCalendar 格式
|
||||
const calendarEvents: CalendarEvent[] = allEvents.map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info: DateClickArg): void => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info: EventClickArg): void => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('CalendarPanel', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadAllData();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除事件
|
||||
const handleDeleteEvent = async (eventId: number): Promise<void> => {
|
||||
if (!eventId) {
|
||||
logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('CalendarPanel', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到计划或复盘标签页
|
||||
const handleViewDetails = (event: InvestmentEvent): void => {
|
||||
if (event.type === 'plan') {
|
||||
setActiveTab(1); // 跳转到"我的计划"标签页
|
||||
} else if (event.type === 'review') {
|
||||
setActiveTab(2); // 跳转到"我的复盘"标签页
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
if (!selectedDate) setSelectedDate(dayjs());
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={calendarEvents}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.source === 'future' ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : event.type === 'plan' ? (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="green" variant="subtle">我的复盘</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{event.importance && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.importance}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack>
|
||||
{!event.source || event.source === 'user' ? (
|
||||
<>
|
||||
<Tooltip label="查看详情">
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleViewDetails(event)}
|
||||
aria-label="查看详情"
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
aria-label="删除事件"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{event.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="review">投资复盘</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -52,13 +52,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -140,12 +140,12 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
moment(event.start).isSame(clickedDate, 'day')
|
||||
dayjs(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -154,7 +154,7 @@ export default function InvestmentCalendarChakra() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
@@ -173,7 +173,7 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function InvestmentCalendarChakra() {
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
|
||||
@@ -66,13 +66,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import '../components/InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 创建 Context 用于跨标签页共享数据
|
||||
const PlanningDataContext = createContext();
|
||||
@@ -232,11 +232,11 @@ function CalendarPanel() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
moment(event.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -245,11 +245,11 @@ function CalendarPanel() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
moment(ev.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -262,7 +262,7 @@ function CalendarPanel() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -368,7 +368,7 @@ function CalendarPanel() {
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
@@ -619,7 +619,7 @@ function PlansPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -638,13 +638,13 @@ function PlansPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -795,7 +795,7 @@ function PlansPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
@@ -1043,7 +1043,7 @@ function ReviewsPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1062,13 +1062,13 @@ function ReviewsPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1205,7 +1205,7 @@ function ReviewsPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件,~200 行)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - PlansPanel (计划面板,懒加载)
|
||||
* - ReviewsPanel (复盘面板,懒加载)
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiFileText,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
const CalendarPanel = lazy(() =>
|
||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||
);
|
||||
const PlansPanel = lazy(() =>
|
||||
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
|
||||
);
|
||||
const ReviewsPanel = lazy(() =>
|
||||
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
|
||||
);
|
||||
|
||||
/**
|
||||
* 面板加载占位符
|
||||
*/
|
||||
const PanelLoadingFallback: React.FC = () => (
|
||||
<Center py={12}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* InvestmentPlanningCenter 主组件
|
||||
*/
|
||||
const InvestmentPlanningCenter: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
*/
|
||||
const loadAllData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllEvents(data.data || []);
|
||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = {
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
toast,
|
||||
bgColor,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
};
|
||||
|
||||
// 计算各类型事件数量
|
||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={5} />
|
||||
<Heading size="md">投资规划中心</Heading>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiCalendar} mr={2} />
|
||||
日历视图
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 日历视图面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<PlansPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<ReviewsPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PlanningDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentPlanningCenter;
|
||||
@@ -60,12 +60,12 @@ import {
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -83,7 +83,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -134,12 +134,12 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
@@ -291,7 +291,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function MyFutureEvents({ limit = 5 }) {
|
||||
const [futureEvents, setFutureEvents] = useState([]);
|
||||
@@ -51,7 +51,7 @@ export default function MyFutureEvents({ limit = 5 }) {
|
||||
if (response.success) {
|
||||
// 按时间排序,最近的在前
|
||||
const sortedEvents = (response.data || []).sort((a, b) =>
|
||||
moment(a.calendar_time).valueOf() - moment(b.calendar_time).valueOf()
|
||||
dayjs(a.calendar_time).valueOf() - dayjs(b.calendar_time).valueOf()
|
||||
);
|
||||
setFutureEvents(sortedEvents);
|
||||
logger.debug('MyFutureEvents', '未来事件加载成功', {
|
||||
@@ -98,8 +98,8 @@ export default function MyFutureEvents({ limit = 5 }) {
|
||||
|
||||
// 格式化时间
|
||||
const formatEventTime = (time) => {
|
||||
const eventTime = moment(time);
|
||||
const now = moment();
|
||||
const eventTime = dayjs(time);
|
||||
const now = dayjs();
|
||||
const daysDiff = eventTime.diff(now, 'days');
|
||||
|
||||
if (daysDiff === 0) {
|
||||
|
||||
60
src/views/Dashboard/components/PlanningContext.tsx
Normal file
60
src/views/Dashboard/components/PlanningContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter Context
|
||||
* 用于在日历、计划、复盘三个面板间共享数据和状态
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import type { PlanningContextValue } from '@/types';
|
||||
|
||||
/**
|
||||
* Planning Data Context
|
||||
* 提供投资规划数据和操作方法
|
||||
*/
|
||||
const PlanningDataContext = createContext<PlanningContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* PlanningDataProvider Props
|
||||
*/
|
||||
interface PlanningDataProviderProps {
|
||||
/** Context 值 */
|
||||
value: PlanningContextValue;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanningDataProvider 组件
|
||||
* 包裹需要访问投资规划数据的组件
|
||||
*/
|
||||
export const PlanningDataProvider: React.FC<PlanningDataProviderProps> = ({ value, children }) => {
|
||||
return (
|
||||
<PlanningDataContext.Provider value={value}>
|
||||
{children}
|
||||
</PlanningDataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* usePlanningData Hook
|
||||
* 在子组件中访问投资规划数据
|
||||
*
|
||||
* @throws {Error} 如果在 PlanningDataProvider 外部调用
|
||||
* @returns {PlanningContextValue} Context 值
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function CalendarPanel() {
|
||||
* const { allEvents, loading, toast } = usePlanningData();
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePlanningData = (): PlanningContextValue => {
|
||||
const context = useContext(PlanningDataContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('usePlanningData 必须在 PlanningDataProvider 内部使用');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
506
src/views/Dashboard/components/PlansPanel.tsx
Normal file
506
src/views/Dashboard/components/PlansPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* PlansPanel - 投资计划列表面板组件
|
||||
* 显示、编辑和管理投资计划
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiTarget,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlansPanel 组件
|
||||
* 计划列表面板,显示所有投资计划
|
||||
*/
|
||||
export const PlansPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选计划列表(排除系统事件)
|
||||
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'plan',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑计划"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除计划"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:布局新能源板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
506
src/views/Dashboard/components/ReviewsPanel.tsx
Normal file
506
src/views/Dashboard/components/ReviewsPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* ReviewsPanel - 投资复盘列表面板组件
|
||||
* 显示、编辑和管理投资复盘
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiFileText,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewsPanel 组件
|
||||
* 复盘列表面板,显示所有投资复盘
|
||||
*/
|
||||
export const ReviewsPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选复盘列表(排除系统事件)
|
||||
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'review',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiFileText} color="green.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑复盘"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除复盘"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="green.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资复盘</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资复盘
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:本周操作复盘"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细记录您的投资复盘..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
Divider
|
||||
} from '@chakra-ui/react';
|
||||
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
||||
@@ -326,7 +326,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof tradeDate === 'string') {
|
||||
formattedTradeDate = tradeDate;
|
||||
} else if (tradeDate instanceof Date) {
|
||||
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(tradeDate).format('YYYY-MM-DD');
|
||||
} else if (moment.isMoment(tradeDate)) {
|
||||
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
|
||||
} else {
|
||||
@@ -334,7 +334,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
tradeDate,
|
||||
tradeDateType: typeof tradeDate
|
||||
});
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
@@ -414,18 +414,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
|
||||
// 检查是否是Date对象
|
||||
if (eventTime instanceof Date) {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else if (typeof eventTime === 'string') {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else if (typeof eventTime === 'number') {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '未知的事件时间格式', {
|
||||
eventTime,
|
||||
eventTimeType: typeof eventTime,
|
||||
eventId
|
||||
});
|
||||
eventMoment = moment();
|
||||
eventMoment = dayjs();
|
||||
}
|
||||
|
||||
// 确保moment对象有效
|
||||
@@ -434,7 +434,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
eventTime,
|
||||
eventId
|
||||
});
|
||||
eventMoment = moment();
|
||||
eventMoment = dayjs();
|
||||
}
|
||||
|
||||
formattedDate = eventMoment.format('YYYY-MM-DD');
|
||||
@@ -448,7 +448,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof nextTradingDay === 'string') {
|
||||
formattedDate = nextTradingDay;
|
||||
} else if (nextTradingDay instanceof Date) {
|
||||
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD');
|
||||
formattedDate = dayjs(nextTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '交易日工具返回了无效格式', {
|
||||
nextTradingDay,
|
||||
@@ -476,16 +476,16 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof currentTradingDay === 'string') {
|
||||
formattedDate = currentTradingDay;
|
||||
} else if (currentTradingDay instanceof Date) {
|
||||
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD');
|
||||
formattedDate = dayjs(currentTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', {
|
||||
currentTradingDay,
|
||||
eventId
|
||||
});
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
formattedDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} else {
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
formattedDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,9 +558,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
<FaCalendarAlt color={textColor} />
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
涨跌幅数据日期:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs">
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
370
src/views/ValueForum/PostDetail.js
Normal file
370
src/views/ValueForum/PostDetail.js
Normal 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;
|
||||
318
src/views/ValueForum/components/CommentSection.js
Normal file
318
src/views/ValueForum/components/CommentSection.js
Normal 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;
|
||||
419
src/views/ValueForum/components/CreatePostModal.js
Normal file
419
src/views/ValueForum/components/CreatePostModal.js
Normal 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;
|
||||
347
src/views/ValueForum/components/EventTimeline.js
Normal file
347
src/views/ValueForum/components/EventTimeline.js
Normal 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;
|
||||
203
src/views/ValueForum/components/PostCard.js
Normal file
203
src/views/ValueForum/components/PostCard.js
Normal 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;
|
||||
311
src/views/ValueForum/index.js
Normal file
311
src/views/ValueForum/index.js
Normal 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
528
valuefrontier
Normal 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)
|
||||
# ==========================================================
|
||||
Reference in New Issue
Block a user