Compare commits

..

70 Commits

Author SHA1 Message Date
6df66abcb4 调整socket对应的浏览器通知处理逻辑 2025-10-24 14:29:45 +08:00
16d04a6d28 调整socket对应的浏览器通知处理逻辑 2025-10-24 14:22:30 +08:00
zdl
3f881d000b feat: 添加修改行业分类不展示的问题 2025-10-24 13:30:52 +08:00
zdl
801113b7e5 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-24 12:54:54 +08:00
zdl
e0cd71880b feat: 申银万国数据分类调整 2025-10-24 12:54:42 +08:00
zdl
10a4dcb5d5 merge 2025-10-24 12:54:13 +08:00
zdl
9429eb0559 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature
# Conflicts:
#	src/views/Community/components/EventFilters.js
2025-10-24 12:37:35 +08:00
zdl
e69f822150 feat: user依赖优化 2025-10-24 12:34:43 +08:00
zdl
13c3c74b92 feat: 添加mock数据 2025-10-24 12:32:36 +08:00
zdl
bcf81f4d47 feat: 使用静态行业数据 2025-10-24 12:32:14 +08:00
zdl
f0d30244d2 feat: 添加重要性等级说明 2025-10-24 12:25:23 +08:00
zdl
f2cdc0756c feat: 添加行业静态数据 2025-10-24 12:21:22 +08:00
zdl
e91656d332 feat: user 依赖优化 2025-10-24 12:19:37 +08:00
62d6487cbb 取消levels接口,限制classifications接口仅为申万行业接口 2025-10-24 11:47:48 +08:00
246adf4538 取消levels接口,限制classifications接口仅为申万行业接口 2025-10-24 11:33:27 +08:00
8dcf643db7 取消levels接口,限制classifications接口仅为申万行业接口 2025-10-24 11:27:45 +08:00
zdl
5eb4227e29 feat: 股票概览中心改为个股中心 2025-10-24 11:03:41 +08:00
zdl
34a6c402c4 feat: homeNavar 将投资日历从社区页面的右侧导航移到了顶部导航栏
InvestmentCalendar.js 将 loadEventCounts 函数改为使用 useCallback 包装
  - 修复了 useEffect 的依赖数组,添加了 loadEventCounts
  - 为事件列表 Modal 添加了 zIndex={1500}
  - 为内容详情 Drawer 添加了 zIndex={1500}
  - 为相关股票 Modal 添加了 zIndex={1500}
src/views/Community/components/RightSidebar.js

  修改内容:
  - 已删除此文件
2025-10-24 10:56:43 +08:00
zdl
6ad38594bb feat: 添加重要事件说明 2025-10-23 17:37:03 +08:00
zdl
1ba8b8fd2f feat: 消息通知能力测试 2025-10-23 15:25:36 +08:00
zdl
45b88309b3 pref: 代码优化 2025-10-23 15:03:39 +08:00
zdl
28975f74e9 feat: 将新闻中心改为事件中心 2025-10-23 14:57:26 +08:00
zdl
4eaeab521f feat: 事件请求防抖优化 2025-10-23 14:42:14 +08:00
zdl
9dcd4bfbf3 feat: 调整行业请求数据结构 2025-10-23 14:24:26 +08:00
zdl
d2988d1a33 feat: 增加券商名字段 2025-10-23 11:44:58 +08:00
zdl
30520542c8 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-23 11:22:36 +08:00
zdl
035bb9a66d feat: 补充翻页功能 2025-10-23 11:22:07 +08:00
zdl
8bd7f59d35 feat: 事件刷新滚动到希望的特定位置 2025-10-23 10:57:54 +08:00
37eba48906 update /api/events/<int:event_id>/stocks resp format 2025-10-23 10:09:24 +08:00
9ad2dc7fab update /api/events/<int:event_id>/stocks resp format 2025-10-23 08:31:06 +08:00
0b1591c3dd update /api/events/<int:event_id>/stocks resp format 2025-10-23 08:18:13 +08:00
0a28f235d3 update /api/events/<int:event_id>/stocks resp format 2025-10-23 07:40:34 +08:00
zdl
db0d0ed269 feat: 去掉路由无用路由 2025-10-22 16:49:46 +08:00
zdl
43229a21c0 feat: 添加路由保护 2025-10-22 15:41:34 +08:00
zdl
35198aa548 feat: 微信UI调整 2025-10-22 15:40:36 +08:00
zdl
1f3fe8ce39 feat: 登陆付款mock添加 2025-10-22 15:36:55 +08:00
zdl
a9fee411ea feat: 权限引导能力测试 2025-10-22 15:23:36 +08:00
zdl
433a982a20 feat: 成功和错误弹窗从顶部弹出 2025-10-22 12:18:53 +08:00
zdl
cc210f9fda feat: 自动化部署代码初步提交 2025-10-22 11:02:39 +08:00
zdl
23188d5690 feat: 修改文件 │
│                                                                                           │
     │ 1. src/services/socketService.js - 指数退避 + 无限重试                                    │
     │ 2. src/components/ConnectionStatusBar/index.js - UI 优化 + 自动消失                       │
     │ 3. src/App.js - handleClose 实现 + dismissed 状态管理                                     │
     │ 4. src/contexts/NotificationContext.js - 添加成功状态检测                                 │
     │ 5. NOTIFICATION_SYSTEM.md - v2.11.0 文档更新
2025-10-21 18:34:38 +08:00
zdl
09c9273190 feat: sockt 弹窗功能添加 2025-10-21 17:50:21 +08:00
zdl
c93f689954 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature 2025-10-21 15:53:01 +08:00
zdl
38499ce650 feat: 添加消息推送能力 2025-10-21 15:48:38 +08:00
zdl
955e0db740 feat: 首页UI调整 2025-10-21 15:43:59 +08:00
zdl
98653f042b feat: 导航UI调整 2025-10-21 15:43:35 +08:00
zdl
eef383f56f feat: 导航UI调整 2025-10-21 15:24:23 +08:00
74968d5bc8 添加socketservice 2025-10-21 15:13:11 +08:00
cfb00ba895 C:/Program Files/Git/api/events加入socketio机制——更新超时时间 2025-10-21 15:00:13 +08:00
4b6d86e923 C:/Program Files/Git/api/events加入socketio机制 2025-10-21 14:43:18 +08:00
zdl
d32cd616de fix: 解决有多个导航烂的问题 2025-10-21 14:04:38 +08:00
zdl
31eb322ecc fix: 解决有多个导航烂的问题 2025-10-21 14:03:58 +08:00
zdl
5a3a3ad42b feat: 添加消息推送能力,添加新闻催化分析页的合规提示 2025-10-21 10:59:52 +08:00
zdl
6c96299b8f feat: 添加合规 2025-10-20 21:25:33 +08:00
zdl
d695f8ff7b feat: 登陆状态调整 2025-10-20 13:58:07 +08:00
zdl
b2681231b0 feat: 删除无用组件 2025-10-20 13:34:19 +08:00
zdl
44f9fea624 feat: 添加导航徽章 2025-10-20 13:28:37 +08:00
zdl
923611f3a8 feat: 个人中心页添加mock数据 2025-10-19 16:17:31 +08:00
zdl
c0aaa5bde1 feat: 删除废弃文件 2025-10-18 22:45:39 +08:00
zdl
5eab62c673 feat: 日志优化 2025-10-18 22:32:50 +08:00
zdl
47fcb570c0 feat: 日志优化 2025-10-18 17:33:15 +08:00
zdl
a7695c7365 feat: 日志优化 2025-10-18 12:26:08 +08:00
zdl
4ebb17190f feat: 日志优化 2025-10-18 12:12:02 +08:00
zdl
87b77af187 feat:Community 组件 (2个文件,8个console)
- EventDetailModal.js - 2个
  - InvestmentCalendar.js - 6个

  EventDetail 组件 (5个文件,54个console)

  - TransmissionChainAnalysis.js - 43个 ⚠️ 最复杂
  - RelatedConcepts.js - 14个
  - LimitAnalyse.js - 5个 (保留2个toast)
  - RelatedStocks.js - 3个 (保留4个toast)
  - HistoricalEvents.js - 1个

  StockChart 组件 (1个文件,4个console)
2025-10-18 10:23:23 +08:00
zdl
3a3cac75f7 feat: 继续重构 Community 子组件和 EventDetail 子组件 2025-10-18 09:53:26 +08:00
zdl
c1bea7a75d feat: 重构文件数: 3 个主要页面文件
- 替换 console 调用: 约 18 个
  - 移除非关键 toast: 6 个
  - 保留关键 toast: 2 个(搜索相关的用户操作反馈)
  - 添加 logger 日志: 约 15 处
2025-10-18 09:17:40 +08:00
zdl
32121c416e feat: 重构 TradingSimulation 和 Dashboard 组件 2025-10-18 09:03:10 +08:00
zdl
ea627f867e feat:添加mock接口
1.  Profile 和 Settings 页面(2个文件)
  2.  EventDetail 页面(1个文件)
  3.  身份验证组件(WechatRegister.js)
  4.  Company 页面(CompanyOverview, index, FinancialPanorama, MarketDataView)
  5.  Concept 页面(ConceptTimelineModal, ConceptStatsPanel, index)
2025-10-18 08:46:56 +08:00
zdl
3821b88f28 feat: 重构Company和Concept页面 2025-10-18 08:14:26 +08:00
zdl
b46ee4a18e feat: 添加日志 2025-10-18 08:08:58 +08:00
zdl
36558e0715 feat: 1. 基础设施(2个文件)
-  src/utils/logger.js - 统一日志工具
    - API 请求/响应/错误日志
    - 组件错误/警告/调试日志
    - 开发环境详细分组,生产环境仅错误
  -  src/utils/axiosConfig.js - axios 全局拦截器
    - 自动记录所有请求/响应
    - 统一 baseURL 和 credentials 配置\
2. 核心文件重构(8个文件)\
 AuthFormContent.js |  保留登录/注册成功 toast 移除验证码发送 toast 添加 .trim() 所有 API 添加 logger |  完成 |
  | Center.js          |  移除所有 toast 移除 toast 依赖 添加错误 logger                         |  完成 |
  | Community/index.js |  移除所有 toast 和导入 移除 toast 依赖 添加错误 logger                     |  完成 |
  | authService.js     |  统一 apiRequest 函数 所有请求自动记录 移除 console.error                 |  完成 |
  | eventService.js    |  重构 apiRequest 所有方法添加 logger 移除 console.log/error           |  完成 |
  | stockService       |  所有方法添加 logger 移除 console 输出                                 |  完成 |
  | indexService       |  添加 logger 移除 console 输出                                     |  完成 |
  | AuthContext.js     |  保留注册/登出成功 toast 移除验证码发送 toast 所有方法添加 logger                |  完成 |\
3. Mock 数据完善(\
 Mock 数据完善(1个文件)

  -  src/mocks/handlers/account.js - 个人中心 Mock
    -  自选股列表 (GET /api/account/watchlist)
    -  实时行情 (GET /api/account/watchlist/realtime)
    -  添加自选股 (POST /api/account/watchlist/add)
    -  删除自选股 (DELETE /api/account/watchlist/:id)
    -  关注的事件 (GET /api/account/events/following)
    -  事件评论 (GET /api/account/events/comments)
    -  当前订阅 (GET /api/subscription/current)\
4. API 文档(1个文件)

  -  API_ENDPOINTS.md - 完整 API 接口文档
    - 认证相关: 4个接口
    - 个人中心: 12个接口
    - 事件相关: 12个接口
    - 总计: 28+个接口\
5。Toast 策略执行:
  -  保留: 3种(登录成功、注册成功、登出成功)
  -  移除: 15+处(验证码、数据加载等)

  Logger 替换:
  -  console.log → logger.debug/logger.info
  -  console.error → logger.error\- console.warn → logger.warn

  Mock 数据:
  已有: auth.js, event.js, users.js, events.js
  新增: account.js(7个新接口)
6.用户体验改进
  静默优化:不再弹出验证码发送成功提示(静默处理)不再弹出数据加载失败提示(console 记录) 仅在关键操作显示 toast(登录/注册/登出)

  开发体验: Console 中有清晰的分组日志(🌐 🔴 ⚠️ 等图标), 所有 API 请求/响应自动记录,错误日志包含完整上下文和堆栈,Mock 服务完善
 测试场景: 登录/注册 - 仅显示成功 toast,验证码静默发送 个人中心 - 加载自选股、实时行情、关注事件 社区页面 - 加载事件列表、Console 查看
9. 添加日志:API Request /  API Response /  API Error
2025-10-18 07:48:00 +08:00
167 changed files with 34449 additions and 13909 deletions

View File

@@ -1,13 +1,14 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(xargs ls:*)",
"Bash(awk:*)",
"Bash(npm start)",
"Bash(python3:*)"
"Read(//Users/qiye/**)",
"Bash(npm run lint:check)",
"Bash(npm run build)",
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)",
"Bash(node scripts/parseIndustryCSV.js)",
"Bash(cat:*)"
],
"deny": [],
"ask": []
}
}
}

63
.env.deploy.example Normal file
View File

@@ -0,0 +1,63 @@
# 部署配置文件
# 首次使用请复制此文件为 .env.deploy 并填写真实配置
# ==================== 服务器配置 ====================
# 服务器 IP 或域名
SERVER_HOST=your-server-ip-or-domain
# SSH 用户名
SERVER_USER=ubuntu
# SSH 端口
SERVER_PORT=22
# SSH 密钥路径(留空使用默认 ~/.ssh/id_rsa
SSH_KEY_PATH=
# ==================== 路径配置 ====================
# 服务器上的 Git 仓库路径
REMOTE_PROJECT_PATH=/home/ubuntu/vf_react
# 生产环境部署路径
PRODUCTION_PATH=/var/www/valuefrontier.cn
# 部署备份目录
BACKUP_DIR=/home/ubuntu/deployments
# 部署日志目录
LOG_DIR=/home/ubuntu/deploy-logs
# ==================== Git 配置 ====================
# 部署分支
DEPLOY_BRANCH=feature
# ==================== 备份配置 ====================
# 保留备份数量
KEEP_BACKUPS=5
# ==================== 企业微信通知配置 ====================
# 是否启用企业微信通知 (true/false)
ENABLE_WECHAT_NOTIFY=false
# 企业微信机器人 Webhook URL
WECHAT_WEBHOOK_URL=
# 通知提及的用户(@all 或 手机号/userid
WECHAT_MENTIONED_LIST=
# ==================== 部署配置 ====================
# 是否在部署前运行 npm install (true/false)
RUN_NPM_INSTALL=true
# 是否在部署前运行 npm test (true/false)
RUN_NPM_TEST=false
# 构建命令
BUILD_COMMAND=npm run build
# ==================== 高级配置 ====================
# SSH 连接超时时间(秒)
SSH_TIMEOUT=30
# 部署超时时间(秒)
DEPLOY_TIMEOUT=600

View File

@@ -1,5 +1,20 @@
# ========================================
# Mock 测试环境配置
# ========================================
# 使用方式: npm run start:mock
#
# 工作原理:
# 1. 通过 env-cmd 加载此配置文件
# 2. REACT_APP_ENABLE_MOCK=true 会在 src/index.js 中启动 MSW (Mock Service Worker)
# 3. MSW 在浏览器层面拦截所有 HTTP 请求
# 4. 根据 src/mocks/handlers/* 中定义的规则返回 mock 数据
# 5. 未定义 mock 的接口会继续请求真实后端
#
# 适用场景:
# - 前端独立开发,无需后端支持
# - 测试特定接口的 UI 表现
# - 后端接口未就绪时的快速原型开发
# ========================================
# React 构建优化配置
GENERATE_SOURCEMAP=false
@@ -10,10 +25,12 @@ IMAGE_INLINE_SIZE_LIMIT=10000
NODE_OPTIONS=--max_old_space_size=4096
# API 配置
# Mock 模式下不需要真实的后端地址
REACT_APP_API_URL=http://localhost:3000
# Mock 模式下使用空字符串,让请求使用相对路径
# MSW 会在浏览器层拦截这些请求,不需要真实的后端地址
REACT_APP_API_URL=
# 启用 Mock 数据(核心配置)
# 此配置会触发 src/index.js 中的 MSW 初始化
REACT_APP_ENABLE_MOCK=true
# Mock 环境标识

9
.gitignore vendored
View File

@@ -39,4 +39,11 @@ pnpm-debug.log*
.DS_Store
# Windows
Thumbs.dbsrc/assets/img/original-backup/
Thumbs.db
# Documentation
*.md
!README.md
!CLAUDE.md
src/assets/img/original-backup/

415
API_ENDPOINTS.md Normal file
View File

@@ -0,0 +1,415 @@
# API 接口文档
本文档记录了项目中所有 API 接口的详细信息。
## 目录
- [认证相关 API](#认证相关-api)
- [个人中心相关 API](#个人中心相关-api)
- [事件相关 API](#事件相关-api)
- [股票相关 API](#股票相关-api)
- [公司相关 API](#公司相关-api)
- [订阅/支付相关 API](#订阅支付相关-api)
---
## 认证相关 API
### POST /api/auth/send-verification-code
发送验证码到手机号或邮箱
**请求参数**:
```json
{
"credential": "13800138000", // 手机号或邮箱
"type": "phone", // 'phone' | 'email'
"purpose": "login" // 'login' | 'register'
}
```
**响应示例**:
```json
{
"success": true,
"message": "验证码已发送到 13800138000",
"dev_code": "123456" // 仅开发环境返回
}
```
**错误响应**:
```json
{
"success": false,
"error": "发送验证码失败"
}
```
**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 21-44
**涉及文件**:
- `src/components/Auth/AuthFormContent.js` 行 164-207
---
### POST /api/auth/login-with-code
使用验证码登录(支持自动注册新用户)
**请求参数**:
```json
{
"credential": "13800138000",
"verification_code": "123456",
"login_type": "phone" // 'phone' | 'email'
}
```
**响应示例**:
```json
{
"success": true,
"message": "登录成功",
"isNewUser": false,
"user": {
"id": 1,
"phone": "13800138000",
"nickname": "用户昵称",
"email": null,
"avatar_url": "https://...",
"has_wechat": false
},
"token": "mock_token_1_1234567890"
}
```
**错误响应**:
```json
{
"success": false,
"error": "验证码错误"
}
```
**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 47-115
**涉及文件**:
- `src/components/Auth/AuthFormContent.js` 行 252-327
**注意事项**:
- 后端需要支持自动注册新用户(当用户不存在时)
- 前端已添加 `.trim()` 防止空格问题
---
### GET /api/auth/session
检查当前登录状态
**响应示例**:
```json
{
"success": true,
"isAuthenticated": true,
"user": {
"id": 1,
"phone": "13800138000",
"nickname": "用户昵称"
}
}
```
**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 269-290
---
### POST /api/auth/logout
退出登录
**响应示例**:
```json
{
"success": true,
"message": "退出成功"
}
```
**Mock 数据**: ✅ `src/mocks/handlers/auth.js` 行 317-329
---
## 个人中心相关 API
### GET /api/account/watchlist
获取用户自选股列表
**响应示例**:
```json
{
"success": true,
"data": [
{
"id": 1,
"stock_code": "000001.SZ",
"stock_name": "平安银行",
"added_at": "2024-01-01T00:00:00Z"
}
]
}
```
**Mock 数据**: ❌ 待创建 `src/mocks/handlers/account.js`
**涉及文件**:
- `src/views/Dashboard/Center.js` 行 94
---
### GET /api/account/watchlist/realtime
获取自选股实时行情
**响应示例**:
```json
{
"success": true,
"data": {
"000001.SZ": {
"price": 12.34,
"change": 0.56,
"change_percent": 4.76,
"volume": 123456789
}
}
}
```
**Mock 数据**: ❌ 待创建
**涉及文件**:
- `src/views/Dashboard/Center.js` 行 133
---
### GET /api/account/events/following
获取用户关注的事件列表
**响应示例**:
```json
{
"success": true,
"data": [
{
"id": 1,
"title": "事件标题",
"followed_at": "2024-01-01T00:00:00Z"
}
]
}
```
**Mock 数据**: ❌ 待创建
**涉及文件**:
- `src/views/Dashboard/Center.js` 行 95
---
### GET /api/account/events/comments
获取用户的事件评论
**响应示例**:
```json
{
"success": true,
"data": [
{
"id": 1,
"event_id": 123,
"content": "评论内容",
"created_at": "2024-01-01T00:00:00Z"
}
]
}
```
**Mock 数据**: ❌ 待创建
**涉及文件**:
- `src/views/Dashboard/Center.js` 行 96
---
### GET /api/subscription/current
获取当前订阅信息
**响应示例**:
```json
{
"success": true,
"data": {
"plan": "premium",
"expires_at": "2025-01-01T00:00:00Z",
"auto_renew": true
}
}
```
**Mock 数据**: ❌ 待创建 `src/mocks/handlers/subscription.js`
**涉及文件**:
- `src/views/Dashboard/Center.js` 行 97
---
## 事件相关 API
### GET /api/events
获取事件列表
**查询参数**:
- `page`: 页码(默认 1
- `per_page`: 每页数量(默认 10
- `sort`: 排序方式 ('new' | 'hot' | 'returns')
- `importance`: 重要性筛选 ('all' | 'high' | 'medium' | 'low')
- `date_range`: 日期范围
- `q`: 搜索关键词
- `industry_classification`: 行业分类
- `industry_code`: 行业代码
**响应示例**:
```json
{
"success": true,
"data": {
"events": [
{
"id": 1,
"title": "事件标题",
"importance": "high",
"created_at": "2024-01-01T00:00:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 10,
"total": 100
}
}
}
```
**Mock 数据**: ⚠️ 部分实现(需完善)
**涉及文件**:
- `src/views/Community/index.js` 行 148
---
### GET /api/events/:id
获取事件详情
**响应示例**:
```json
{
"success": true,
"data": {
"id": 1,
"title": "事件标题",
"content": "事件内容",
"importance": "high",
"created_at": "2024-01-01T00:00:00Z"
}
}
```
**Mock 数据**: ❌ 待创建
---
### GET /api/events/:id/stocks
获取事件相关股票
**响应示例**:
```json
{
"success": true,
"data": [
{
"stock_code": "000001.SZ",
"stock_name": "平安银行",
"correlation": 0.85
}
]
}
```
**Mock 数据**: ✅ `src/mocks/handlers/event.js` 行 12-38
---
### GET /api/events/popular-keywords
获取热门关键词
**查询参数**:
- `limit`: 返回数量(默认 20
**响应示例**:
```json
{
"success": true,
"data": [
{
"keyword": "人工智能",
"count": 123,
"trend": "up"
}
]
}
```
**Mock 数据**: ❌ 待创建
**涉及文件**:
- `src/views/Community/index.js` 行 180
---
### GET /api/events/hot
获取热点事件
**查询参数**:
- `days`: 天数范围(默认 5
- `limit`: 返回数量(默认 4
**响应示例**:
```json
{
"success": true,
"data": [
{
"id": 1,
"title": "热点事件标题",
"heat_score": 95.5
}
]
}
```
**Mock 数据**: ❌ 待创建
**涉及文件**:
- `src/views/Community/index.js` 行 192
---
## 待补充 API
以下 API 将在重构其他文件时逐步添加:
- 股票相关 API
- 公司相关 API
- 订阅/支付相关 API
- 用户资料相关 API
- 行业分类相关 API
---
## 更新日志
- 2024-XX-XX: 创建文档,记录认证和个人中心相关 API

1812
CENTER_PAGE_FLOW_ANALYSIS.md Normal file

File diff suppressed because it is too large Load Diff

307
DARK_MODE_TEST.md Normal file
View File

@@ -0,0 +1,307 @@
# 🌙 暗色模式适配 - 测试指南
## ✅ 完成的修改
### 修改文件
1. **`src/constants/notificationTypes.js`** - 添加暗色模式配置
2. **`src/components/NotificationContainer/index.js`** - 更新颜色逻辑
### 新增配置
为每种通知类型添加了暗色模式专属配置:
| 配置项 | 亮色值 | 暗色值 | 说明 |
|-------|-------|-------|------|
| `bg` | `{color}.50` | `rgba(..., 0.15)` | 背景色15% 透明度 |
| `borderColor` | `{color}.400` | `{color}.400` | 边框色:保持一致 |
| `iconColor` | `{color}.500` | `{color}.300` | 图标色:降低饱和度 |
| `hoverBg` | `{color}.100` | `rgba(..., 0.25)` | Hover背景25% 透明度 |
---
## 🧪 测试步骤
### 1. 启动应用
```bash
npm start
```
### 2. 切换到暗色模式
#### 方法 A通过浏览器开发者工具
1. 打开浏览器开发者工具F12
2. 切换到 "渲染" 或 "Rendering" 标签
3. 找到 "Emulate CSS media feature prefers-color-scheme"
4. 选择 "prefers-color-scheme: dark"
#### 方法 B系统设置
1. 将你的操作系统切换到暗色模式
2. 刷新页面
#### 方法 CChakra UI Color Mode Toggle
如果你的应用有主题切换按钮,直接点击切换即可。
### 3. 触发通知
**Mock 模式**(默认):
- 等待 60 秒,会自动推送 1-2 条通知
- 或在控制台执行:
```javascript
import { mockSocketService } from './services/mockSocketService.js';
mockSocketService.sendTestNotification();
```
**Real 模式**
- 创建测试事件(运行后端测试脚本)
### 4. 验证效果
检查以下项目:
#### ✅ 背景色
- [ ] **半透明效果**:背景应该是半透明的,能看到底层背景
- [ ] **类型区分**:蓝、橙、紫、红、绿应该清晰可辨
- [ ] **不刺眼**:不应该有过深的背景色
#### ✅ 文字颜色
- [ ] **主标题**`gray.100`(浅灰,不是纯白)
- [ ] **副文本**`gray.300`(更淡的灰)
- [ ] **元信息**`gray.500`(中等灰)
#### ✅ 图标颜色
- [ ] 图标应该是 `.300` 色阶(柔和但清晰)
- [ ] 不同类型有不同颜色
#### ✅ 边框
- [ ] 边框清晰可见(`.400` 色阶)
- [ ] 保持类型区分
#### ✅ Hover 效果
- [ ] 鼠标悬停时背景加深25% 透明度)
- [ ] 有平滑过渡动画
---
## 🎨 视觉对比
### 亮色模式
```
┌─────────────────────────────────┐
│ 🔵 蓝色浅背景 (blue.50) │
│ 深色文字 (gray.800) │
│ 明亮图标 (blue.500) │
│ 边框清晰 (blue.400) │
└─────────────────────────────────┘
```
### 暗色模式(修改后)
```
┌─────────────────────────────────┐
│ 🔵 半透明蓝背景 (15% opacity) │
│ 浅灰文字 (gray.100) │
│ 柔和图标 (blue.300) │
│ 边框可见 (blue.400) │
└─────────────────────────────────┘
```
---
## 📋 各类型通知配色
### 公告通知(蓝色)
- **亮色**`blue.50` 背景
- **暗色**`rgba(59, 130, 246, 0.15)` 半透明蓝
### 股票涨(红色)
- **亮色**`red.50` 背景
- **暗色**`rgba(239, 68, 68, 0.15)` 半透明红
### 股票跌(绿色)
- **亮色**`green.50` 背景
- **暗色**`rgba(34, 197, 94, 0.15)` 半透明绿
### 事件动向(橙色)
- **亮色**`orange.50` 背景
- **暗色**`rgba(249, 115, 22, 0.15)` 半透明橙
### 分析报告(紫色)
- **亮色**`purple.50` 背景
- **暗色**`rgba(168, 85, 247, 0.15)` 半透明紫
---
## 🔍 在浏览器控制台测试
### 手动触发各类型通知
```javascript
// 引入服务
import { mockSocketService } from './services/mockSocketService.js';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from './constants/notificationTypes.js';
// 测试公告通知(蓝色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '测试公告通知',
content: '这是暗色模式下的蓝色通知',
timestamp: Date.now(),
autoClose: 0,
});
// 测试股票上涨(红色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.URGENT,
title: '测试股票上涨',
content: '宁德时代 +5.2%',
extra: { priceChange: '+5.2%' },
timestamp: Date.now(),
autoClose: 0,
});
// 测试股票下跌(绿色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '测试股票下跌',
content: '比亚迪 -3.8%',
extra: { priceChange: '-3.8%' },
timestamp: Date.now(),
autoClose: 0,
});
// 测试事件动向(橙色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '测试事件动向',
content: '央行宣布降准',
timestamp: Date.now(),
autoClose: 0,
});
// 测试分析报告(紫色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '测试分析报告',
content: '医药行业深度报告',
timestamp: Date.now(),
autoClose: 0,
});
```
---
## 🐛 常见问题
### Q: 暗色模式下还是很深?
**A:** 检查配置是否正确应用:
1. 清除浏览器缓存并刷新
2. 确认 `notificationTypes.js` 包含 `darkBg` 等配置
3. 在控制台查看元素的实际 `background` 值
### Q: 不同类型看起来都一样?
**A:** 确认:
1. 透明度配置是否生效(应该看到半透明效果)
2. 不同类型的 RGB 值是否不同
3. 浏览器是否支持 `rgba()` 颜色
### Q: 文字看不清?
**A:** 调整文字颜色:
- 主标题:`gray.100`(可调整为 `gray.50` 或 `white`
- 如果背景太淡可以增加透明度15% → 20%
### Q: 如何微调透明度?
**A:** 在 `notificationTypes.js` 中修改 `rgba()` 的第 4 个参数:
```javascript
darkBg: 'rgba(59, 130, 246, 0.20)', // 从 0.15 改为 0.20
```
---
## 🎯 预期效果截图对比
### 亮色模式下的通知
- 背景明亮(.50 色阶)
- 文字深色gray.800
- 图标鲜艳(.500 色阶)
### 暗色模式下的通知
- 背景半透明15% 透明度)
- 文字浅色gray.100
- 图标柔和(.300 色阶)
- **保持类型区分度**
---
## 📊 技术参数
### 透明度参数
| 状态 | 透明度 | 说明 |
|-----|-------|------|
| 默认 | 15% | 背景色 |
| Hover | 25% | 鼠标悬停 |
### 色阶选择
| 元素 | 亮色 | 暗色 | 原因 |
|-----|------|------|------|
| 背景 | .50 | rgba 15% | 保持通透感 |
| 边框 | .400 | .400 | 确保可见 |
| 图标 | .500 | .300 | 降低饱和度 |
| 文字 | .800 | .100 | 保持对比度 |
---
## ✅ 测试检查清单
- [ ] 亮色模式下通知正常显示
- [ ] 暗色模式下通知半透明效果
- [ ] 5 种类型(蓝、红、绿、橙、紫)区分清晰
- [ ] 文字在暗色背景上可读性良好
- [ ] 图标颜色柔和但醒目
- [ ] Hover 效果明显
- [ ] 边框清晰可见
- [ ] 亮色/暗色切换平滑
---
## 🚀 如果需要调整
如果效果不满意,可以调整以下参数:
### 调整透明度(`notificationTypes.js`
```javascript
// 增加对比度(背景更明显)
darkBg: 'rgba(59, 130, 246, 0.25)', // 15% → 25%
// 减少对比度(更柔和)
darkBg: 'rgba(59, 130, 246, 0.10)', // 15% → 10%
```
### 调整文字颜色(`NotificationContainer/index.js`
```javascript
// 更亮的文字
const textColor = useColorModeValue('gray.800', 'gray.50'); // gray.100 → gray.50
// 更柔和的文字
const textColor = useColorModeValue('gray.800', 'gray.200'); // gray.100 → gray.200
```
---
**测试完成后,请反馈效果!** 🎉

626
ENHANCED_FEATURES_GUIDE.md Normal file
View File

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

View File

@@ -0,0 +1,370 @@
# 消息推送系统整合 - 测试指南
## 📋 整合完成清单
**统一事件名称**
- 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/mockSocketService.js` - Mock Socket 服务
- `src/services/socketService.js` - 真实 Socket.IO 服务
- `src/services/socket/index.js` - 统一导出
- `src/contexts/NotificationContext.js` - 通知上下文(含适配器)
- `src/hooks/useEventNotifications.js` - React Hook
- `src/views/Community/components/EventList.js` - 事件列表集成
### 数据流
```
后端创建事件
后端轮询检测30秒
Socket.IO 推送 new_event
前端 socketService 接收
NotificationContext 监听并适配
同时触发:
├─ NotificationContainer右下角卡片
└─ EventList onNewEventToast + 列表更新)
```
---
## ✅ 整合完成
所有代码和功能已经就绪!你现在可以:
1. ✅ 在 Mock 模式下测试实时推送
2. ✅ 在 Real 模式下连接后端
3. ✅ 查看右下角通知卡片
4. ✅ 体验事件列表实时更新
5. ✅ 随时切换 Mock/Real 模式
**祝测试顺利!🎉**

View File

@@ -0,0 +1,695 @@
# 个人中心 Mock 数据补充文档
> **补充日期**: 2025-01-19
> **补充范围**: 个人中心 (`/home/center`) 页面所需的全部 Mock 数据和 API
> **补充目标**: 完善 Mock 数据,支持个人中心页面在开发环境下完整运行
---
## 📋 目录
- [1. 业务逻辑梳理](#1-业务逻辑梳理)
- [2. API 接口清单](#2-api-接口清单)
- [3. Mock 数据结构](#3-mock-数据结构)
- [4. 实施内容](#4-实施内容)
- [5. 测试验证](#5-测试验证)
- [6. 附录](#6-附录)
---
## 1. 业务逻辑梳理
### 1.1 个人中心核心功能
个人中心 (`src/views/Dashboard/Center.js`) 是用户的核心控制面板包含以下6大功能模块
| 功能模块 | 描述 | 核心价值 |
|---------|------|---------|
| **自选股管理** | 添加/查看/删除自选股,查看实时行情 | 快速追踪关注股票的动态 |
| **事件关注** | 关注的热点事件列表,查看事件详情 | 掌握市场热点和投资机会 |
| **我的评论** | 用户在各个事件下的评论历史 | 回顾自己的观点和判断 |
| **订阅信息** | 用户会员状态、剩余天数、功能权限 | 管理订阅和升级服务 |
| **投资日历** | 用户自定义的投资相关日程事件 | 规划投资时间线 |
| **投资计划与复盘** | 投资计划和复盘记录的CRUD | 系统化投资管理 |
### 1.2 页面数据加载流程
```
页面加载
并行请求4个APIPromise.all
├─ GET /api/account/watchlist → 自选股列表
├─ GET /api/account/events/following → 关注事件
├─ GET /api/account/events/comments → 我的评论
└─ GET /api/subscription/current → 订阅信息
如果有自选股,加载实时行情
└─ GET /api/account/watchlist/realtime → 实时行情数据
子组件加载自己的数据
├─ InvestmentCalendarChakra
│ └─ GET /api/account/calendar/events → 日历事件
└─ InvestmentPlansAndReviews
└─ GET /api/account/investment-plans → 投资计划
```
### 1.3 用户交互流程
#### 自选股操作
```
查看自选股 → 点击刷新 → 更新实时行情
点击股票 → 跳转到个股详情页
点击添加 → 跳转到股票搜索页
点击删除 → DELETE /api/account/watchlist/:id
```
#### 投资计划操作
```
查看计划列表
点击新增 → 填写表单 → POST /api/account/investment-plans
点击编辑 → 修改内容 → PUT /api/account/investment-plans/:id
点击删除 → DELETE /api/account/investment-plans/:id
```
#### 日历事件操作
```
查看日历(月视图)
选择日期 → 查看当天事件
点击新增 → 填写表单 → POST /api/account/calendar/events
点击事件 → 查看详情 → 编辑/删除
PUT /api/account/calendar/events/:id
DELETE /api/account/calendar/events/:id
```
---
## 2. API 接口清单
### 2.1 接口总览
共实现 **20 个** Mock API 接口,覆盖个人中心的所有功能需求。
| 分类 | 接口数量 | 说明 |
|-----|---------|------|
| 用户资料 | 3 | 资料完整度、获取/更新资料 |
| 自选股管理 | 4 | 获取列表、实时行情、添加、删除 |
| 事件关注 | 2 | 获取关注事件、我的评论 |
| 投资计划 | 4 | 获取、创建、更新、删除 |
| 投资日历 | 4 | 获取、创建、更新、删除 |
| 订阅信息 | 3 | 订阅信息、当前订阅、权限列表 |
### 2.2 详细接口列表
#### 用户资料管理
| # | 方法 | 路径 | 描述 | 返回数据 |
|---|------|------|------|---------|
| 1 | GET | `/api/account/profile-completeness` | 获取资料完整度 | 完整度百分比、缺失项 |
| 2 | PUT | `/api/account/profile` | 更新用户资料 | 更新后的用户对象 |
| 3 | GET | `/api/account/profile` | 获取用户资料 | 用户对象 |
#### 自选股管理
| # | 方法 | 路径 | 描述 | 返回数据 |
|---|------|------|------|---------|
| 4 | GET | `/api/account/watchlist` | 获取自选股列表 | 自选股数组 |
| 5 | GET | `/api/account/watchlist/realtime` | 获取实时行情 | 行情数据数组 |
| 6 | POST | `/api/account/watchlist/add` | 添加自选股 | 新添加的自选股对象 |
| 7 | DELETE | `/api/account/watchlist/:id` | 删除自选股 | 成功消息 |
#### 事件关注管理
| # | 方法 | 路径 | 描述 | 返回数据 |
|---|------|------|------|---------|
| 8 | GET | `/api/account/events/following` | 获取关注的事件 | 事件数组 |
| 9 | GET | `/api/account/events/comments` | 获取我的评论 | 评论数组 |
#### 投资计划与复盘
| # | 方法 | 路径 | 描述 | 返回数据 |
|---|------|------|------|---------|
| 10 | GET | `/api/account/investment-plans` | 获取投资计划列表 | 计划数组 |
| 11 | POST | `/api/account/investment-plans` | 创建投资计划 | 新创建的计划对象 |
| 12 | PUT | `/api/account/investment-plans/:id` | 更新投资计划 | 更新后的计划对象 |
| 13 | DELETE | `/api/account/investment-plans/:id` | 删除投资计划 | 成功消息 |
#### 投资日历
| # | 方法 | 路径 | 描述 | 返回数据 |
|---|------|------|------|---------|
| 14 | GET | `/api/account/calendar/events` | 获取日历事件 | 事件数组(支持日期范围过滤) |
| 15 | POST | `/api/account/calendar/events` | 创建日历事件 | 新创建的事件对象 |
| 16 | PUT | `/api/account/calendar/events/:id` | 更新日历事件 | 更新后的事件对象 |
| 17 | DELETE | `/api/account/calendar/events/:id` | 删除日历事件 | 成功消息 |
#### 订阅信息
| # | 方法 | 路径 | 描述 | 返回数据 |
|---|------|------|------|---------|
| 18 | GET | `/api/subscription/info` | 获取订阅信息 | 订阅类型、状态、剩余天数 |
| 19 | GET | `/api/subscription/current` | 获取当前订阅详情 | 详细的订阅信息 |
| 20 | GET | `/api/subscription/permissions` | 获取订阅权限 | 功能权限列表 |
---
## 3. Mock 数据结构
### 3.1 自选股数据 (Watchlist)
```javascript
{
id: 1, // 自选股ID
user_id: 1, // 用户ID
stock_code: "600519.SH", // 股票代码
stock_name: "贵州茅台", // 股票名称
industry: "白酒", // 所属行业
current_price: 1650.50, // 当前价格
change_percent: 2.5, // 涨跌幅(%)
added_at: "2025-01-10T10:30:00Z" // 添加时间
}
```
**Mock 数据数量**: 5 只股票
- 贵州茅台 (600519.SH)
- 平安银行 (000001.SZ)
- 五粮液 (000858.SZ)
- 宁德时代 (300750.SZ)
- BYD比亚迪 (002594.SZ)
### 3.2 实时行情数据 (Realtime Quotes)
```javascript
{
stock_code: "600519.SH", // 股票代码
current_price: 1650.50, // 当前价格
change_percent: 2.5, // 涨跌幅(%)
change: 40.25, // 涨跌额
volume: 2345678, // 成交量
turnover: 3945678901.23, // 成交额
high: 1665.00, // 最高价
low: 1645.00, // 最低价
open: 1648.80, // 开盘价
prev_close: 1610.25, // 昨收价
update_time: "15:00:00" // 更新时间
}
```
**Mock 数据数量**: 5 只股票的实时行情
### 3.3 关注事件数据 (Following Events)
```javascript
{
id: 101, // 事件ID
title: "央行宣布降准0.5个百分点...", // 事件标题
tags: ["货币政策", "央行", "降准", "银行"], // 标签
view_count: 12340, // 浏览数
comment_count: 156, // 评论数
upvote_count: 489, // 点赞数
heat_score: 95, // 热度分数
exceed_expectation_score: 85, // 超预期分数
creator: { // 创建者
id: 1001,
username: "财经分析师",
avatar_url: "https://i.pravatar.cc/150?img=11"
},
created_at: "2025-01-15T09:00:00Z", // 创建时间
followed_at: "2025-01-15T10:30:00Z" // 关注时间
}
```
**Mock 数据数量**: 5 个热点事件
- 央行降准
- ChatGPT-5 发布
- 新能源补贴政策
- 芯片法案
- 医保目录调整
### 3.4 评论数据 (Comments)
```javascript
{
id: 201, // 评论ID
user_id: 1, // 用户ID
event_id: 101, // 关联事件ID
event_title: "央行宣布降准0.5个百分点...", // 事件标题
content: "这次降准对银行股是重大利好!...", // 评论内容
created_at: "2025-01-15T11:20:00Z", // 评论时间
likes: 45, // 点赞数
replies: 12 // 回复数
}
```
**Mock 数据数量**: 5 条评论
### 3.5 投资计划数据 (Investment Plans)
```javascript
{
id: 301, // 计划ID
user_id: 1, // 用户ID
type: "plan", // 类型: plan | review
title: "2025年Q1 新能源板块布局计划", // 标题
content: "计划在Q1分批建仓新能源板块...", // 内容支持Markdown
target_date: "2025-03-31", // 目标日期
status: "in_progress", // 状态: pending | in_progress | completed | cancelled
created_at: "2025-01-10T10:00:00Z", // 创建时间
updated_at: "2025-01-15T14:30:00Z", // 更新时间
tags: ["新能源", "布局计划", "Q1计划"] // 标签
}
```
**Mock 数据数量**: 4 条记录
- 2 条计划 (plan)
- 2 条复盘 (review)
### 3.6 日历事件数据 (Calendar Events)
```javascript
{
id: 401, // 事件ID
user_id: 1, // 用户ID
title: "贵州茅台年报披露", // 事件标题
date: "2025-03-28", // 事件日期
type: "earnings", // 类型: earnings | policy | reminder | custom
category: "financial_report", // 分类: financial_report | macro_policy | trading | investment | review
description: "关注营收和净利润增速...", // 描述
stock_code: "600519.SH", // 关联股票代码(可选)
stock_name: "贵州茅台", // 关联股票名称(可选)
importance: "high", // 重要性: low | medium | high
is_recurring: false, // 是否重复
recurrence_rule: null, // 重复规则: daily | weekly | monthly可选
created_at: "2025-01-10T10:00:00Z" // 创建时间
}
```
**Mock 数据数量**: 7 个日历事件
- 2 个财报事件
- 2 个政策事件
- 3 个提醒事件(含重复事件)
### 3.7 订阅信息数据 (Subscription)
```javascript
{
type: "pro", // 订阅类型: free | pro | max
status: "active", // 状态: active | expired | cancelled
is_active: true, // 是否激活
days_left: 90, // 剩余天数
end_date: "2025-04-15T23:59:59Z", // 到期时间
plan_name: "Pro版", // 套餐名称
features: [ // 功能列表
"无限事件查看",
"实时行情推送",
"专业分析报告",
...
],
price: 0.01, // 价格
currency: "CNY", // 货币
billing_cycle: "monthly", // 计费周期: monthly | quarterly | yearly
auto_renew: true, // 自动续费
next_billing_date: "2025-02-15T00:00:00Z" // 下次扣费日期
}
```
---
## 4. 实施内容
### 4.1 创建的文件
#### 1. `src/mocks/data/account.js` (新建)
**文件作用**: 存储个人中心相关的所有 Mock 数据
**包含内容**:
- `mockWatchlist` - 自选股数据 (5条)
- `mockRealtimeQuotes` - 实时行情数据 (5条)
- `mockFollowingEvents` - 关注事件数据 (5条)
- `mockEventComments` - 评论数据 (5条)
- `mockInvestmentPlans` - 投资计划数据 (4条)
- `mockCalendarEvents` - 日历事件数据 (7条)
- `mockSubscriptionCurrent` - 订阅详情数据 (1条)
**辅助函数**:
```javascript
// 根据用户ID获取数据
getWatchlistByUserId(userId)
getFollowingEventsByUserId(userId)
getCommentsByUserId(userId)
getInvestmentPlansByUserId(userId)
getCalendarEventsByUserId(userId)
// 根据日期范围获取日历事件
getCalendarEventsByDateRange(userId, startDate, endDate)
```
**文件大小**: 约 550 行代码
#### 2. `src/mocks/handlers/account.js` (完全重写)
**文件作用**: 处理个人中心相关的所有 API 请求
**包含内容**: 20 个 API Handler
**主要改动**:
- ✅ 保留原有的用户资料管理接口 (3个)
- ✅ 完善自选股管理接口 (4个)
- ✅ 完善事件关注接口 (2个)
-**新增** 投资计划接口 (4个)
-**新增** 投资日历接口 (4个)
- ✅ 完善订阅信息接口 (3个)
**文件大小**: 660 行代码(从原 542 行扩展到 660 行)
### 4.2 修改的文件
#### `src/mocks/handlers/index.js` (无需修改)
**检查结果**: ✅ 已正确导入和导出 `accountHandlers`
```javascript
import { accountHandlers } from './account';
export const handlers = [
...authHandlers,
...accountHandlers, // ✅ 已包含
...simulationHandlers,
...eventHandlers,
];
```
### 4.3 Mock 数据特点
#### 数据真实性
- ✅ 使用真实的股票代码和名称
- ✅ 价格和涨跌幅符合市场规律
- ✅ 事件标题和内容贴近实际热点
- ✅ 日期时间合理分布
#### 数据关联性
- ✅ 评论关联到对应的事件
- ✅ 日历事件关联到对应的股票
- ✅ 实时行情对应自选股列表
- ✅ 订阅类型影响权限配置
#### 数据可扩展性
- ✅ 支持动态添加/删除数据
- ✅ 数据结构预留扩展字段
- ✅ 辅助函数便于数据查询
- ✅ 支持日期范围过滤
---
## 5. 测试验证
### 5.1 功能测试清单
#### 个人中心页面加载
| 测试项 | 测试步骤 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **页面初始加载** | 1. 登录系统<br>2. 访问 `/home/center` | 页面正常加载,显示所有板块 | ⬜ |
| **统计卡片显示** | 查看顶部4个统计卡片 | 显示:自选股(5)、关注事件(5)、我的评论(5)、订阅状态(Pro版) | ⬜ |
| **自选股列表** | 查看自选股板块 | 显示5只股票包含股票代码、名称、价格、涨跌幅 | ⬜ |
| **实时行情** | 等待实时行情加载 | 股票价格显示,涨跌幅有颜色标识(红涨绿跌) | ⬜ |
| **关注事件列表** | 查看关注事件板块 | 显示5个事件包含标题、标签、统计数据、热度分数 | ⬜ |
| **我的评论列表** | 查看我的评论板块 | 显示5条评论包含内容、时间、关联事件 | ⬜ |
| **订阅信息卡片** | 查看订阅管理板块 | 显示Pro版剩余90天状态正常 | ⬜ |
#### 自选股功能
| 测试项 | 测试步骤 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **查看自选股详情** | 点击任一自选股 | 跳转到个股详情页 | ⬜ |
| **刷新实时行情** | 点击刷新按钮 | 显示Loading刷新完成后更新价格数据 | ⬜ |
| **自动刷新行情** | 等待60秒 | 自动刷新实时行情(每分钟一次) | ⬜ |
#### 投资计划功能
| 测试项 | 测试步骤 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **查看投资计划** | 滚动到投资计划板块 | 显示4条记录2个计划 + 2个复盘 | ⬜ |
| **创建计划** | 1. 点击"新增计划"<br>2. 填写表单<br>3. 提交 | 计划创建成功,列表刷新 | ⬜ |
| **编辑计划** | 1. 点击编辑按钮<br>2. 修改内容<br>3. 保存 | 计划更新成功,显示更新后的内容 | ⬜ |
| **删除计划** | 1. 点击删除按钮<br>2. 确认删除 | 计划删除成功,列表刷新 | ⬜ |
| **计划状态切换** | 切换计划状态(待进行/进行中/已完成) | 状态更新成功,显示对应标识 | ⬜ |
#### 投资日历功能
| 测试项 | 测试步骤 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **查看日历** | 查看投资日历板块 | 显示月视图,标记有事件的日期 | ⬜ |
| **查看事件** | 点击有事件的日期 | 显示当天的事件列表(支持多个事件) | ⬜ |
| **创建事件** | 1. 选择日期<br>2. 点击"添加事件"<br>3. 填写表单<br>4. 提交 | 事件创建成功,日历更新 | ⬜ |
| **编辑事件** | 1. 点击事件<br>2. 修改信息<br>3. 保存 | 事件更新成功 | ⬜ |
| **删除事件** | 1. 点击事件<br>2. 点击删除<br>3. 确认 | 事件删除成功,日历更新 | ⬜ |
| **重复事件** | 创建一个重复事件如每月20日 | 日历上多个日期显示该事件 | ⬜ |
#### 订阅管理功能
| 测试项 | 测试步骤 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **查看订阅详情** | 点击订阅卡片 | 跳转到订阅管理页面 | ⬜ |
| **订阅权限检查** | 访问需要权限的功能 | Pro用户可访问Free用户提示升级 | ⬜ |
### 5.2 数据一致性测试
| 测试项 | 验证方法 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **自选股与行情匹配** | 对比自选股列表和实时行情 | 每只自选股都有对应的行情数据 | ⬜ |
| **评论与事件关联** | 点击评论中的事件链接 | 能正确跳转到对应事件 | ⬜ |
| **日历事件与股票关联** | 查看带股票代码的日历事件 | 点击能跳转到对应股票详情 | ⬜ |
| **订阅类型一致性** | 对比多处显示的订阅类型 | 统计卡片、订阅管理、权限检查一致 | ⬜ |
### 5.3 边界情况测试
| 测试项 | 测试步骤 | 预期结果 | 状态 |
|-------|---------|---------|-----|
| **空数据状态** | 1. 清空所有自选股<br>2. 刷新页面 | 显示"暂无自选股"提示,引导添加 | ⬜ |
| **网络延迟** | 模拟慢速网络 | 显示Loading状态300ms后加载完成 | ⬜ |
| **未登录状态** | 未登录访问个人中心 | 返回401错误被ProtectedRoute拦截 | ⬜ |
| **大数据量** | 添加10+只自选股 | 前端只显示前10只其他可查看全部 | ⬜ |
| **日期范围查询** | 查询特定月份的日历事件 | 只返回该月份的事件 | ⬜ |
---
## 6. 附录
### 6.1 API 请求示例
#### 获取自选股列表
```javascript
// 请求
GET /api/account/watchlist
// 响应
{
"success": true,
"data": [
{
"id": 1,
"user_id": 1,
"stock_code": "600519.SH",
"stock_name": "贵州茅台",
"industry": "白酒",
"current_price": 1650.50,
"change_percent": 2.5,
"added_at": "2025-01-10T10:30:00Z"
},
...
]
}
```
#### 创建投资计划
```javascript
// 请求
POST /api/account/investment-plans
Content-Type: application/json
{
"type": "plan",
"title": "2025年Q1 新能源板块布局计划",
"content": "计划在Q1分批建仓新能源板块...",
"target_date": "2025-03-31",
"status": "pending",
"tags": ["新能源", "布局计划"]
}
// 响应
{
"success": true,
"message": "创建成功",
"data": {
"id": 305,
"user_id": 1,
"type": "plan",
"title": "2025年Q1 新能源板块布局计划",
"content": "计划在Q1分批建仓新能源板块...",
"target_date": "2025-03-31",
"status": "pending",
"tags": ["新能源", "布局计划"],
"created_at": "2025-01-19T10:00:00Z",
"updated_at": "2025-01-19T10:00:00Z"
}
}
```
#### 获取日历事件(日期范围)
```javascript
// 请求
GET /api/account/calendar/events?start_date=2025-01-01&end_date=2025-01-31
// 响应
{
"success": true,
"data": [
{
"id": 403,
"user_id": 1,
"title": "央行货币政策委员会例会",
"date": "2025-01-25",
"type": "policy",
"category": "macro_policy",
"importance": "medium",
"created_at": "2025-01-08T09:00:00Z"
},
...
]
}
```
### 6.2 数据模型 ER 图
```
User (用户)
├─ 1:N → Watchlist (自选股)
├─ 1:N → FollowingEvents (关注事件)
├─ 1:N → EventComments (评论)
├─ 1:N → InvestmentPlans (投资计划)
├─ 1:N → CalendarEvents (日历事件)
└─ 1:1 → Subscription (订阅信息)
Event (事件)
├─ 1:N → EventComments (评论)
└─ N:N → Users (关注用户)
Stock (股票)
├─ 1:N → Watchlist (自选股)
├─ 1:1 → RealtimeQuote (实时行情)
└─ 1:N → CalendarEvents (日历事件)
```
### 6.3 Mock 数据统计
| 数据类型 | 数量 | 字段数 | 总大小(估算) |
|---------|-----|--------|--------------|
| 自选股 | 5 | 8 | 约 0.5KB |
| 实时行情 | 5 | 11 | 约 0.8KB |
| 关注事件 | 5 | 10 | 约 2KB |
| 评论 | 5 | 8 | 约 1.5KB |
| 投资计划 | 4 | 10 | 约 3KB |
| 日历事件 | 7 | 12 | 约 1.5KB |
| **总计** | **31** | **59** | **约 9.3KB** |
### 6.4 前端组件映射
| 前端组件 | 使用的 API | Mock 数据来源 |
|---------|-----------|-------------|
| `Center.js` (主组件) | 4个并行API | `mockWatchlist`, `mockFollowingEvents`, `mockEventComments`, `mockSubscriptionCurrent` |
| 自选股卡片 | `/api/account/watchlist` | `mockWatchlist` |
| 实时行情刷新 | `/api/account/watchlist/realtime` | `mockRealtimeQuotes` |
| 关注事件列表 | `/api/account/events/following` | `mockFollowingEvents` |
| 我的评论列表 | `/api/account/events/comments` | `mockEventComments` |
| 订阅信息卡片 | `/api/subscription/current` | `mockSubscriptionCurrent` |
| `InvestmentCalendarChakra.js` | `/api/account/calendar/events` | `mockCalendarEvents` |
| `InvestmentPlansAndReviews.js` | `/api/account/investment-plans` | `mockInvestmentPlans` |
### 6.5 常见问题 (FAQ)
**Q1: Mock 数据会持久化吗?**
A: 不会。Mock 数据存储在内存中,刷新页面后会重置。如果需要持久化,可以考虑使用 localStorage。
**Q2: 如何切换到真实 API**
A: 在 `.env` 文件中设置 `REACT_APP_ENABLE_MOCK=false` 即可切换到真实 API。
**Q3: Mock 数据支持多用户吗?**
A: 目前的 Mock 数据基于当前登录用户(`getCurrentUser()`),支持基本的多用户场景。
**Q4: 实时行情数据是真的实时吗?**
A: Mock 模式下不是真实的实时数据只是静态数据。真实环境下需要对接WebSocket或轮询API。
**Q5: 如何添加更多 Mock 数据?**
A: 编辑 `src/mocks/data/account.js`,在对应的数组中添加新的数据对象即可。
### 6.6 后续优化建议
#### 短期优化1周内
- [ ] 添加更多股票到自选股池目前5只 → 10只
- [ ] 丰富事件类型和标签
- [ ] 完善投资计划的标签系统
- [ ] 添加日历事件的提醒功能Mock
#### 中期优化1月内
- [ ] 实现 Mock 数据的 localStorage 持久化
- [ ] 添加数据导入/导出功能
- [ ] 模拟网络波动和错误场景
- [ ] 添加更多的边界测试用例
#### 长期优化3月内
- [ ] 实现完整的 Mock 数据生成器
- [ ] 支持批量生成测试数据
- [ ] 添加数据一致性校验工具
- [ ] 完善 Mock 数据文档和最佳实践
---
## ✅ 总结
### 完成内容
- ✅ 创建完整的 Mock 数据文件 (`src/mocks/data/account.js`)
- ✅ 重写并扩展 Mock Handler (`src/mocks/handlers/account.js`)
- ✅ 实现 20 个 API 接口的 Mock
- ✅ 提供 31 条 Mock 数据记录
- ✅ 验证 handlers/index.js 配置正确
### 覆盖功能
- ✅ 自选股管理(查看、添加、删除、实时行情)
- ✅ 事件关注(关注列表、我的评论)
- ✅ 投资计划(增删改查、计划与复盘)
- ✅ 投资日历(增删改查、日期范围查询)
- ✅ 订阅信息(订阅详情、权限管理)
- ✅ 用户资料(资料完整度、更新资料)
### 数据质量
- ✅ 数据真实性:使用真实股票和合理价格
- ✅ 数据关联性:评论关联事件、日历关联股票
- ✅ 数据可扩展性:预留字段、支持动态操作
- ✅ 数据完整性:包含所有必需字段
### 测试准备
- ✅ 提供完整的测试用例清单
- ✅ 覆盖功能、数据一致性、边界测试
- ✅ 包含42个测试项
- ✅ 提供测试步骤和预期结果
---
**文档版本**: 1.0
**生成日期**: 2025-01-19
**维护者**: Development Team
**相关文档**:
- `CONSOLE_LOG_REFACTOR_REPORT.md` - Console Log 重构文档
- `LOGIN_MODAL_REFACTOR_PLAN.md` - 登录弹窗改造计划

View File

@@ -0,0 +1,280 @@
# 消息推送系统优化总结
## 优化目标
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 错误

1551
NOTIFICATION_SYSTEM.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,546 @@
# WebSocket 事件实时推送 - 前端集成指南
## 📦 已创建的文件
1. **`src/services/socketService.js`** - WebSocket 服务(已扩展)
2. **`src/hooks/useEventNotifications.js`** - React Hook
3. **`test_websocket.html`** - 测试页面
4. **`test_create_event.py`** - 测试脚本
---
## 🚀 快速开始
### 方案 1使用 React Hook推荐
在任何 React 组件中使用:
```jsx
import { useEventNotifications } from 'hooks/useEventNotifications';
import { useToast } from '@chakra-ui/react';
function EventsPage() {
const toast = useToast();
// 订阅事件推送
const { newEvent, isConnected } = useEventNotifications({
eventType: 'all', // 'all' | 'policy' | 'market' | 'tech' | ...
importance: 'all', // 'all' | 'S' | 'A' | 'B' | 'C'
enabled: true, // 是否启用订阅
onNewEvent: (event) => {
// 收到新事件时的处理
console.log('🔔 收到新事件:', event);
// 显示 Toast 通知
toast({
title: '新事件提醒',
description: event.title,
status: 'info',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
});
return (
<Box>
<Text>连接状态: {isConnected ? '已连接 ✅' : '未连接 ❌'}</Text>
{/* 你的事件列表 */}
</Box>
);
}
```
---
### 方案 2在事件列表页面集成完整示例
**在 `src/views/Community/components/EventList.js` 中集成:**
```jsx
import React, { useState, useEffect } from 'react';
import { Box, Text, Badge, useToast } from '@chakra-ui/react';
import { useEventNotifications } from 'hooks/useEventNotifications';
function EventList() {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const toast = useToast();
// 1⃣ 初始加载事件列表REST API
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await fetch('/api/events?per_page=20');
const data = await response.json();
if (data.success) {
setEvents(data.data.events);
}
} catch (error) {
console.error('加载事件失败:', error);
} finally {
setLoading(false);
}
};
// 2⃣ 订阅 WebSocket 实时推送
const { newEvent, isConnected } = useEventNotifications({
eventType: 'all',
importance: 'all',
enabled: true, // 可以根据用户设置控制是否启用
onNewEvent: (event) => {
console.log('🔔 收到新事件:', event);
// 显示通知
toast({
title: '📰 新事件发布',
description: `${event.title}`,
status: 'info',
duration: 6000,
isClosable: true,
position: 'top-right',
});
// 将新事件添加到列表顶部
setEvents((prevEvents) => {
// 检查是否已存在(防止重复)
const exists = prevEvents.some(e => e.id === event.id);
if (exists) {
return prevEvents;
}
// 添加到顶部,最多保留 100 个
return [event, ...prevEvents].slice(0, 100);
});
}
});
return (
<Box>
{/* 连接状态指示器 */}
<Box mb={4} display="flex" alignItems="center" gap={2}>
<Badge colorScheme={isConnected ? 'green' : 'red'}>
{isConnected ? '实时推送已开启' : '实时推送未连接'}
</Badge>
</Box>
{/* 事件列表 */}
{loading ? (
<Text>加载中...</Text>
) : (
<Box>
{events.map((event) => (
<EventCard key={event.id} event={event} />
))}
</Box>
)}
</Box>
);
}
export default EventList;
```
---
### 方案 3只订阅重要事件S 和 A 级)
```jsx
import { useImportantEventNotifications } from 'hooks/useEventNotifications';
function Dashboard() {
const { importantEvents, isConnected } = useImportantEventNotifications((event) => {
// 只会收到 S 和 A 级别的重要事件
console.log('⚠️ 重要事件:', event);
// 播放提示音
new Audio('/notification.mp3').play();
});
return (
<Box>
<Heading>重要事件通知</Heading>
{importantEvents.map(event => (
<Alert key={event.id} status="warning">
<AlertIcon />
{event.title}
</Alert>
))}
</Box>
);
}
```
---
### 方案 4直接使用 Service不用 Hook
```jsx
import { useEffect } from 'react';
import socketService from 'services/socketService';
function MyComponent() {
useEffect(() => {
// 连接
socketService.connect();
// 订阅
const unsubscribe = socketService.subscribeToAllEvents((event) => {
console.log('新事件:', event);
});
// 清理
return () => {
unsubscribe();
socketService.disconnect();
};
}, []);
return <div>...</div>;
}
```
---
## 🎨 UI 集成示例
### 1. Toast 通知Chakra UI
```jsx
import { useToast } from '@chakra-ui/react';
const toast = useToast();
// 在 onNewEvent 回调中
onNewEvent: (event) => {
toast({
title: '新事件',
description: event.title,
status: 'info',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
```
---
### 2. 顶部通知栏
```jsx
import { Alert, AlertIcon, CloseButton } from '@chakra-ui/react';
function EventNotificationBanner() {
const [showNotification, setShowNotification] = useState(false);
const [latestEvent, setLatestEvent] = useState(null);
useEventNotifications({
eventType: 'all',
onNewEvent: (event) => {
setLatestEvent(event);
setShowNotification(true);
}
});
if (!showNotification || !latestEvent) return null;
return (
<Alert status="info" variant="solid">
<AlertIcon />
新事件{latestEvent.title}
<CloseButton
position="absolute"
right="8px"
top="8px"
onClick={() => setShowNotification(false)}
/>
</Alert>
);
}
```
---
### 3. 角标提示(红点)
```jsx
import { Badge } from '@chakra-ui/react';
function EventsMenuItem() {
const [unreadCount, setUnreadCount] = useState(0);
useEventNotifications({
eventType: 'all',
onNewEvent: () => {
setUnreadCount(prev => prev + 1);
}
});
return (
<MenuItem position="relative">
事件中心
{unreadCount > 0 && (
<Badge
colorScheme="red"
position="absolute"
top="-5px"
right="-5px"
borderRadius="full"
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
)}
</MenuItem>
);
}
```
---
### 4. 浮动通知卡片
```jsx
import { Box, Slide, useDisclosure } from '@chakra-ui/react';
function FloatingEventNotification() {
const { isOpen, onClose, onOpen } = useDisclosure();
const [event, setEvent] = useState(null);
useEventNotifications({
eventType: 'all',
onNewEvent: (newEvent) => {
setEvent(newEvent);
onOpen();
// 5秒后自动关闭
setTimeout(onClose, 5000);
}
});
return (
<Slide direction="bottom" in={isOpen} style={{ zIndex: 10 }}>
<Box
p="40px"
color="white"
bg="blue.500"
rounded="md"
shadow="md"
m={4}
>
<Text fontWeight="bold">{event?.title}</Text>
<Text fontSize="sm">{event?.description}</Text>
<Button size="sm" mt={2} onClick={onClose}>
关闭
</Button>
</Box>
</Slide>
);
}
```
---
## 📋 API 参考
### `useEventNotifications(options)`
**参数:**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `eventType` | string | `'all'` | 事件类型:`'all'` / `'policy'` / `'market'` / `'tech'` 等 |
| `importance` | string | `'all'` | 重要性:`'all'` / `'S'` / `'A'` / `'B'` / `'C'` |
| `enabled` | boolean | `true` | 是否启用订阅 |
| `onNewEvent` | function | - | 收到新事件时的回调函数 |
**返回值:**
| 属性 | 类型 | 说明 |
|------|------|------|
| `newEvent` | object | 最新收到的事件对象 |
| `isConnected` | boolean | WebSocket 连接状态 |
| `error` | object | 错误信息 |
| `clearNewEvent` | function | 清除新事件状态 |
---
### `socketService` API
```javascript
// 连接
socketService.connect(options)
// 断开
socketService.disconnect()
// 订阅所有事件
socketService.subscribeToAllEvents(callback)
// 订阅特定类型
socketService.subscribeToEventType('tech', callback)
// 订阅特定重要性
socketService.subscribeToImportantEvents('S', callback)
// 取消订阅
socketService.unsubscribeFromEvents({ eventType: 'all' })
// 检查连接状态
socketService.isConnected()
```
---
## 🔧 事件数据结构
收到的 `event` 对象包含:
```javascript
{
id: 123,
title: "事件标题",
description: "事件描述",
event_type: "tech", // 类型
importance: "S", // 重要性
status: "active",
created_at: "2025-01-21T14:30:00",
hot_score: 85.5,
view_count: 1234,
related_avg_chg: 5.2, // 平均涨幅
related_max_chg: 15.8, // 最大涨幅
keywords: ["AI", "芯片"], // 关键词
}
```
---
## ⚙️ 高级配置
### 1. 条件订阅(用户设置)
```jsx
function EventsPage() {
const [enableNotifications, setEnableNotifications] = useState(
localStorage.getItem('enableEventNotifications') === 'true'
);
useEventNotifications({
eventType: 'all',
enabled: enableNotifications, // 根据用户设置控制
onNewEvent: handleNewEvent
});
return (
<Switch
isChecked={enableNotifications}
onChange={(e) => {
const enabled = e.target.checked;
setEnableNotifications(enabled);
localStorage.setItem('enableEventNotifications', enabled);
}}
>
启用事件实时通知
</Switch>
);
}
```
---
### 2. 多个订阅(不同类型)
```jsx
function MultiSubscriptionExample() {
// 订阅科技类事件
useEventNotifications({
eventType: 'tech',
onNewEvent: (event) => console.log('科技事件:', event)
});
// 订阅政策类事件
useEventNotifications({
eventType: 'policy',
onNewEvent: (event) => console.log('政策事件:', event)
});
return <div>...</div>;
}
```
---
### 3. 防抖处理(避免通知过多)
```jsx
import { debounce } from 'lodash';
const debouncedNotify = debounce((event) => {
toast({
title: '新事件',
description: event.title,
});
}, 1000);
useEventNotifications({
eventType: 'all',
onNewEvent: debouncedNotify
});
```
---
## 🧪 测试步骤
1. **启动 Flask 服务**
```bash
python app.py
```
2. **启动 React 应用**
```bash
npm start
```
3. **创建测试事件**
```bash
python test_create_event.py
```
4. **观察结果**
- 最多等待 30 秒
- 前端页面应该显示通知
- 控制台输出日志
---
## 🐛 常见问题
### Q: 没有收到推送?
**A:** 检查:
1. Flask 服务是否启动
2. 浏览器控制台是否有连接错误
3. 后端日志是否显示 `[轮询] 发现 X 个新事件`
### Q: 连接一直失败?
**A:** 检查:
1. API_BASE_URL 配置是否正确
2. CORS 配置是否包含前端域名
3. 防火墙/代理设置
### Q: 收到重复通知?
**A:** 检查是否多次调用了 Hook确保只在需要的地方订阅一次。
---
## 📚 更多资源
- Socket.IO 文档: https://socket.io/docs/v4/
- Chakra UI Toast: https://chakra-ui.com/docs/components/toast
- React Hooks: https://react.dev/reference/react
---
**完成!🎉** 现在你的前端可以实时接收事件推送了!

728
app.py
View File

@@ -8,6 +8,7 @@ import uuid
from functools import wraps
import qrcode
from flask_mail import Mail, Message
from flask_socketio import SocketIO, emit, join_room, leave_room
import pytz
import requests
from celery import Celery
@@ -40,6 +41,7 @@ from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentClo
from sqlalchemy import text, desc, and_
import pandas as pd
from decimal import Decimal
from apscheduler.schedulers.background import BackgroundScheduler
# 交易日数据缓存
trading_days = []
@@ -242,6 +244,18 @@ db = SQLAlchemy(app)
# 初始化邮件服务
mail = Mail(app)
# 初始化 Flask-SocketIO用于实时事件推送
socketio = SocketIO(
app,
cors_allowed_origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173",
"https://valuefrontier.cn", "http://valuefrontier.cn"],
async_mode='gevent',
logger=True,
engineio_logger=False,
ping_timeout=120, # 心跳超时时间客户端120秒内无响应才断开
ping_interval=25 # 心跳检测间隔每25秒发送一次ping
)
@login_manager.user_loader
def load_user(user_id):
@@ -2123,8 +2137,10 @@ def register_with_phone():
data = request.get_json()
phone = data.get('phone')
code = data.get('code')
password = data.get('password')
username = data.get('username')
if not all([phone, code]):
if not all([phone, code, password, username]):
return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400
# 验证验证码
@@ -2135,12 +2151,14 @@ def register_with_phone():
if stored_code['code'] != code:
return jsonify({'success': False, 'error': '验证码错误'}), 400
if User.query.filter_by(phone=phone).first():
return jsonify({'success': False, 'error': '手机号已存在'}), 400
if User.query.filter_by(username=username).first():
return jsonify({'success': False, 'error': '用户名已存在'}), 400
try:
# 创建用户
user = User(username='用户', phone=phone)
user = User(username=username, phone=phone)
user.email = f"{username}@valuefrontier.temp"
user.set_password(password)
user.phone_confirmed = True
db.session.add(user)
@@ -2506,12 +2524,13 @@ def get_wechat_qrcode():
'wechat_unionid': None
}
return jsonify({'code':0,
'data':{
'auth_url': wechat_auth_url,
'session_id': state,
'expires_in': 300
}}), 200
return jsonify({"code":0,
"data":
{
'auth_url': wechat_auth_url,
'session_id': state,
'expires_in': 300
}}), 200
@app.route('/api/account/wechat/qrcode', methods=['GET'])
@@ -4004,18 +4023,31 @@ def get_related_stocks(event_id):
stocks_data = []
for stock in stocks:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': stock.relation_desc,
'retrieved_sources': stock.retrieved_sources,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None,
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
})
if stock.retrieved_sources is not None:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': {"data":stock.retrieved_sources},
'retrieved_sources': stock.retrieved_sources,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None,
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
})
else:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': stock.relation_desc,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None,
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
})
return jsonify({
'success': True,
@@ -6292,196 +6324,100 @@ def parse_json_field(field_value):
# ==================== 行业API ====================
@app.route('/api/classifications', methods=['GET'])
def get_classifications():
"""获取所有行业分类系统"""
"""获取申银万国行业分类树形结构"""
try:
# 查询申银万国行业分类的所有数据
sql = """
SELECT DISTINCT f002v as classification_name
SELECT f003v as code, f004v as level1, f005v as level2, f006v as level3,f007v as level4
FROM ea_sector
WHERE f002v NOT IN ('指数成份股', '市场分类', '概念板块', '地区省市分类', '中上协行业分类')
ORDER BY classification_name \
WHERE f002v = '申银万国行业分类'
AND f003v IS NOT NULL
AND f004v IS NOT NULL
ORDER BY f003v
"""
result = db.session.execute(text(sql)).all()
classifications = [{'name': row.classification_name} for row in result]
# 构建树形结构
tree_dict = {}
return jsonify({
'success': True,
'data': classifications
})
for row in result:
code = row.code
level1 = row.level1
level2 = row.level2
level3 = row.level3
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
# 跳过空数据
if not level1:
continue
@app.route('/api/levels', methods=['GET'])
def get_industry_levels():
"""获取行业层级数据"""
try:
classification = request.args.get('classification')
level = request.args.get('level', type=int)
level1_name = request.args.get('level1_name', '')
level2_name = request.args.get('level2_name', '')
level3_name = request.args.get('level3_name', '')
if not classification or not level or level < 1 or level > 4:
return jsonify({
'success': False,
'error': 'Invalid parameters'
}), 400
# 层级到字段的映射
level_fields = {
1: "f004v",
2: "f005v",
3: "f006v",
4: "f007v"
}
field_name = level_fields[level]
# 构建查询
if level == 1:
sql = f"""
SELECT DISTINCT {field_name} as name,
MIN(f003v) as code
FROM ea_sector
WHERE f002v = :classification
AND {field_name} IS NOT NULL
GROUP BY name
ORDER BY name
"""
params = {"classification": classification}
elif level == 2:
sql = f"""
SELECT DISTINCT {field_name} as name,
MIN(f003v) as code
FROM ea_sector
WHERE f002v = :classification
AND f004v = :level1_name
AND {field_name} IS NOT NULL
GROUP BY name
ORDER BY name
"""
params = {"classification": classification, "level1_name": level1_name}
elif level == 3:
sql = f"""
SELECT DISTINCT {field_name} as name,
MIN(f003v) as code
FROM ea_sector
WHERE f002v = :classification
AND f004v = :level1_name
AND f005v = :level2_name
AND {field_name} IS NOT NULL
GROUP BY name
ORDER BY name
"""
params = {
"classification": classification,
"level1_name": level1_name,
"level2_name": level2_name
}
elif level == 4:
sql = f"""
SELECT DISTINCT f003v as code,
{field_name} as name
FROM ea_sector
WHERE f002v = :classification
AND f004v = :level1_name
AND f005v = :level2_name
AND f006v = :level3_name
AND {field_name} IS NOT NULL
ORDER BY name
"""
params = {
"classification": classification,
"level1_name": level1_name,
"level2_name": level2_name,
"level3_name": level3_name
}
results = db.session.execute(text(sql), params).all()
industries = [{"code": row.code, "name": row.name} for row in results if row.name]
return jsonify({
'success': True,
'data': industries
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/info', methods=['GET'])
def get_industry_info():
"""获取行业详细信息"""
try:
classification = request.args.get('classification')
code = request.args.get('code')
if not classification or not code:
return jsonify({
'success': False,
'error': 'Missing parameters'
}), 400
# 根据代码长度确定字段
if len(code) >= 8:
field_name = "f007v"
elif len(code) >= 6:
field_name = "f006v"
elif len(code) >= 4:
field_name = "f005v"
else:
field_name = "f004v"
sql = f"""
SELECT {field_name} as name,
f004v as level1_name,
f005v as level2_name,
f006v as level3_name,
f007v as level4_name
FROM ea_sector
WHERE f002v = :classification
AND f003v = :code
AND {field_name} IS NOT NULL
LIMIT 1
"""
result = db.session.execute(text(sql), {
"classification": classification,
"code": code
}).first()
if not result:
return jsonify({
'success': False,
'error': 'Industry not found'
}), 404
return jsonify({
'success': True,
'data': {
'name': result.name,
'code': code,
'classification': classification,
'hierarchy': {
'level1': result.level1_name,
'level2': result.level2_name,
'level3': result.level3_name,
'level4': result.level4_name
# 第一层
if level1 not in tree_dict:
# 获取第一层的code取前3位或前缀
level1_code = code[:3] if len(code) >= 3 else code
tree_dict[level1] = {
'value': level1_code,
'label': level1,
'children_dict': {}
}
# 第二层
if level2:
if level2 not in tree_dict[level1]['children_dict']:
# 获取第二层的code取前6位
level2_code = code[:6] if len(code) >= 6 else code
tree_dict[level1]['children_dict'][level2] = {
'value': level2_code,
'label': level2,
'children_dict': {}
}
# 第三层
if level3:
if level3 not in tree_dict[level1]['children_dict'][level2]['children_dict']:
tree_dict[level1]['children_dict'][level2]['children_dict'][level3] = {
'value': code,
'label': level3
}
# 转换为最终格式
result_list = []
for level1_name, level1_data in tree_dict.items():
level1_node = {
'value': level1_data['value'],
'label': level1_data['label']
}
# 处理第二层
if level1_data['children_dict']:
level1_children = []
for level2_name, level2_data in level1_data['children_dict'].items():
level2_node = {
'value': level2_data['value'],
'label': level2_data['label']
}
# 处理第三层
if level2_data['children_dict']:
level2_children = []
for level3_name, level3_data in level2_data['children_dict'].items():
level2_children.append({
'value': level3_data['value'],
'label': level3_data['label']
})
if level2_children:
level2_node['children'] = level2_children
level1_children.append(level2_node)
if level1_children:
level1_node['children'] = level1_children
result_list.append(level1_node)
return jsonify({
'success': True,
'data': result_list
})
except Exception as e:
@@ -6491,6 +6427,29 @@ def get_industry_info():
}), 500
@app.route('/api/stocklist', methods=['GET'])
def get_stock_list():
"""获取股票列表"""
try:
sql = """
SELECT DISTINCT SECCODE as code, SECNAME as name
FROM ea_stocklist
ORDER BY SECCODE
"""
result = db.session.execute(text(sql)).all()
stocks = [{'code': row.code, 'name': row.name} for row in result]
return jsonify(stocks)
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/events', methods=['GET'], strict_slashes=False)
def api_get_events():
"""
@@ -6512,10 +6471,8 @@ def api_get_events():
date_range = request.args.get('date_range')
recent_days = request.args.get('recent_days', type=int)
# 行业筛选参数
industry_classification = request.args.get('industry_classification')
industry_code = request.args.get('industry_code')
industry_level = request.args.get('industry_level', type=int)
# 行业筛选参数(只支持申银万国行业分类)
industry_code = request.args.get('industry_code') # 申万行业代码,如 "S370502"
# 概念/标签筛选参数
tag = request.args.get('tag')
@@ -6561,12 +6518,28 @@ def api_get_events():
query = query.filter_by(importance=importance)
if creator_id:
query = query.filter_by(creator_id=creator_id)
# 新增:行业代码过滤(MySQL JSON对象数组模式
if industry_classification and industry_code:
json_path = f'$[*]."{industry_classification}"'
query = query.filter(
text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :industry_code)")
).params(json_path=json_path, industry_code=json.dumps(industry_code))
# 新增:行业代码过滤(申银万国行业分类
if industry_code:
# related_industries 格式: [{"申银万国行业分类": "S370502"}, ...]
# 支持多个行业代码,用逗号分隔
json_path = '$[*]."申银万国行业分类"'
# 如果包含逗号,说明是多个行业代码
if ',' in industry_code:
codes = [code.strip() for code in industry_code.split(',') if code.strip()]
# 使用 OR 条件匹配任意一个行业代码
conditions = []
for code in codes:
conditions.append(
text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :code)")
.bindparams(json_path=json_path, code=json.dumps(code))
)
query = query.filter(db.or_(*conditions))
else:
# 单个行业代码
query = query.filter(
text("JSON_CONTAINS(JSON_EXTRACT(related_industries, :json_path), :industry_code)")
).params(json_path=json_path, industry_code=json.dumps(industry_code))
# 新增:关键词/全文搜索过滤MySQL JSON
if search_query:
like_pattern = f"%{search_query}%"
@@ -6687,8 +6660,6 @@ def api_get_events():
applied_filters['start_date'] = start_date
if end_date:
applied_filters['end_date'] = end_date
if industry_classification:
applied_filters['industry_classification'] = industry_classification
if industry_code:
applied_filters['industry_code'] = industry_code
if tag:
@@ -7438,6 +7409,333 @@ def add_event_comment(event_id):
}), 500
# ==================== WebSocket 事件处理器(实时事件推送) ====================
@socketio.on('connect')
def handle_connect():
"""客户端连接事件"""
print(f'\n[WebSocket DEBUG] ========== 客户端连接 ==========')
print(f'[WebSocket DEBUG] Socket ID: {request.sid}')
print(f'[WebSocket DEBUG] Remote Address: {request.remote_addr if hasattr(request, "remote_addr") else "N/A"}')
print(f'[WebSocket] 客户端已连接: {request.sid}')
emit('connection_response', {
'status': 'connected',
'sid': request.sid,
'message': '已连接到事件推送服务'
})
print(f'[WebSocket DEBUG] ✓ 已发送 connection_response')
print(f'[WebSocket DEBUG] ========== 连接完成 ==========\n')
@socketio.on('subscribe_events')
def handle_subscribe(data):
"""
客户端订阅事件推送
data: {
'event_type': 'all' | 'policy' | 'market' | 'tech' | ...,
'importance': 'all' | 'S' | 'A' | 'B' | 'C',
'filters': {...} # 可选的其他筛选条件
}
"""
try:
print(f'\n[WebSocket DEBUG] ========== 收到订阅请求 ==========')
print(f'[WebSocket DEBUG] Socket ID: {request.sid}')
print(f'[WebSocket DEBUG] 订阅数据: {data}')
event_type = data.get('event_type', 'all')
importance = data.get('importance', 'all')
print(f'[WebSocket DEBUG] 事件类型: {event_type}')
print(f'[WebSocket DEBUG] 重要性: {importance}')
# 加入对应的房间
room_name = f"events_{event_type}"
print(f'[WebSocket DEBUG] 准备加入房间: {room_name}')
join_room(room_name)
print(f'[WebSocket DEBUG] ✓ 已加入房间: {room_name}')
print(f'[WebSocket] 客户端 {request.sid} 订阅了房间: {room_name}')
response_data = {
'success': True,
'room': room_name,
'event_type': event_type,
'importance': importance,
'message': f'已订阅 {event_type} 类型的事件推送'
}
print(f'[WebSocket DEBUG] 准备发送 subscription_confirmed: {response_data}')
emit('subscription_confirmed', response_data)
print(f'[WebSocket DEBUG] ✓ 已发送 subscription_confirmed')
print(f'[WebSocket DEBUG] ========== 订阅完成 ==========\n')
except Exception as e:
print(f'[WebSocket ERROR] 订阅失败: {e}')
import traceback
traceback.print_exc()
emit('subscription_error', {
'success': False,
'error': str(e)
})
@socketio.on('unsubscribe_events')
def handle_unsubscribe(data):
"""取消订阅事件推送"""
try:
print(f'\n[WebSocket DEBUG] ========== 收到取消订阅请求 ==========')
print(f'[WebSocket DEBUG] Socket ID: {request.sid}')
print(f'[WebSocket DEBUG] 数据: {data}')
event_type = data.get('event_type', 'all')
room_name = f"events_{event_type}"
print(f'[WebSocket DEBUG] 准备离开房间: {room_name}')
leave_room(room_name)
print(f'[WebSocket DEBUG] ✓ 已离开房间: {room_name}')
print(f'[WebSocket] 客户端 {request.sid} 取消订阅房间: {room_name}')
emit('unsubscription_confirmed', {
'success': True,
'room': room_name,
'message': f'已取消订阅 {event_type} 类型的事件推送'
})
print(f'[WebSocket DEBUG] ========== 取消订阅完成 ==========\n')
except Exception as e:
print(f'[WebSocket ERROR] 取消订阅失败: {e}')
import traceback
traceback.print_exc()
emit('unsubscription_error', {
'success': False,
'error': str(e)
})
@socketio.on('disconnect')
def handle_disconnect():
"""客户端断开连接事件"""
print(f'\n[WebSocket DEBUG] ========== 客户端断开 ==========')
print(f'[WebSocket DEBUG] Socket ID: {request.sid}')
print(f'[WebSocket] 客户端已断开: {request.sid}')
print(f'[WebSocket DEBUG] ========== 断开完成 ==========\n')
# ==================== WebSocket 辅助函数 ====================
def broadcast_new_event(event):
"""
广播新事件到所有订阅的客户端
在创建新事件时调用此函数
Args:
event: Event 模型实例
"""
try:
print(f'\n[WebSocket DEBUG] ========== 广播新事件 ==========')
print(f'[WebSocket DEBUG] 事件ID: {event.id}')
print(f'[WebSocket DEBUG] 事件标题: {event.title}')
print(f'[WebSocket DEBUG] 事件类型: {event.event_type}')
print(f'[WebSocket DEBUG] 重要性: {event.importance}')
event_data = {
'id': event.id,
'title': event.title,
'description': event.description,
'event_type': event.event_type,
'importance': event.importance,
'status': event.status,
'created_at': event.created_at.isoformat() if event.created_at else None,
'hot_score': event.hot_score,
'view_count': event.view_count,
'related_avg_chg': event.related_avg_chg,
'related_max_chg': event.related_max_chg,
'keywords': event.keywords_list if hasattr(event, 'keywords_list') else event.keywords,
}
print(f'[WebSocket DEBUG] 准备发送的数据: {event_data}')
# 发送到所有订阅者all 房间)
print(f'[WebSocket DEBUG] 正在发送到房间: events_all')
socketio.emit('new_event', event_data, room='events_all', namespace='/')
print(f'[WebSocket DEBUG] ✓ 已发送到 events_all')
# 发送到特定类型订阅者
if event.event_type:
room_name = f"events_{event.event_type}"
print(f'[WebSocket DEBUG] 正在发送到房间: {room_name}')
socketio.emit('new_event', event_data, room=room_name, namespace='/')
print(f'[WebSocket DEBUG] ✓ 已发送到 {room_name}')
print(f'[WebSocket] 已推送新事件到房间: events_all, {room_name}')
else:
print(f'[WebSocket] 已推送新事件到房间: events_all')
print(f'[WebSocket DEBUG] ========== 广播完成 ==========\n')
except Exception as e:
print(f'[WebSocket ERROR] 推送新事件失败: {e}')
import traceback
traceback.print_exc()
# ==================== WebSocket 轮询机制(检测新事件) ====================
# 内存变量记录近24小时内已知的事件ID集合和最大ID
known_event_ids_in_24h = set() # 近24小时内已知的所有事件ID
last_max_event_id = 0 # 已知的最大事件ID
def poll_new_events():
"""
定期轮询数据库,检查是否有新事件
每 30 秒执行一次
新的设计思路(修复 created_at 不是入库时间的问题):
1. 查询近24小时内的所有活跃事件按 created_at因为这是事件发生时间
2. 通过对比事件ID自增ID来判断是否为新插入的事件
3. 推送 ID > last_max_event_id 的事件
4. 更新已知事件ID集合和最大ID
"""
global known_event_ids_in_24h, last_max_event_id
try:
with app.app_context():
from datetime import datetime, timedelta
current_time = datetime.now()
print(f'\n[轮询 DEBUG] ========== 开始轮询 ==========')
print(f'[轮询 DEBUG] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}')
print(f'[轮询 DEBUG] 已知事件ID数量: {len(known_event_ids_in_24h)}')
print(f'[轮询 DEBUG] 当前最大事件ID: {last_max_event_id}')
# 查询近24小时内的所有活跃事件按事件发生时间 created_at
time_24h_ago = current_time - timedelta(hours=24)
print(f'[轮询 DEBUG] 查询时间范围: 近24小时{time_24h_ago.strftime("%Y-%m-%d %H:%M:%S")} ~ 现在)')
# 查询所有近24小时内的活跃事件
events_in_24h = Event.query.filter(
Event.created_at >= time_24h_ago,
Event.status == 'active'
).order_by(Event.id.asc()).all()
print(f'[轮询 DEBUG] 数据库查询结果: 找到 {len(events_in_24h)} 个近24小时内的事件')
# 找出新插入的事件ID > last_max_event_id
new_events = [
event for event in events_in_24h
if event.id > last_max_event_id
]
print(f'[轮询 DEBUG] 新事件数量ID > {last_max_event_id}: {len(new_events)}')
if new_events:
print(f'[轮询] 发现 {len(new_events)} 个新事件')
for event in new_events:
print(f'[轮询 DEBUG] 新事件详情:')
print(f'[轮询 DEBUG] - ID: {event.id}')
print(f'[轮询 DEBUG] - 标题: {event.title}')
print(f'[轮询 DEBUG] - 事件发生时间(created_at): {event.created_at}')
print(f'[轮询 DEBUG] - 事件类型: {event.event_type}')
# 推送新事件
print(f'[轮询 DEBUG] 准备推送事件 ID={event.id}')
broadcast_new_event(event)
print(f'[轮询] ✓ 已推送事件 ID={event.id}, 标题={event.title}')
# 更新已知事件ID集合所有近24小时内的事件ID
known_event_ids_in_24h = set(event.id for event in events_in_24h)
# 更新最大事件ID
new_max_id = max(event.id for event in events_in_24h)
print(f'[轮询 DEBUG] 更新最大事件ID: {last_max_event_id} -> {new_max_id}')
last_max_event_id = new_max_id
print(f'[轮询 DEBUG] 更新后已知事件ID数量: {len(known_event_ids_in_24h)}')
else:
print(f'[轮询 DEBUG] 没有新事件需要推送')
# 即使没有新事件也要更新已知事件集合清理超过24小时的
if events_in_24h:
known_event_ids_in_24h = set(event.id for event in events_in_24h)
current_max_id = max(event.id for event in events_in_24h)
if current_max_id != last_max_event_id:
print(f'[轮询 DEBUG] 更新最大事件ID: {last_max_event_id} -> {current_max_id}')
last_max_event_id = current_max_id
print(f'[轮询 DEBUG] ========== 轮询结束 ==========\n')
except Exception as e:
print(f'[轮询 ERROR] 检查新事件时出错: {e}')
import traceback
traceback.print_exc()
def initialize_event_polling():
"""
初始化事件轮询机制
在应用启动时调用
"""
global known_event_ids_in_24h, last_max_event_id
try:
from datetime import datetime, timedelta
with app.app_context():
current_time = datetime.now()
time_24h_ago = current_time - timedelta(hours=24)
print(f'\n[轮询] ========== 初始化事件轮询 ==========')
print(f'[轮询] 当前时间: {current_time.strftime("%Y-%m-%d %H:%M:%S")}')
# 查询近24小时内的所有活跃事件
events_in_24h = Event.query.filter(
Event.created_at >= time_24h_ago,
Event.status == 'active'
).order_by(Event.id.asc()).all()
# 初始化已知事件ID集合
known_event_ids_in_24h = set(event.id for event in events_in_24h)
# 初始化最大事件ID
if events_in_24h:
last_max_event_id = max(event.id for event in events_in_24h)
print(f'[轮询] 近24小时内共有 {len(events_in_24h)} 个活跃事件')
print(f'[轮询] 初始最大事件ID: {last_max_event_id}')
print(f'[轮询] 事件ID范围: {min(event.id for event in events_in_24h)} ~ {last_max_event_id}')
else:
last_max_event_id = 0
print(f'[轮询] 近24小时内没有活跃事件')
print(f'[轮询] 初始最大事件ID: 0')
# 统计数据库中的事件总数
total_events = Event.query.filter_by(status='active').count()
print(f'[轮询] 数据库中共有 {total_events} 个活跃事件(所有时间)')
print(f'[轮询] 只会推送 ID > {last_max_event_id} 的新事件')
print(f'[轮询] ========== 初始化完成 ==========\n')
# 创建后台调度器
scheduler = BackgroundScheduler()
# 每 30 秒执行一次轮询
scheduler.add_job(
func=poll_new_events,
trigger='interval',
seconds=30,
id='poll_new_events',
name='检查新事件并推送',
replace_existing=True
)
scheduler.start()
print('[轮询] 调度器已启动,每 30 秒检查一次新事件')
except Exception as e:
print(f'[轮询] 初始化失败: {e}')
# ==================== 结束 WebSocket 部分 ====================
@app.route('/api/posts/<int:post_id>/like', methods=['POST'])
@login_required
def like_post(post_id):
@@ -11576,4 +11874,8 @@ if __name__ == '__main__':
except Exception as e:
app.logger.error(f"数据库初始化失败: {e}")
app.run(host='0.0.0.0', port=5001, debug=False)
# 初始化事件轮询机制WebSocket 推送)
initialize_event_polling()
# 使用 socketio.run 替代 app.run 以支持 WebSocket
socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True)

View File

@@ -1,45 +0,0 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
from datetime import datetime
import pytz
import os
# 创建Flask应用
app = Flask(__name__)
# 配置
config_name = os.environ.get('FLASK_ENV', 'development')
from config import config
app.config.from_object(config[config_name])
# 初始化扩展
db = SQLAlchemy(app)
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 时区设置
def beijing_now():
"""获取北京时间"""
tz = pytz.timezone('Asia/Shanghai')
return datetime.now(tz)
# 导入模型
from app.models import *
# 创建数据库表
with app.app_context():
db.create_all()
# 注册路由
from app.routes import events, stocks, limitanalyse, calendar, industries
app.register_blueprint(events.bp)
app.register_blueprint(stocks.bp)
app.register_blueprint(limitanalyse.bp)
app.register_blueprint(calendar.bp)
app.register_blueprint(industries.bp)
if __name__ == '__main__':
print("=== Value Frontier React 架构启动 ===")
app.run(host='0.0.0.0', port=5001, debug=True)

View File

@@ -1,30 +0,0 @@
# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_compress import Compress
from flask_cors import CORS
from clickhouse_driver import Client as Cclient
from sqlalchemy import create_engine
# Database instances
db = SQLAlchemy()
# Other extensions
login_manager = LoginManager()
compress = Compress()
cors = CORS()
# Database engines (如果仍然需要直接使用 engine)
engine = create_engine("mysql+pymysql://root:Zzl33818!@111.198.58.126:33060/stock", echo=False)
engine_med = create_engine("mysql+pymysql://root:Zzl33818!@111.198.58.126:33060/med", echo=False)
engine_2 = create_engine("mysql+pymysql://root:Zzl33818!@111.198.58.126:33060/valuefrontier", echo=False)
# ClickHouse client factory
def get_clickhouse_client():
return Cclient(
host='111.198.58.126',
port=18778,
user='default',
password='Zzl5588161!',
database='stock'
)

View File

@@ -1,504 +0,0 @@
from app import db
from datetime import datetime
import pytz
import json
def beijing_now():
"""获取北京时间"""
tz = pytz.timezone('Asia/Shanghai')
return datetime.now(tz)
class Post(db.Model):
"""帖子模型"""
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# 内容
title = db.Column(db.String(200)) # 标题(可选)
content = db.Column(db.Text, nullable=False) # 内容
content_type = db.Column(db.String(20), default='text') # 内容类型:text/rich_text/link
# 时间
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
# 统计
likes_count = db.Column(db.Integer, default=0)
comments_count = db.Column(db.Integer, default=0)
view_count = db.Column(db.Integer, default=0)
# 状态
status = db.Column(db.String(20), default='active') # active/hidden/deleted
is_top = db.Column(db.Boolean, default=False) # 是否置顶
# 关系
user = db.relationship('User', backref='posts')
likes = db.relationship('PostLike', backref='post', lazy='dynamic')
comments = db.relationship('Comment', backref='post', lazy='dynamic')
class User(db.Model):
"""用户模型"""
id = db.Column(db.Integer, primary_key=True)
# 基础账号信息(注册时必填)
username = db.Column(db.String(80), unique=True, nullable=False) # 用户名
email = db.Column(db.String(120), unique=True, nullable=False) # 邮箱
password_hash = db.Column(db.String(128), nullable=False) # 密码哈希
email_confirmed = db.Column(db.Boolean, default=False) # 邮箱是否验证
# 账号状态
created_at = db.Column(db.DateTime, default=beijing_now) # 注册时间
last_seen = db.Column(db.DateTime, default=beijing_now) # 最后活跃时间
status = db.Column(db.String(20), default='active') # 账号状态 active/banned/deleted
# 个人资料(可选,后续在个人中心完善)
nickname = db.Column(db.String(30)) # 社区昵称
avatar_url = db.Column(db.String(200)) # 头像URL
banner_url = db.Column(db.String(200)) # 个人主页背景图
bio = db.Column(db.String(200)) # 个人简介
gender = db.Column(db.String(10)) # 性别
birth_date = db.Column(db.Date) # 生日
location = db.Column(db.String(100)) # 所在地
# 联系方式(可选)
phone = db.Column(db.String(20)) # 手机号
wechat_id = db.Column(db.String(80)) # 微信号
# 实名认证信息(可选)
real_name = db.Column(db.String(30)) # 真实姓名
id_number = db.Column(db.String(18)) # 身份证号(加密存储)
is_verified = db.Column(db.Boolean, default=False) # 是否实名认证
verify_time = db.Column(db.DateTime) # 实名认证时间
# 投资相关信息(可选)
trading_experience = db.Column(db.Integer) # 炒股年限
investment_style = db.Column(db.String(50)) # 投资风格
risk_preference = db.Column(db.String(20)) # 风险偏好
investment_amount = db.Column(db.String(20)) # 投资规模
preferred_markets = db.Column(db.String(200), default='[]') # 偏好市场 JSON
# 社区信息(系统自动更新)
user_level = db.Column(db.Integer, default=1) # 用户等级
reputation_score = db.Column(db.Integer, default=0) # 信用积分
contribution_point = db.Column(db.Integer, default=0) # 贡献点数
post_count = db.Column(db.Integer, default=0) # 发帖数
comment_count = db.Column(db.Integer, default=0) # 评论数
follower_count = db.Column(db.Integer, default=0) # 粉丝数
following_count = db.Column(db.Integer, default=0) # 关注数
# 创作者信息(可选)
is_creator = db.Column(db.Boolean, default=False) # 是否创作者
creator_type = db.Column(db.String(20)) # 创作者类型
creator_tags = db.Column(db.String(200), default='[]') # 创作者标签 JSON
# 系统设置
email_notifications = db.Column(db.Boolean, default=True) # 邮件通知
sms_notifications = db.Column(db.Boolean, default=False) # 短信通知
wechat_notifications = db.Column(db.Boolean, default=False) # 微信通知
notification_preferences = db.Column(db.String(500), default='{}') # 通知偏好 JSON
privacy_level = db.Column(db.String(20), default='public') # 隐私级别
theme_preference = db.Column(db.String(20), default='light') # 主题偏好
blocked_keywords = db.Column(db.String(500), default='[]') # 屏蔽关键词 JSON
# 手机号验证
phone_confirmed = db.Column(db.Boolean, default=False) # 手机是否验证
phone_confirm_time = db.Column(db.DateTime) # 手机验证时间
def __init__(self, username, email=None, password=None, phone=None):
self.username = username
if email:
self.email = email
if password:
self.set_password(password)
if phone:
self.phone = phone
def set_password(self, password):
from werkzeug.security import generate_password_hash
self.password_hash = generate_password_hash(password)
def check_password(self, password):
from werkzeug.security import check_password_hash
return check_password_hash(self.password_hash, password)
def update_last_seen(self):
self.last_seen = beijing_now()
db.session.commit()
def get_preferred_markets(self):
try:
return json.loads(self.preferred_markets)
except (json.JSONDecodeError, TypeError):
return []
def get_blocked_keywords(self):
try:
return json.loads(self.blocked_keywords)
except (json.JSONDecodeError, TypeError):
return []
def get_notification_preferences(self):
try:
return json.loads(self.notification_preferences)
except (json.JSONDecodeError, TypeError):
return {}
def get_creator_tags(self):
try:
return json.loads(self.creator_tags)
except (json.JSONDecodeError, TypeError):
return []
def set_preferred_markets(self, markets):
self.preferred_markets = json.dumps(markets)
def set_blocked_keywords(self, keywords):
self.blocked_keywords = json.dumps(keywords)
def set_notification_preferences(self, preferences):
self.notification_preferences = json.dumps(preferences)
def set_creator_tags(self, tags):
self.creator_tags = json.dumps(tags)
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'nickname': self.nickname,
'avatar_url': self.avatar_url,
'bio': self.bio,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
'status': self.status,
'user_level': self.user_level,
'reputation_score': self.reputation_score,
'post_count': self.post_count,
'follower_count': self.follower_count,
'following_count': self.following_count
}
def __repr__(self):
return f'<User {self.username}>'
class Comment(db.Model):
"""评论"""
id = db.Column(db.Integer, primary_key=True)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
content = db.Column(db.Text, nullable=False)
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id')) # 父评论ID,用于回复
created_at = db.Column(db.DateTime, default=beijing_now)
status = db.Column(db.String(20), default='active')
user = db.relationship('User', backref='comments')
replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id]))
class CommentLike(db.Model):
"""评论点赞记录基于session_id以兼容匿名点赞"""
__tablename__ = 'comment_like'
id = db.Column(db.Integer, primary_key=True)
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False)
session_id = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=beijing_now)
__table_args__ = (db.UniqueConstraint('comment_id', 'session_id'),)
class EventFollow(db.Model):
"""事件关注"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', backref='event_follows')
__table_args__ = (db.UniqueConstraint('user_id', 'event_id'),)
class PostLike(db.Model):
"""帖子点赞"""
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', backref='post_likes')
__table_args__ = (db.UniqueConstraint('user_id', 'post_id'),)
class Event(db.Model):
"""事件模型"""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
# 事件类型与状态
event_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
# 时间相关
start_time = db.Column(db.DateTime, default=beijing_now)
end_time = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now)
# 热度与统计
hot_score = db.Column(db.Float, default=0)
view_count = db.Column(db.Integer, default=0)
trending_score = db.Column(db.Float, default=0)
post_count = db.Column(db.Integer, default=0)
follower_count = db.Column(db.Integer, default=0)
# 关联信息
related_industries = db.Column(db.JSON)
keywords = db.Column(db.JSON)
files = db.Column(db.JSON)
importance = db.Column(db.String(20))
related_avg_chg = db.Column(db.Float, default=0)
related_max_chg = db.Column(db.Float, default=0)
related_week_chg = db.Column(db.Float, default=0)
# 新增字段
invest_score = db.Column(db.Integer) # 超预期得分
expectation_surprise_score = db.Column(db.Integer)
# 创建者信息
creator_id = db.Column(db.Integer, db.ForeignKey('user.id'))
creator = db.relationship('User', backref='created_events')
# 关系
posts = db.relationship('Post', backref='event', lazy='dynamic')
followers = db.relationship('EventFollow', backref='event', lazy='dynamic')
related_stocks = db.relationship('RelatedStock', backref='event', lazy='dynamic')
historical_events = db.relationship('HistoricalEvent', backref='event', lazy='dynamic')
related_data = db.relationship('RelatedData', backref='event', lazy='dynamic')
related_concepts = db.relationship('RelatedConcepts', backref='event', lazy='dynamic')
@property
def keywords_list(self):
if isinstance(self.keywords, list):
return self.keywords
elif isinstance(self.keywords, str):
try:
return json.loads(self.keywords)
except (json.JSONDecodeError, TypeError):
return []
return []
def set_keywords(self, keywords):
if isinstance(keywords, list):
self.keywords = keywords
elif isinstance(keywords, str):
try:
self.keywords = json.loads(keywords)
except json.JSONDecodeError:
self.keywords = [keywords]
else:
self.keywords = []
class RelatedStock(db.Model):
"""相关标的模型"""
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
stock_code = db.Column(db.String(20)) # 股票代码
stock_name = db.Column(db.String(100)) # 股票名称
sector = db.Column(db.String(100)) # 关联类型
relation_desc = db.Column(db.String(1024)) # 关联原因描述
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
correlation = db.Column(db.Float())
momentum = db.Column(db.String(1024)) #动量
class RelatedData(db.Model):
"""关联数据模型"""
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
title = db.Column(db.String(200)) # 数据标题
data_type = db.Column(db.String(50)) # 数据类型
data_content = db.Column(db.JSON) # 数据内容(JSON格式)
description = db.Column(db.Text) # 数据描述
created_at = db.Column(db.DateTime, default=beijing_now)
class RelatedConcepts(db.Model):
"""关联数据模型"""
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
concept_code = db.Column(db.String(20)) # 数据标题
concept = db.Column(db.String(100)) # 数据类型
reason = db.Column(db.Text) # 数据描述
image_paths = db.Column(db.JSON) # 数据内容(JSON格式)
created_at = db.Column(db.DateTime, default=beijing_now)
@property
def image_paths_list(self):
if isinstance(self.image_paths, list):
return self.image_paths
elif isinstance(self.image_paths, str):
try:
return json.loads(self.image_paths)
except (json.JSONDecodeError, TypeError):
return []
return []
def set_image_paths(self, image_paths):
if isinstance(image_paths, list):
self.image_paths = image_paths
elif isinstance(image_paths, str):
try:
self.image_paths = json.loads(image_paths)
except json.JSONDecodeError:
self.image_paths = [image_paths]
else:
self.image_paths = []
def get_first_image_path(self):
paths = self.image_paths_list
return paths[0] if paths else None
class EventHotHistory(db.Model):
"""事件热度历史记录"""
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
score = db.Column(db.Float) # 总分
interaction_score = db.Column(db.Float) # 互动分数
follow_score = db.Column(db.Float) # 关注度分数
view_score = db.Column(db.Float) # 浏览量分数
recent_activity_score = db.Column(db.Float) # 最近活跃度分数
time_decay = db.Column(db.Float) # 时间衰减因子
created_at = db.Column(db.DateTime, default=beijing_now)
event = db.relationship('Event', backref='hot_history')
class EventTransmissionNode(db.Model):
"""事件传导节点模型"""
__tablename__ = 'event_transmission_nodes'
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
node_type = db.Column(db.Enum('company', 'industry', 'policy', 'technology',
'market', 'event', 'other'), nullable=False)
node_name = db.Column(db.String(200), nullable=False)
node_description = db.Column(db.Text)
importance_score = db.Column(db.Integer, default=50)
stock_code = db.Column(db.String(20))
is_main_event = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
# Relationships
event = db.relationship('Event', backref='transmission_nodes')
outgoing_edges = db.relationship('EventTransmissionEdge',
foreign_keys='EventTransmissionEdge.from_node_id',
backref='from_node', cascade='all, delete-orphan')
incoming_edges = db.relationship('EventTransmissionEdge',
foreign_keys='EventTransmissionEdge.to_node_id',
backref='to_node', cascade='all, delete-orphan')
__table_args__ = (
db.Index('idx_event_node_type', 'event_id', 'node_type'),
db.Index('idx_node_name', 'node_name'),
)
class EventTransmissionEdge(db.Model):
"""事件传导边模型"""
__tablename__ = 'event_transmission_edges'
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
from_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False)
to_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False)
transmission_type = db.Column(db.Enum('supply_chain', 'competition', 'policy',
'technology', 'capital_flow', 'expectation',
'cyclic_effect', 'other'), nullable=False)
transmission_mechanism = db.Column(db.Text)
direction = db.Column(db.Enum('positive', 'negative', 'neutral', 'mixed'), default='neutral')
strength = db.Column(db.Integer, default=50)
impact = db.Column(db.Text)
is_circular = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
# Relationship
event = db.relationship('Event', backref='transmission_edges')
__table_args__ = (
db.Index('idx_event_edge_type', 'event_id', 'transmission_type'),
db.Index('idx_from_to_nodes', 'from_node_id', 'to_node_id'),
)
class EventSankeyFlow(db.Model):
"""事件桑基流模型"""
__tablename__ = 'event_sankey_flows'
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
# 流的基本信息
source_node = db.Column(db.String(200), nullable=False)
source_type = db.Column(db.Enum('event', 'policy', 'technology', 'industry',
'company', 'product'), nullable=False)
source_level = db.Column(db.Integer, nullable=False, default=0)
target_node = db.Column(db.String(200), nullable=False)
target_type = db.Column(db.Enum('policy', 'technology', 'industry',
'company', 'product'), nullable=False)
target_level = db.Column(db.Integer, nullable=False, default=1)
# 流量信息
flow_value = db.Column(db.Numeric(10, 2), nullable=False)
flow_ratio = db.Column(db.Numeric(5, 4), nullable=False)
# 传导机制
transmission_path = db.Column(db.String(500))
impact_description = db.Column(db.Text)
evidence_strength = db.Column(db.Integer, default=50)
# 时间戳
created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
# 关系
event = db.relationship('Event', backref='sankey_flows')
__table_args__ = (
db.Index('idx_event_flow', 'event_id'),
db.Index('idx_source_target', 'source_node', 'target_node'),
)
class HistoricalEvent(db.Model):
"""历史事件模型"""
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
title = db.Column(db.String(200))
content = db.Column(db.Text)
event_date = db.Column(db.DateTime)
relevance = db.Column(db.Integer) # 相关性
importance = db.Column(db.Integer) # 重要程度
related_stock = db.Column(db.JSON) # 保留JSON字段
created_at = db.Column(db.DateTime, default=beijing_now)
# 新增关系
stocks = db.relationship('HistoricalEventStock', backref='historical_event', lazy='dynamic',
cascade='all, delete-orphan')
class HistoricalEventStock(db.Model):
"""历史事件相关股票模型"""
__tablename__ = 'historical_event_stocks'
id = db.Column(db.Integer, primary_key=True)
historical_event_id = db.Column(db.Integer, db.ForeignKey('historical_event.id'), nullable=False)
stock_code = db.Column(db.String(20), nullable=False)
stock_name = db.Column(db.String(50))
relation_desc = db.Column(db.Text)
correlation = db.Column(db.Float, default=0.5)
sector = db.Column(db.String(100))
created_at = db.Column(db.DateTime, default=beijing_now)
__table_args__ = (
db.Index('idx_historical_event_stock', 'historical_event_id', 'stock_code'),
)

View File

@@ -1 +0,0 @@
# 路由包初始化文件

View File

@@ -1,121 +0,0 @@
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
import json
bp = Blueprint('calendar', __name__, url_prefix='/api/v1/calendar')
@bp.route('/event-counts', methods=['GET'])
def get_event_counts():
"""获取事件数量统计"""
try:
year = request.args.get('year', '2027')
month = request.args.get('month', '10')
# 模拟事件数量数据
event_counts = []
for day in range(1, 32):
count = (day % 7) + 1 # 模拟每天1-7个事件
event_counts.append({
'date': f'{year}-{month.zfill(2)}-{day:02d}',
'count': count
})
return jsonify({
'success': True,
'data': event_counts
})
except Exception as e:
print(f"Error getting event counts: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/events', methods=['GET'])
def get_calendar_events():
"""获取日历事件"""
try:
year = request.args.get('year', '2027')
month = request.args.get('month', '10')
event_type = request.args.get('type', 'all')
# 模拟日历事件数据
events = []
for day in range(1, 32):
for i in range((day % 7) + 1):
event = {
'id': f'{year}{month.zfill(2)}{day:02d}{i}',
'title': f'事件{day}-{i+1}',
'date': f'{year}-{month.zfill(2)}-{day:02d}',
'type': ['政策', '技术', '产业', '公司'][i % 4],
'importance': ['', '', ''][i % 3],
'status': 'active'
}
events.append(event)
# 根据类型过滤
if event_type != 'all':
events = [e for e in events if e['type'] == event_type]
return jsonify({
'success': True,
'data': events
})
except Exception as e:
print(f"Error getting calendar events: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/events/<int:event_id>', methods=['GET'])
def get_calendar_event_detail(event_id):
"""获取日历事件详情"""
try:
# 模拟事件详情
event_detail = {
'id': event_id,
'title': f'事件{event_id}详情',
'description': f'这是事件{event_id}的详细描述',
'date': '2027-10-15',
'type': '政策',
'importance': '',
'status': 'active',
'related_stocks': [
{'code': '000001', 'name': '股票A'},
{'code': '000002', 'name': '股票B'}
],
'keywords': ['政策', '改革', '创新'],
'files': [
{'name': '报告.pdf', 'url': '/files/report.pdf'},
{'name': '数据.xlsx', 'url': '/files/data.xlsx'}
]
}
return jsonify({
'success': True,
'data': event_detail
})
except Exception as e:
print(f"Error getting calendar event detail: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def get_event_class(count):
"""根据事件数量获取CSS类"""
if count == 0:
return 'no-events'
elif count <= 3:
return 'few-events'
elif count <= 6:
return 'medium-events'
else:
return 'many-events'
def parse_json_field(field_value):
"""解析JSON字段"""
if isinstance(field_value, str):
try:
return json.loads(field_value)
except (json.JSONDecodeError, TypeError):
return []
elif isinstance(field_value, (list, dict)):
return field_value
else:
return []

View File

@@ -1,385 +0,0 @@
from flask import Blueprint, request, jsonify
from app import db
from app.models import Event, RelatedStock, RelatedConcepts, HistoricalEvent, EventTransmissionNode, EventTransmissionEdge, EventSankeyFlow
from datetime import datetime
import json
bp = Blueprint('events', __name__, url_prefix='/api/events')
@bp.route('/<int:event_id>', methods=['GET'])
def get_event_detail(event_id):
"""获取事件详情"""
try:
event = Event.query.get(event_id)
if not event:
return jsonify({'success': False, 'error': '事件不存在'}), 404
# 获取相关股票
related_stocks = RelatedStock.query.filter_by(event_id=event_id).all()
stocks_data = []
for stock in related_stocks:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': stock.relation_desc,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None
})
# 获取相关概念
related_concepts = RelatedConcepts.query.filter_by(event_id=event_id).all()
concepts_data = []
for concept in related_concepts:
concepts_data.append({
'id': concept.id,
'concept_code': concept.concept_code,
'concept': concept.concept,
'reason': concept.reason,
'image_paths': concept.image_paths_list,
'created_at': concept.created_at.isoformat() if concept.created_at else None
})
event_data = {
'id': event.id,
'title': event.title,
'description': event.description,
'event_type': event.event_type,
'status': event.status,
'start_time': event.start_time.isoformat() if event.start_time else None,
'end_time': event.end_time.isoformat() if event.end_time else None,
'created_at': event.created_at.isoformat() if event.created_at else None,
'updated_at': event.updated_at.isoformat() if event.updated_at else None,
'hot_score': event.hot_score,
'view_count': event.view_count,
'trending_score': event.trending_score,
'post_count': event.post_count,
'follower_count': event.follower_count,
'related_industries': event.related_industries,
'keywords': event.keywords_list,
'files': event.files,
'importance': event.importance,
'related_avg_chg': event.related_avg_chg,
'related_max_chg': event.related_max_chg,
'related_week_chg': event.related_week_chg,
'invest_score': event.invest_score,
'expectation_surprise_score': event.expectation_surprise_score,
'related_stocks': stocks_data,
'related_concepts': concepts_data
}
return jsonify({
'success': True,
'data': event_data
})
except Exception as e:
print(f"Error getting event detail: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/stocks', methods=['GET'])
def get_related_stocks(event_id):
"""获取事件相关股票"""
try:
stocks = RelatedStock.query.filter_by(event_id=event_id).all()
stocks_data = []
for stock in stocks:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': stock.relation_desc,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None
})
return jsonify({
'success': True,
'data': stocks_data
})
except Exception as e:
print(f"Error getting related stocks: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/stocks', methods=['POST'])
def add_related_stock(event_id):
"""添加相关股票"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': '请提供数据'}), 400
# 检查事件是否存在
event = Event.query.get(event_id)
if not event:
return jsonify({'success': False, 'error': '事件不存在'}), 404
# 创建新的相关股票记录
new_stock = RelatedStock(
event_id=event_id,
stock_code=data['stock_code'],
stock_name=data.get('stock_name', ''),
sector=data.get('sector', ''),
relation_desc=data['relation_desc'],
correlation=data.get('correlation', 0.5),
momentum=data.get('momentum', '')
)
db.session.add(new_stock)
db.session.commit()
return jsonify({
'success': True,
'message': '相关股票添加成功',
'data': {
'id': new_stock.id,
'stock_code': new_stock.stock_code,
'stock_name': new_stock.stock_name,
'sector': new_stock.sector,
'relation_desc': new_stock.relation_desc,
'correlation': new_stock.correlation,
'momentum': new_stock.momentum
}
})
except Exception as e:
db.session.rollback()
print(f"Error adding related stock: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/stocks/<int:stock_id>', methods=['DELETE'])
def delete_related_stock(stock_id):
"""删除相关股票"""
try:
stock = RelatedStock.query.get(stock_id)
if not stock:
return jsonify({'success': False, 'error': '相关股票不存在'}), 404
db.session.delete(stock)
db.session.commit()
return jsonify({
'success': True,
'message': '相关股票删除成功'
})
except Exception as e:
db.session.rollback()
print(f"Error deleting related stock: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/concepts', methods=['GET'])
def get_related_concepts(event_id):
"""获取事件相关概念"""
try:
concepts = RelatedConcepts.query.filter_by(event_id=event_id).all()
concepts_data = []
for concept in concepts:
concepts_data.append({
'id': concept.id,
'concept_code': concept.concept_code,
'concept': concept.concept,
'reason': concept.reason,
'image_paths': concept.image_paths_list,
'created_at': concept.created_at.isoformat() if concept.created_at else None
})
return jsonify({
'success': True,
'data': concepts_data
})
except Exception as e:
print(f"Error getting related concepts: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/historical', methods=['GET'])
def get_historical_events(event_id):
"""获取历史事件"""
try:
historical_events = HistoricalEvent.query.filter_by(event_id=event_id).all()
events_data = []
for event in historical_events:
events_data.append({
'id': event.id,
'title': event.title,
'content': event.content,
'event_date': event.event_date.isoformat() if event.event_date else None,
'relevance': event.relevance,
'importance': event.importance,
'related_stock': event.related_stock,
'created_at': event.created_at.isoformat() if event.created_at else None
})
return jsonify({
'success': True,
'data': events_data
})
except Exception as e:
print(f"Error getting historical events: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/expectation-score', methods=['GET'])
def get_expectation_score(event_id):
"""获取超预期得分"""
try:
event = Event.query.get(event_id)
if not event:
return jsonify({'success': False, 'error': '事件不存在'}), 404
return jsonify({
'success': True,
'data': {
'invest_score': event.invest_score,
'expectation_surprise_score': event.expectation_surprise_score
}
})
except Exception as e:
print(f"Error getting expectation score: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/follow', methods=['POST'])
def toggle_event_follow(event_id):
"""关注/取消关注事件"""
try:
# 这里需要用户认证,暂时返回成功
return jsonify({
'success': True,
'message': '关注状态更新成功'
})
except Exception as e:
print(f"Error toggling event follow: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/transmission', methods=['GET'])
def get_transmission_chain(event_id):
"""获取事件传导链"""
try:
# 获取传导节点
nodes = EventTransmissionNode.query.filter_by(event_id=event_id).all()
nodes_data = []
for node in nodes:
nodes_data.append({
'id': node.id,
'node_type': node.node_type,
'node_name': node.node_name,
'node_description': node.node_description,
'importance_score': node.importance_score,
'stock_code': node.stock_code,
'is_main_event': node.is_main_event
})
# 获取传导边
edges = EventTransmissionEdge.query.filter_by(event_id=event_id).all()
edges_data = []
for edge in edges:
edges_data.append({
'id': edge.id,
'from_node_id': edge.from_node_id,
'to_node_id': edge.to_node_id,
'transmission_type': edge.transmission_type,
'transmission_mechanism': edge.transmission_mechanism,
'direction': edge.direction,
'strength': edge.strength,
'impact': edge.impact,
'is_circular': edge.is_circular
})
return jsonify({
'success': True,
'data': {
'nodes': nodes_data,
'edges': edges_data
}
})
except Exception as e:
print(f"Error getting transmission chain: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/sankey-data')
def get_event_sankey_data(event_id):
"""获取事件桑基图数据"""
try:
flows = EventSankeyFlow.query.filter_by(event_id=event_id).all()
flows_data = []
for flow in flows:
flows_data.append({
'id': flow.id,
'source_node': flow.source_node,
'source_type': flow.source_type,
'source_level': flow.source_level,
'target_node': flow.target_node,
'target_type': flow.target_type,
'target_level': flow.target_level,
'flow_value': float(flow.flow_value),
'flow_ratio': float(flow.flow_ratio),
'transmission_path': flow.transmission_path,
'impact_description': flow.impact_description,
'evidence_strength': flow.evidence_strength
})
return jsonify({
'success': True,
'data': flows_data
})
except Exception as e:
print(f"Error getting sankey data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/chain-analysis')
def get_event_chain_analysis(event_id):
"""获取事件链分析"""
try:
# 这里可以添加更复杂的链分析逻辑
return jsonify({
'success': True,
'data': {
'event_id': event_id,
'analysis': '链分析数据'
}
})
except Exception as e:
print(f"Error getting chain analysis: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<int:event_id>/chain-node/<int:node_id>', methods=['GET'])
def get_chain_node_detail(event_id, node_id):
"""获取链节点详情"""
try:
node = EventTransmissionNode.query.filter_by(
event_id=event_id,
id=node_id
).first()
if not node:
return jsonify({'success': False, 'error': '节点不存在'}), 404
return jsonify({
'success': True,
'data': {
'id': node.id,
'node_type': node.node_type,
'node_name': node.node_name,
'node_description': node.node_description,
'importance_score': node.importance_score,
'stock_code': node.stock_code,
'is_main_event': node.is_main_event
}
})
except Exception as e:
print(f"Error getting chain node detail: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1,511 +0,0 @@
from flask import Blueprint, request, jsonify
import json
bp = Blueprint('industries', __name__, url_prefix='/api')
@bp.route('/classifications', methods=['GET'])
def get_classifications():
"""获取行业分类"""
try:
# 模拟行业分类数据
classifications = [
{
'id': 1,
'name': '申万一级行业',
'description': '申万一级行业分类标准',
'levels': [
{'id': 1, 'name': '农林牧渔'},
{'id': 2, 'name': '采掘'},
{'id': 3, 'name': '化工'},
{'id': 4, 'name': '钢铁'},
{'id': 5, 'name': '有色金属'},
{'id': 6, 'name': '建筑材料'},
{'id': 7, 'name': '建筑装饰'},
{'id': 8, 'name': '电气设备'},
{'id': 9, 'name': '国防军工'},
{'id': 10, 'name': '汽车'},
{'id': 11, 'name': '家用电器'},
{'id': 12, 'name': '纺织服装'},
{'id': 13, 'name': '轻工制造'},
{'id': 14, 'name': '医药生物'},
{'id': 15, 'name': '公用事业'},
{'id': 16, 'name': '交通运输'},
{'id': 17, 'name': '房地产'},
{'id': 18, 'name': '商业贸易'},
{'id': 19, 'name': '休闲服务'},
{'id': 20, 'name': '银行'},
{'id': 21, 'name': '非银金融'},
{'id': 22, 'name': '综合'},
{'id': 23, 'name': '计算机'},
{'id': 24, 'name': '传媒'},
{'id': 25, 'name': '通信'},
{'id': 26, 'name': '电子'},
{'id': 27, 'name': '机械设备'},
{'id': 28, 'name': '食品饮料'}
]
}
]
return jsonify({
'success': True,
'data': classifications
})
except Exception as e:
print(f"Error getting classifications: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/levels', methods=['GET'])
def get_industry_levels():
"""获取行业层级"""
try:
classification_id = request.args.get('classification_id', '1')
# 模拟行业层级数据
levels = [
{
'id': 1,
'name': '农林牧渔',
'code': '801010',
'description': '农业、林业、畜牧业、渔业',
'stock_count': 45,
'avg_change': 1.2,
'total_market_cap': 500000000000,
'sub_industries': [
{'id': 101, 'name': '种植业', 'stock_count': 20},
{'id': 102, 'name': '林业', 'stock_count': 8},
{'id': 103, 'name': '畜牧业', 'stock_count': 12},
{'id': 104, 'name': '渔业', 'stock_count': 5}
]
},
{
'id': 2,
'name': '采掘',
'code': '801020',
'description': '煤炭、石油、天然气、有色金属矿采选',
'stock_count': 38,
'avg_change': 0.8,
'total_market_cap': 800000000000,
'sub_industries': [
{'id': 201, 'name': '煤炭开采', 'stock_count': 15},
{'id': 202, 'name': '石油开采', 'stock_count': 8},
{'id': 203, 'name': '有色金属矿采选', 'stock_count': 15}
]
},
{
'id': 3,
'name': '化工',
'code': '801030',
'description': '化学原料、化学制品、化学纤维',
'stock_count': 156,
'avg_change': 1.5,
'total_market_cap': 1200000000000,
'sub_industries': [
{'id': 301, 'name': '化学原料', 'stock_count': 45},
{'id': 302, 'name': '化学制品', 'stock_count': 78},
{'id': 303, 'name': '化学纤维', 'stock_count': 33}
]
},
{
'id': 4,
'name': '钢铁',
'code': '801040',
'description': '钢铁冶炼、钢铁制品',
'stock_count': 32,
'avg_change': 0.6,
'total_market_cap': 600000000000,
'sub_industries': [
{'id': 401, 'name': '钢铁冶炼', 'stock_count': 18},
{'id': 402, 'name': '钢铁制品', 'stock_count': 14}
]
},
{
'id': 5,
'name': '有色金属',
'code': '801050',
'description': '有色金属冶炼、有色金属制品',
'stock_count': 67,
'avg_change': 1.8,
'total_market_cap': 900000000000,
'sub_industries': [
{'id': 501, 'name': '有色金属冶炼', 'stock_count': 35},
{'id': 502, 'name': '有色金属制品', 'stock_count': 32}
]
},
{
'id': 6,
'name': '建筑材料',
'code': '801060',
'description': '水泥、玻璃、陶瓷、其他建材',
'stock_count': 89,
'avg_change': 1.1,
'total_market_cap': 700000000000,
'sub_industries': [
{'id': 601, 'name': '水泥', 'stock_count': 25},
{'id': 602, 'name': '玻璃', 'stock_count': 18},
{'id': 603, 'name': '陶瓷', 'stock_count': 12},
{'id': 604, 'name': '其他建材', 'stock_count': 34}
]
},
{
'id': 7,
'name': '建筑装饰',
'code': '801070',
'description': '房屋建设、装修装饰、园林工程',
'stock_count': 45,
'avg_change': 0.9,
'total_market_cap': 400000000000,
'sub_industries': [
{'id': 701, 'name': '房屋建设', 'stock_count': 15},
{'id': 702, 'name': '装修装饰', 'stock_count': 20},
{'id': 703, 'name': '园林工程', 'stock_count': 10}
]
},
{
'id': 8,
'name': '电气设备',
'code': '801080',
'description': '电机、电气自动化设备、电源设备',
'stock_count': 134,
'avg_change': 2.1,
'total_market_cap': 1500000000000,
'sub_industries': [
{'id': 801, 'name': '电机', 'stock_count': 25},
{'id': 802, 'name': '电气自动化设备', 'stock_count': 45},
{'id': 803, 'name': '电源设备', 'stock_count': 64}
]
},
{
'id': 9,
'name': '国防军工',
'code': '801090',
'description': '航天装备、航空装备、地面兵装',
'stock_count': 28,
'avg_change': 1.6,
'total_market_cap': 300000000000,
'sub_industries': [
{'id': 901, 'name': '航天装备', 'stock_count': 8},
{'id': 902, 'name': '航空装备', 'stock_count': 12},
{'id': 903, 'name': '地面兵装', 'stock_count': 8}
]
},
{
'id': 10,
'name': '汽车',
'code': '801100',
'description': '汽车整车、汽车零部件',
'stock_count': 78,
'avg_change': 1.3,
'total_market_cap': 1100000000000,
'sub_industries': [
{'id': 1001, 'name': '汽车整车', 'stock_count': 25},
{'id': 1002, 'name': '汽车零部件', 'stock_count': 53}
]
},
{
'id': 11,
'name': '家用电器',
'code': '801110',
'description': '白色家电、小家电、家电零部件',
'stock_count': 56,
'avg_change': 1.0,
'total_market_cap': 800000000000,
'sub_industries': [
{'id': 1101, 'name': '白色家电', 'stock_count': 20},
{'id': 1102, 'name': '小家电', 'stock_count': 18},
{'id': 1103, 'name': '家电零部件', 'stock_count': 18}
]
},
{
'id': 12,
'name': '纺织服装',
'code': '801120',
'description': '纺织制造、服装家纺',
'stock_count': 67,
'avg_change': 0.7,
'total_market_cap': 500000000000,
'sub_industries': [
{'id': 1201, 'name': '纺织制造', 'stock_count': 35},
{'id': 1202, 'name': '服装家纺', 'stock_count': 32}
]
},
{
'id': 13,
'name': '轻工制造',
'code': '801130',
'description': '造纸、包装印刷、家用轻工',
'stock_count': 89,
'avg_change': 0.9,
'total_market_cap': 600000000000,
'sub_industries': [
{'id': 1301, 'name': '造纸', 'stock_count': 25},
{'id': 1302, 'name': '包装印刷', 'stock_count': 30},
{'id': 1303, 'name': '家用轻工', 'stock_count': 34}
]
},
{
'id': 14,
'name': '医药生物',
'code': '801140',
'description': '化学制药、中药、生物制品、医疗器械',
'stock_count': 234,
'avg_change': 1.9,
'total_market_cap': 2500000000000,
'sub_industries': [
{'id': 1401, 'name': '化学制药', 'stock_count': 78},
{'id': 1402, 'name': '中药', 'stock_count': 45},
{'id': 1403, 'name': '生物制品', 'stock_count': 56},
{'id': 1404, 'name': '医疗器械', 'stock_count': 55}
]
},
{
'id': 15,
'name': '公用事业',
'code': '801150',
'description': '电力、燃气、水务',
'stock_count': 78,
'avg_change': 0.5,
'total_market_cap': 900000000000,
'sub_industries': [
{'id': 1501, 'name': '电力', 'stock_count': 45},
{'id': 1502, 'name': '燃气', 'stock_count': 18},
{'id': 1503, 'name': '水务', 'stock_count': 15}
]
},
{
'id': 16,
'name': '交通运输',
'code': '801160',
'description': '港口、公路、铁路、航空',
'stock_count': 67,
'avg_change': 0.8,
'total_market_cap': 800000000000,
'sub_industries': [
{'id': 1601, 'name': '港口', 'stock_count': 15},
{'id': 1602, 'name': '公路', 'stock_count': 20},
{'id': 1603, 'name': '铁路', 'stock_count': 12},
{'id': 1604, 'name': '航空', 'stock_count': 20}
]
},
{
'id': 17,
'name': '房地产',
'code': '801170',
'description': '房地产开发、房地产服务',
'stock_count': 89,
'avg_change': 0.6,
'total_market_cap': 1200000000000,
'sub_industries': [
{'id': 1701, 'name': '房地产开发', 'stock_count': 65},
{'id': 1702, 'name': '房地产服务', 'stock_count': 24}
]
},
{
'id': 18,
'name': '商业贸易',
'code': '801180',
'description': '贸易、零售',
'stock_count': 78,
'avg_change': 0.7,
'total_market_cap': 600000000000,
'sub_industries': [
{'id': 1801, 'name': '贸易', 'stock_count': 35},
{'id': 1802, 'name': '零售', 'stock_count': 43}
]
},
{
'id': 19,
'name': '休闲服务',
'code': '801190',
'description': '景点、酒店、旅游综合',
'stock_count': 34,
'avg_change': 1.2,
'total_market_cap': 300000000000,
'sub_industries': [
{'id': 1901, 'name': '景点', 'stock_count': 12},
{'id': 1902, 'name': '酒店', 'stock_count': 15},
{'id': 1903, 'name': '旅游综合', 'stock_count': 7}
]
},
{
'id': 20,
'name': '银行',
'code': '801200',
'description': '银行',
'stock_count': 28,
'avg_change': 0.4,
'total_market_cap': 8000000000000,
'sub_industries': [
{'id': 2001, 'name': '银行', 'stock_count': 28}
]
},
{
'id': 21,
'name': '非银金融',
'code': '801210',
'description': '保险、证券、多元金融',
'stock_count': 45,
'avg_change': 0.8,
'total_market_cap': 2000000000000,
'sub_industries': [
{'id': 2101, 'name': '保险', 'stock_count': 8},
{'id': 2102, 'name': '证券', 'stock_count': 25},
{'id': 2103, 'name': '多元金融', 'stock_count': 12}
]
},
{
'id': 22,
'name': '综合',
'code': '801220',
'description': '综合',
'stock_count': 23,
'avg_change': 0.6,
'total_market_cap': 200000000000,
'sub_industries': [
{'id': 2201, 'name': '综合', 'stock_count': 23}
]
},
{
'id': 23,
'name': '计算机',
'code': '801230',
'description': '计算机设备、计算机应用',
'stock_count': 156,
'avg_change': 2.3,
'total_market_cap': 1800000000000,
'sub_industries': [
{'id': 2301, 'name': '计算机设备', 'stock_count': 45},
{'id': 2302, 'name': '计算机应用', 'stock_count': 111}
]
},
{
'id': 24,
'name': '传媒',
'code': '801240',
'description': '文化传媒、营销传播',
'stock_count': 78,
'avg_change': 1.4,
'total_market_cap': 700000000000,
'sub_industries': [
{'id': 2401, 'name': '文化传媒', 'stock_count': 45},
{'id': 2402, 'name': '营销传播', 'stock_count': 33}
]
},
{
'id': 25,
'name': '通信',
'code': '801250',
'description': '通信设备、通信运营',
'stock_count': 45,
'avg_change': 1.7,
'total_market_cap': 600000000000,
'sub_industries': [
{'id': 2501, 'name': '通信设备', 'stock_count': 30},
{'id': 2502, 'name': '通信运营', 'stock_count': 15}
]
},
{
'id': 26,
'name': '电子',
'code': '801260',
'description': '半导体、电子制造、光学光电子',
'stock_count': 178,
'avg_change': 2.0,
'total_market_cap': 2000000000000,
'sub_industries': [
{'id': 2601, 'name': '半导体', 'stock_count': 45},
{'id': 2602, 'name': '电子制造', 'stock_count': 78},
{'id': 2603, 'name': '光学光电子', 'stock_count': 55}
]
},
{
'id': 27,
'name': '机械设备',
'code': '801270',
'description': '通用机械、专用设备、仪器仪表',
'stock_count': 234,
'avg_change': 1.1,
'total_market_cap': 1500000000000,
'sub_industries': [
{'id': 2701, 'name': '通用机械', 'stock_count': 89},
{'id': 2702, 'name': '专用设备', 'stock_count': 98},
{'id': 2703, 'name': '仪器仪表', 'stock_count': 47}
]
},
{
'id': 28,
'name': '食品饮料',
'code': '801280',
'description': '食品加工、饮料制造',
'stock_count': 67,
'avg_change': 1.3,
'total_market_cap': 1000000000000,
'sub_industries': [
{'id': 2801, 'name': '食品加工', 'stock_count': 35},
{'id': 2802, 'name': '饮料制造', 'stock_count': 32}
]
}
]
return jsonify({
'success': True,
'data': levels
})
except Exception as e:
print(f"Error getting industry levels: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/info', methods=['GET'])
def get_industry_info():
"""获取行业信息"""
try:
industry_id = request.args.get('industry_id')
if not industry_id:
return jsonify({'success': False, 'error': '请提供行业ID'}), 400
# 模拟行业信息
industry_info = {
'id': industry_id,
'name': f'行业{industry_id}',
'code': f'801{industry_id.zfill(3)}',
'description': f'这是行业{industry_id}的详细描述',
'stock_count': 50,
'avg_change': 1.5,
'total_market_cap': 800000000000,
'pe_ratio': 15.6,
'pb_ratio': 2.3,
'roe': 8.5,
'top_stocks': [
{'code': '000001', 'name': '龙头股A', 'weight': 0.15},
{'code': '000002', 'name': '龙头股B', 'weight': 0.12},
{'code': '000003', 'name': '龙头股C', 'weight': 0.10}
],
'sub_industries': [
{'id': 1, 'name': '子行业A', 'stock_count': 20},
{'id': 2, 'name': '子行业B', 'stock_count': 18},
{'id': 3, 'name': '子行业C', 'stock_count': 12}
],
'performance': {
'daily': 1.5,
'weekly': 3.2,
'monthly': 8.5,
'quarterly': 12.3,
'yearly': 25.6
},
'trend': {
'direction': 'up',
'strength': 'medium',
'duration': '3 months'
}
}
return jsonify({
'success': True,
'data': industry_info
})
except Exception as e:
print(f"Error getting industry info: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1,469 +0,0 @@
from flask import Blueprint, request, jsonify
import pandas as pd
import json
from datetime import datetime
bp = Blueprint('limitanalyse', __name__, url_prefix='/api/limit-analyse')
@bp.route('/available-dates', methods=['GET'])
def get_available_dates():
"""获取可用日期列表"""
try:
# 模拟可用日期
dates = [
'2025-07-16',
'2025-07-15',
'2025-07-14',
'2025-07-11',
'2025-07-10'
]
return jsonify({
'success': True,
'data': dates
})
except Exception as e:
print(f"Error getting available dates: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def load_stock_data(datestr):
"""加载股票数据"""
try:
# 模拟股票数据
data = []
for i in range(100):
data.append({
'code': f'00000{i:03d}',
'name': f'股票{i}',
'price': 10.0 + i * 0.1,
'change': (i % 10 - 5) * 0.5,
'sector': f'板块{i % 5}',
'limit_type': '涨停' if i % 10 == 0 else '正常',
'volume': 1000000 + i * 50000,
'amount': 10000000 + i * 500000
})
return pd.DataFrame(data)
except Exception as e:
print(f"Error loading stock data: {e}")
return pd.DataFrame()
@bp.route('/data', methods=['GET'])
def get_analysis_data():
"""获取分析数据"""
try:
date = request.args.get('date', '2025-07-16')
# 加载数据
df = load_stock_data(date)
if df.empty:
return jsonify({'success': False, 'error': '数据加载失败'}), 500
# 统计信息
total_stocks = len(df)
limit_up_stocks = len(df[df['limit_type'] == '涨停'])
limit_down_stocks = len(df[df['limit_type'] == '跌停'])
# 板块统计
sector_stats = df.groupby('sector').agg({
'code': 'count',
'change': 'mean',
'volume': 'sum'
}).reset_index()
sector_data = []
for _, row in sector_stats.iterrows():
sector_data.append({
'sector': row['sector'],
'stock_count': int(row['code']),
'avg_change': round(row['change'], 2),
'total_volume': int(row['volume'])
})
return jsonify({
'success': True,
'data': {
'date': date,
'total_stocks': total_stocks,
'limit_up_stocks': limit_up_stocks,
'limit_down_stocks': limit_down_stocks,
'sector_stats': sector_data
}
})
except Exception as e:
print(f"Error getting analysis data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/sector-data', methods=['GET'])
def get_sector_data():
"""获取板块数据"""
try:
date = request.args.get('date', '2025-07-16')
# 加载数据
df = load_stock_data(date)
if df.empty:
return jsonify({'success': False, 'error': '数据加载失败'}), 500
# 板块统计
sector_stats = df.groupby('sector').agg({
'code': 'count',
'change': 'mean',
'volume': 'sum',
'amount': 'sum'
}).reset_index()
sector_data = []
for _, row in sector_stats.iterrows():
sector_data.append({
'sector': row['sector'],
'stock_count': int(row['code']),
'avg_change': round(row['change'], 2),
'total_volume': int(row['volume']),
'total_amount': int(row['amount'])
})
return jsonify({
'success': True,
'data': sector_data
})
except Exception as e:
print(f"Error getting sector data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/word-cloud', methods=['GET'])
def get_word_cloud_data():
"""获取词云数据"""
try:
date = request.args.get('date', '2025-07-16')
# 模拟词云数据
word_data = [
{'word': '科技', 'value': 100},
{'word': '新能源', 'value': 85},
{'word': '医药', 'value': 70},
{'word': '消费', 'value': 65},
{'word': '金融', 'value': 50},
{'word': '地产', 'value': 45},
{'word': '制造', 'value': 40},
{'word': '农业', 'value': 35},
{'word': '传媒', 'value': 30},
{'word': '环保', 'value': 25}
]
return jsonify({
'success': True,
'data': word_data
})
except Exception as e:
print(f"Error getting word cloud data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/chart-data', methods=['GET'])
def get_chart_data():
"""获取图表数据"""
try:
date = request.args.get('date', '2025-07-16')
# 模拟图表数据
chart_data = {
'limit_up_distribution': [
{'sector': '科技', 'count': 15},
{'sector': '新能源', 'count': 12},
{'sector': '医药', 'count': 10},
{'sector': '消费', 'count': 8},
{'sector': '金融', 'count': 6}
],
'sector_performance': [
{'sector': '科技', 'change': 2.5},
{'sector': '新能源', 'change': 1.8},
{'sector': '医药', 'change': 1.2},
{'sector': '消费', 'change': 0.8},
{'sector': '金融', 'change': 0.5}
]
}
return jsonify({
'success': True,
'data': chart_data
})
except Exception as e:
print(f"Error getting chart data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/stock-details', methods=['GET'])
def get_stock_details():
"""获取股票详情"""
try:
code = request.args.get('code')
date = request.args.get('date', '2025-07-16')
if not code:
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
# 模拟股票详情
stock_detail = {
'code': code,
'name': f'股票{code}',
'price': 15.50,
'change': 2.5,
'sector': '科技',
'volume': 1500000,
'amount': 23250000,
'limit_type': '涨停',
'turnover_rate': 3.2,
'market_cap': 15500000000
}
return jsonify({
'success': True,
'data': stock_detail
})
except Exception as e:
print(f"Error getting stock details: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/sector-analysis', methods=['GET'])
def get_sector_analysis():
"""获取板块分析"""
try:
sector = request.args.get('sector')
date = request.args.get('date', '2025-07-16')
if not sector:
return jsonify({'success': False, 'error': '请提供板块名称'}), 400
# 模拟板块分析数据
sector_analysis = {
'sector': sector,
'stock_count': 25,
'avg_change': 1.8,
'limit_up_count': 8,
'limit_down_count': 2,
'total_volume': 50000000,
'total_amount': 750000000,
'top_stocks': [
{'code': '000001', 'name': '股票A', 'change': 10.0},
{'code': '000002', 'name': '股票B', 'change': 9.5},
{'code': '000003', 'name': '股票C', 'change': 8.8}
]
}
return jsonify({
'success': True,
'data': sector_analysis
})
except Exception as e:
print(f"Error getting sector analysis: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/trend-analysis', methods=['GET'])
def get_trend_analysis():
"""获取趋势分析"""
try:
date = request.args.get('date', '2025-07-16')
# 模拟趋势分析数据
trend_data = {
'limit_up_trend': [
{'date': '2025-07-10', 'count': 45},
{'date': '2025-07-11', 'count': 52},
{'date': '2025-07-14', 'count': 48},
{'date': '2025-07-15', 'count': 55},
{'date': '2025-07-16', 'count': 51}
],
'sector_trend': [
{'sector': '科技', 'trend': 'up'},
{'sector': '新能源', 'trend': 'up'},
{'sector': '医药', 'trend': 'stable'},
{'sector': '消费', 'trend': 'down'},
{'sector': '金融', 'trend': 'stable'}
]
}
return jsonify({
'success': True,
'data': trend_data
})
except Exception as e:
print(f"Error getting trend analysis: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/heat-map', methods=['GET'])
def get_heat_map_data():
"""获取热力图数据"""
try:
date = request.args.get('date', '2025-07-16')
# 模拟热力图数据
heat_map_data = []
sectors = ['科技', '新能源', '医药', '消费', '金融', '地产', '制造', '农业']
for i, sector in enumerate(sectors):
for j in range(8):
heat_map_data.append({
'sector': sector,
'metric': f'指标{j+1}',
'value': (i + j) % 10 + 1
})
return jsonify({
'success': True,
'data': heat_map_data
})
except Exception as e:
print(f"Error getting heat map data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/correlation-analysis', methods=['GET'])
def get_correlation_analysis():
"""获取相关性分析"""
try:
date = request.args.get('date', '2025-07-16')
# 模拟相关性分析数据
correlation_data = {
'sector_correlations': [
{'sector1': '科技', 'sector2': '新能源', 'correlation': 0.85},
{'sector1': '医药', 'sector2': '消费', 'correlation': 0.72},
{'sector1': '金融', 'sector2': '地产', 'correlation': 0.68},
{'sector1': '科技', 'sector2': '医药', 'correlation': 0.45},
{'sector1': '新能源', 'sector2': '制造', 'correlation': 0.78}
],
'stock_correlations': [
{'stock1': '000001', 'stock2': '000002', 'correlation': 0.92},
{'stock1': '000003', 'stock2': '000004', 'correlation': 0.88},
{'stock1': '000005', 'stock2': '000006', 'correlation': 0.76}
]
}
return jsonify({
'success': True,
'data': correlation_data
})
except Exception as e:
print(f"Error getting correlation analysis: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/export-data', methods=['POST'])
def export_data():
"""导出数据"""
try:
data = request.get_json()
date = data.get('date', '2025-07-16')
export_type = data.get('type', 'excel')
# 模拟导出
filename = f'limit_analyse_{date}.{export_type}'
return jsonify({
'success': True,
'message': '数据导出成功',
'data': {
'filename': filename,
'download_url': f'/downloads/{filename}'
}
})
except Exception as e:
print(f"Error exporting data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/high-position-stocks', methods=['GET'])
def get_high_position_stocks():
"""获取高位股统计数据"""
try:
date = request.args.get('date', datetime.now().strftime('%Y%m%d'))
# 模拟高位股数据 - 实际使用时需要连接真实的数据库
# 根据用户提供的表结构,查询连续涨停天数较多的股票
high_position_stocks = [
{
'stock_code': '000001',
'stock_name': '平安银行',
'price': 15.68,
'increase_rate': 10.02,
'limit_up_days': 5,
'continuous_limit_up': 3,
'industry': '银行',
'turnover_rate': 3.45,
'market_cap': 32000000000
},
{
'stock_code': '000002',
'stock_name': '万科A',
'price': 18.92,
'increase_rate': 9.98,
'limit_up_days': 4,
'continuous_limit_up': 2,
'industry': '房地产',
'turnover_rate': 5.67,
'market_cap': 21000000000
},
{
'stock_code': '600036',
'stock_name': '招商银行',
'price': 42.15,
'increase_rate': 8.45,
'limit_up_days': 6,
'continuous_limit_up': 4,
'industry': '银行',
'turnover_rate': 2.89,
'market_cap': 105000000000
},
{
'stock_code': '000858',
'stock_name': '五粮液',
'price': 168.50,
'increase_rate': 7.23,
'limit_up_days': 3,
'continuous_limit_up': 2,
'industry': '白酒',
'turnover_rate': 1.56,
'market_cap': 650000000000
},
{
'stock_code': '002415',
'stock_name': '海康威视',
'price': 35.68,
'increase_rate': 6.89,
'limit_up_days': 4,
'continuous_limit_up': 3,
'industry': '安防',
'turnover_rate': 4.12,
'market_cap': 33000000000
}
]
# 统计信息
total_count = len(high_position_stocks)
avg_continuous_days = sum(stock['continuous_limit_up'] for stock in high_position_stocks) / total_count if total_count > 0 else 0
# 按连续涨停天数排序
high_position_stocks.sort(key=lambda x: x['continuous_limit_up'], reverse=True)
return jsonify({
'success': True,
'data': {
'stocks': high_position_stocks,
'statistics': {
'total_count': total_count,
'avg_continuous_days': round(avg_continuous_days, 2),
'max_continuous_days': max([stock['continuous_limit_up'] for stock in high_position_stocks], default=0),
'industry_distribution': {}
}
}
})
except Exception as e:
print(f"Error getting high position stocks: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1,241 +0,0 @@
from flask import Blueprint, request, jsonify
from app import db
from clickhouse_driver import Client
import pandas as pd
from datetime import datetime, timedelta
import pytz
bp = Blueprint('stocks', __name__, url_prefix='/api/stock')
def get_clickhouse_client():
"""获取ClickHouse客户端"""
return Client('localhost', port=9000, user='default', password='', database='default')
@bp.route('/quotes', methods=['GET', 'POST'])
def get_stock_quotes():
"""获取股票实时报价"""
try:
if request.method == 'GET':
# GET 请求从 URL 参数获取数据
codes = request.args.get('codes', '').split(',')
event_time_str = request.args.get('event_time')
else:
# POST 请求从 JSON 获取数据
codes = request.json.get('codes', [])
event_time_str = request.json.get('event_time')
if not codes:
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
# 过滤空字符串
codes = [code.strip() for code in codes if code.strip()]
if not codes:
return jsonify({'success': False, 'error': '请提供有效的股票代码'}), 400
# 解析事件时间
event_time = None
if event_time_str:
try:
event_time = datetime.fromisoformat(event_time_str.replace('Z', '+00:00'))
except ValueError:
return jsonify({'success': False, 'error': '事件时间格式错误'}), 400
# 获取当前时间
now = datetime.now(pytz.timezone('Asia/Shanghai'))
# 如果提供了事件时间,使用事件时间;否则使用当前时间
target_time = event_time if event_time else now
# 获取交易日和交易时间
def get_trading_day_and_times(event_datetime):
"""获取交易日和交易时间列表"""
# 这里简化处理,实际应该查询交易日历
trading_day = event_datetime.strftime('%Y-%m-%d')
# 生成交易时间列表 (9:30-11:30, 13:00-15:00)
morning_times = [f"{trading_day} {hour:02d}:{minute:02d}"
for hour in range(9, 12)
for minute in range(0, 60, 1)
if not (hour == 9 and minute < 30) and not (hour == 11 and minute > 30)]
afternoon_times = [f"{trading_day} {hour:02d}:{minute:02d}"
for hour in range(13, 16)
for minute in range(0, 60, 1)]
return trading_day, morning_times + afternoon_times
trading_day, trading_times = get_trading_day_and_times(target_time)
# 模拟股票数据
results = {}
for code in codes:
# 这里应该从ClickHouse或其他数据源获取真实数据
# 现在使用模拟数据
import random
base_price = 10.0 + random.random() * 20.0
change = (random.random() - 0.5) * 2.0
results[code] = {
'price': round(base_price, 2),
'change': round(change, 2),
'name': f'股票{code}'
}
return jsonify({
'success': True,
'data': results
})
except Exception as e:
print(f"Error getting stock quotes: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/<stock_code>/kline')
def get_stock_kline(stock_code):
"""获取股票K线数据"""
try:
chart_type = request.args.get('type', 'daily')
event_time_str = request.args.get('event_time')
if not event_time_str:
return jsonify({'success': False, 'error': '请提供事件时间'}), 400
try:
event_datetime = datetime.fromisoformat(event_time_str.replace('Z', '+00:00'))
except ValueError:
return jsonify({'success': False, 'error': '事件时间格式错误'}), 400
# 获取股票名称(这里简化处理)
stock_name = f'股票{stock_code}'
if chart_type == 'daily':
return get_daily_kline(stock_code, event_datetime, stock_name)
elif chart_type == 'minute':
return get_minute_kline(stock_code, event_datetime, stock_name)
elif chart_type == 'timeline':
return get_timeline_data(stock_code, event_datetime, stock_name)
else:
return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400
except Exception as e:
print(f"Error getting stock kline: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def get_daily_kline(stock_code, event_datetime, stock_name):
"""获取日K线数据"""
try:
# 模拟日K线数据
data = []
base_price = 10.0
for i in range(30):
date = (event_datetime - timedelta(days=30-i)).strftime('%Y-%m-%d')
open_price = base_price + (i * 0.1) + (i % 3 - 1) * 0.5
close_price = open_price + (i % 5 - 2) * 0.3
high_price = max(open_price, close_price) + 0.2
low_price = min(open_price, close_price) - 0.2
volume = 1000000 + i * 50000
data.append({
'date': date,
'open': round(open_price, 2),
'close': round(close_price, 2),
'high': round(high_price, 2),
'low': round(low_price, 2),
'volume': volume
})
return jsonify({
'code': stock_code,
'name': stock_name,
'trade_date': event_datetime.strftime('%Y-%m-%d'),
'data': data
})
except Exception as e:
print(f"Error getting daily kline: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def get_minute_kline(stock_code, event_datetime, stock_name):
"""获取分钟K线数据"""
try:
# 模拟分钟K线数据
data = []
base_price = 10.0
trading_times = []
# 生成交易时间
for hour in range(9, 16):
if hour == 12:
continue
for minute in range(0, 60):
if (hour == 9 and minute < 30) or (hour == 11 and minute > 30):
continue
trading_times.append(f"{hour:02d}:{minute:02d}")
for i, time in enumerate(trading_times):
open_price = base_price + (i * 0.01) + (i % 10 - 5) * 0.02
close_price = open_price + (i % 7 - 3) * 0.01
high_price = max(open_price, close_price) + 0.01
low_price = min(open_price, close_price) - 0.01
volume = 50000 + i * 1000
data.append({
'time': time,
'open': round(open_price, 2),
'close': round(close_price, 2),
'high': round(high_price, 2),
'low': round(low_price, 2),
'volume': volume
})
return jsonify({
'code': stock_code,
'name': stock_name,
'trade_date': event_datetime.strftime('%Y-%m-%d'),
'data': data
})
except Exception as e:
print(f"Error getting minute kline: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def get_timeline_data(stock_code, event_datetime, stock_name):
"""获取分时图数据"""
try:
# 模拟分时图数据
data = []
base_price = 10.0
trading_times = []
# 生成交易时间
for hour in range(9, 16):
if hour == 12:
continue
for minute in range(0, 60):
if (hour == 9 and minute < 30) or (hour == 11 and minute > 30):
continue
trading_times.append(f"{hour:02d}:{minute:02d}")
for i, time in enumerate(trading_times):
price = base_price + (i * 0.01) + (i % 10 - 5) * 0.02
avg_price = price + (i % 5 - 2) * 0.01
volume = 50000 + i * 1000
data.append({
'time': time,
'price': round(price, 2),
'avg_price': round(avg_price, 2),
'volume': volume
})
return jsonify({
'code': stock_code,
'name': stock_name,
'trade_date': event_datetime.strftime('%Y-%m-%d'),
'data': data
})
except Exception as e:
print(f"Error getting timeline data: {e}")
return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -97,7 +97,9 @@
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
"test": "craco test --env=jsdom",
"eject": "react-scripts eject",
"deploy": "npm run build",
"deploy": "bash scripts/deploy-from-local.sh",
"deploy:setup": "bash scripts/setup-deployment.sh",
"rollback": "bash scripts/rollback-from-local.sh",
"lint:check": "eslint . --ext=js,jsx; exit 0",
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
"install:clean": "rm -rf node_modules/ && rm -rf package-lock.json && npm install && npm start"

392
scripts/deploy-from-local.sh Executable file
View File

@@ -0,0 +1,392 @@
#!/bin/bash
###############################################################################
# 本地部署脚本
# 在本地运行,通过 SSH 连接服务器并执行部署
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
###############################################################################
# 函数:打印带颜色的消息
###############################################################################
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
log_error() { echo -e "${RED}[✗]${NC} $1"; }
log_step() { echo -e "${CYAN}${BOLD}[$1]${NC} $2"; }
###############################################################################
# 函数:加载配置文件
###############################################################################
load_config() {
if [ ! -f "$PROJECT_ROOT/.env.deploy" ]; then
log_error "配置文件不存在: $PROJECT_ROOT/.env.deploy"
echo ""
echo "请先运行以下命令进行配置:"
echo " npm run deploy:setup"
echo ""
echo "或者手动创建配置文件:"
echo " cp .env.deploy.example .env.deploy"
echo ""
exit 1
fi
# 加载配置
source "$PROJECT_ROOT/.env.deploy"
# 检查必需的配置项
if [ -z "$SERVER_HOST" ] || [ -z "$SERVER_USER" ]; then
log_error "配置不完整,请检查 .env.deploy 文件"
echo "必需配置项:"
echo " - SERVER_HOST: 服务器地址"
echo " - SERVER_USER: SSH 用户名"
exit 1
fi
log_success "配置加载完成"
}
###############################################################################
# 函数:检查本地 Git 状态
###############################################################################
check_local_git() {
log_step "1/8" "检查本地代码"
cd "$PROJECT_ROOT"
# 检查是否是 Git 仓库
if [ ! -d ".git" ]; then
log_error "当前目录不是 Git 仓库"
exit 1
fi
# 获取当前分支
local current_branch=$(git branch --show-current)
log_info "当前分支: $current_branch"
# 检查是否有未提交的更改
if ! git diff-index --quiet HEAD --; then
log_warning "存在未提交的更改"
echo ""
git status --short
echo ""
read -p "是否继续部署? (y/n): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "部署已取消"
exit 0
fi
fi
# 获取最新提交信息
COMMIT_HASH=$(git rev-parse --short HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1)
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
log_info "最新提交: $COMMIT_HASH - $COMMIT_MESSAGE"
log_info "提交作者: $COMMIT_AUTHOR"
log_success "本地代码检查完成"
echo ""
}
###############################################################################
# 函数:显示部署预览
###############################################################################
show_deploy_preview() {
log_step "2/8" "部署预览"
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 部署预览 ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo -e "${BOLD}项目信息:${NC}"
echo " 项目名称: vf_react"
echo " 部署环境: 生产环境"
echo " 目标服务器: $SERVER_USER@$SERVER_HOST"
echo ""
echo -e "${BOLD}代码信息:${NC}"
echo " 当前分支: $(git branch --show-current)"
echo " 提交版本: $COMMIT_HASH"
echo " 提交信息: $COMMIT_MESSAGE"
echo " 提交作者: $COMMIT_AUTHOR"
echo ""
echo -e "${BOLD}部署路径:${NC}"
echo " Git 仓库: $REMOTE_PROJECT_PATH"
echo " 生产目录: $PRODUCTION_PATH"
echo ""
echo "════════════════════════════════════════════════════════════════"
echo ""
# 询问是否继续
read -p "确认部署到生产环境? (yes/no): " -r
echo ""
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
log_info "部署已取消"
exit 0
fi
}
###############################################################################
# 函数:测试 SSH 连接
###############################################################################
test_ssh_connection() {
log_step "3/8" "测试 SSH 连接"
local ssh_options="-o ConnectTimeout=${SSH_TIMEOUT:-30} -o BatchMode=yes"
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="$ssh_options -i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ]; then
ssh_options="$ssh_options -p $SERVER_PORT"
fi
# 测试连接
if ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "echo 'SSH 连接成功'" > /dev/null 2>&1; then
log_success "SSH 连接成功"
else
log_error "SSH 连接失败"
echo ""
echo "请检查:"
echo " 1. 服务器地址是否正确: $SERVER_HOST"
echo " 2. SSH 用户名是否正确: $SERVER_USER"
echo " 3. SSH 密钥是否配置正确"
echo " 4. 服务器端口是否正确: ${SERVER_PORT:-22}"
exit 1
fi
echo ""
}
###############################################################################
# 函数:上传服务器端脚本
###############################################################################
upload_server_scripts() {
log_step "4/8" "上传部署脚本"
local ssh_options=""
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="-i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ]; then
ssh_options="$ssh_options -P $SERVER_PORT"
fi
# 创建远程脚本目录
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "mkdir -p /tmp/deploy-scripts" || {
log_error "创建远程目录失败"
exit 1
}
# 上传脚本
scp $ssh_options \
"$SCRIPT_DIR/deploy-on-server.sh" \
"$SCRIPT_DIR/rollback-on-server.sh" \
"$SCRIPT_DIR/notify-wechat.sh" \
"$SERVER_USER@$SERVER_HOST":/tmp/deploy-scripts/ > /dev/null 2>&1 || {
log_error "上传脚本失败"
exit 1
}
# 设置执行权限
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "chmod +x /tmp/deploy-scripts/*.sh" || {
log_error "设置脚本权限失败"
exit 1
}
log_success "部署脚本上传完成"
echo ""
}
###############################################################################
# 函数:执行服务器端部署
###############################################################################
execute_remote_deployment() {
log_step "5/8" "执行远程部署"
echo ""
local ssh_options=""
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="-i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ]; then
ssh_options="$ssh_options -p $SERVER_PORT"
fi
# 构建环境变量
local env_vars="REMOTE_PROJECT_PATH=$REMOTE_PROJECT_PATH "
env_vars+="PRODUCTION_PATH=$PRODUCTION_PATH "
env_vars+="BACKUP_DIR=$BACKUP_DIR "
env_vars+="LOG_DIR=$LOG_DIR "
env_vars+="DEPLOY_BRANCH=$DEPLOY_BRANCH "
env_vars+="KEEP_BACKUPS=$KEEP_BACKUPS "
env_vars+="RUN_NPM_INSTALL=$RUN_NPM_INSTALL"
# 记录开始时间
DEPLOY_START_TIME=$(date +%s)
# 执行部署脚本
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "$env_vars bash /tmp/deploy-scripts/deploy-on-server.sh" || {
log_error "远程部署失败"
send_failure_notification "部署脚本执行失败"
exit 1
}
# 记录结束时间
DEPLOY_END_TIME=$(date +%s)
DEPLOY_DURATION=$((DEPLOY_END_TIME - DEPLOY_START_TIME))
echo ""
log_success "远程部署完成"
echo ""
}
###############################################################################
# 函数:发送成功通知
###############################################################################
send_success_notification() {
log_step "6/8" "发送部署通知"
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
local minutes=$((DEPLOY_DURATION / 60))
local seconds=$((DEPLOY_DURATION % 60))
local duration="${minutes}${seconds}"
bash "$SCRIPT_DIR/notify-wechat.sh" success \
"$DEPLOY_BRANCH" \
"$COMMIT_HASH" \
"$COMMIT_MESSAGE" \
"$duration" \
"$USER" || {
log_warning "企业微信通知发送失败"
}
else
log_info "企业微信通知未启用"
fi
echo ""
}
###############################################################################
# 函数:发送失败通知
###############################################################################
send_failure_notification() {
local error_message="$1"
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
bash "$SCRIPT_DIR/notify-wechat.sh" failure \
"$DEPLOY_BRANCH" \
"$error_message" \
"$USER" 2>/dev/null || true
fi
}
###############################################################################
# 函数:清理临时文件
###############################################################################
cleanup() {
log_step "7/8" "清理临时文件"
local ssh_options=""
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="-i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ]; then
ssh_options="$ssh_options -p $SERVER_PORT"
fi
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "rm -rf /tmp/deploy-scripts" > /dev/null 2>&1 || true
log_success "清理完成"
echo ""
}
###############################################################################
# 函数:显示部署结果
###############################################################################
show_deployment_result() {
log_step "8/8" "部署完成"
local minutes=$((DEPLOY_DURATION / 60))
local seconds=$((DEPLOY_DURATION % 60))
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 🎉 部署成功! ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo -e "${BOLD}部署信息:${NC}"
echo " 版本: $COMMIT_HASH"
echo " 分支: $DEPLOY_BRANCH"
echo " 提交: $COMMIT_MESSAGE"
echo " 作者: $COMMIT_AUTHOR"
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo " 耗时: ${minutes}${seconds}"
echo ""
echo -e "${BOLD}访问地址:${NC}"
echo " https://valuefrontier.cn"
echo ""
echo "════════════════════════════════════════════════════════════════"
echo ""
}
###############################################################################
# 主函数
###############################################################################
main() {
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ VF React - 生产环境部署工具 ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# 加载配置
load_config
# 检查本地 Git 状态
check_local_git
# 显示部署预览
show_deploy_preview
# 测试 SSH 连接
test_ssh_connection
# 上传服务器端脚本
upload_server_scripts
# 执行远程部署
execute_remote_deployment
# 发送成功通知
send_success_notification
# 清理临时文件
cleanup
# 显示部署结果
show_deployment_result
}
# 错误处理
trap 'log_error "部署过程中发生错误"; send_failure_notification "部署异常中断"; exit 1' ERR
# 执行主函数
main "$@"

313
scripts/deploy-on-server.sh Executable file
View File

@@ -0,0 +1,313 @@
#!/bin/bash
###############################################################################
# 服务器端部署脚本
# 此脚本在服务器上执行,由本地部署脚本远程调用
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
###############################################################################
# 配置变量(通过环境变量传入)
###############################################################################
PROJECT_PATH="${REMOTE_PROJECT_PATH:-/home/ubuntu/vf_react}"
PRODUCTION_PATH="${PRODUCTION_PATH:-/var/www/valuefrontier.cn}"
BACKUP_DIR="${BACKUP_DIR:-/home/ubuntu/deployments}"
LOG_DIR="${LOG_DIR:-/home/ubuntu/deploy-logs}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-feature}"
KEEP_BACKUPS="${KEEP_BACKUPS:-5}"
RUN_NPM_INSTALL="${RUN_NPM_INSTALL:-true}"
###############################################################################
# 函数:打印带颜色的消息
###############################################################################
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
###############################################################################
# 函数:创建必要的目录
###############################################################################
create_directories() {
log_info "创建必要的目录..."
mkdir -p "$BACKUP_DIR"
mkdir -p "$LOG_DIR"
mkdir -p "$PRODUCTION_PATH"
log_success "目录创建完成"
}
###############################################################################
# 函数:检查 Git 仓库
###############################################################################
check_git_repo() {
log_info "检查 Git 仓库..."
if [ ! -d "$PROJECT_PATH/.git" ]; then
log_error "Git 仓库不存在: $PROJECT_PATH"
exit 1
fi
cd "$PROJECT_PATH"
log_success "Git 仓库检查通过"
}
###############################################################################
# 函数:切换到目标分支
###############################################################################
checkout_branch() {
log_info "切换到 $DEPLOY_BRANCH 分支..."
cd "$PROJECT_PATH"
# 获取当前分支
current_branch=$(git branch --show-current)
if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
log_warning "当前分支是 $current_branch,正在切换到 $DEPLOY_BRANCH..."
git checkout "$DEPLOY_BRANCH" || {
log_error "切换分支失败"
exit 1
}
fi
log_success "已在 $DEPLOY_BRANCH 分支"
}
###############################################################################
# 函数:拉取最新代码
###############################################################################
pull_latest_code() {
log_info "拉取最新代码..."
cd "$PROJECT_PATH"
# 保存本地修改(如果有)
if ! git diff-index --quiet HEAD --; then
log_warning "检测到本地修改,正在暂存..."
git stash
fi
# 拉取最新代码
git pull origin "$DEPLOY_BRANCH" || {
log_error "拉取代码失败"
exit 1
}
log_success "代码更新完成"
}
###############################################################################
# 函数:获取当前提交信息
###############################################################################
get_commit_info() {
cd "$PROJECT_PATH"
COMMIT_HASH=$(git rev-parse --short HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1)
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
COMMIT_TIME=$(git log -1 --pretty=%cd --date=format:'%Y-%m-%d %H:%M:%S')
echo "提交哈希: $COMMIT_HASH"
echo "提交信息: $COMMIT_MESSAGE"
echo "提交作者: $COMMIT_AUTHOR"
echo "提交时间: $COMMIT_TIME"
}
###############################################################################
# 函数:安装依赖
###############################################################################
install_dependencies() {
if [ "$RUN_NPM_INSTALL" = "true" ]; then
log_info "安装依赖..."
cd "$PROJECT_PATH"
# 检查 package.json 是否变化
if git diff HEAD@{1} HEAD --name-only | grep -q "package.json"; then
log_info "package.json 有变化,执行 npm install..."
npm install || {
log_error "依赖安装失败"
exit 1
}
else
log_info "package.json 无变化,跳过 npm install"
fi
log_success "依赖检查完成"
else
log_info "跳过依赖安装 (RUN_NPM_INSTALL=false)"
fi
}
###############################################################################
# 函数:构建项目
###############################################################################
build_project() {
log_info "构建项目..."
cd "$PROJECT_PATH"
# 执行构建
npm run build || {
log_error "构建失败"
exit 1
}
# 检查构建产物
if [ ! -d "$PROJECT_PATH/build" ]; then
log_error "构建产物不存在"
exit 1
fi
log_success "构建完成"
}
###############################################################################
# 函数:备份当前版本
###############################################################################
backup_current_version() {
log_info "备份当前版本..."
local timestamp=$(date +%Y%m%d-%H%M%S)
local backup_path="$BACKUP_DIR/backup-$timestamp"
if [ -d "$PRODUCTION_PATH" ] && [ "$(ls -A $PRODUCTION_PATH)" ]; then
mkdir -p "$backup_path"
cp -r "$PRODUCTION_PATH"/* "$backup_path/" || {
log_error "备份失败"
exit 1
}
# 创建符号链接指向当前版本
ln -snf "$backup_path" "$BACKUP_DIR/current"
log_success "备份完成: $backup_path"
echo "$backup_path"
else
log_warning "生产目录为空,跳过备份"
echo "no-backup"
fi
}
###############################################################################
# 函数:清理旧备份
###############################################################################
cleanup_old_backups() {
log_info "清理旧备份..."
cd "$BACKUP_DIR"
# 获取所有备份目录(排除 current 符号链接)
local backup_count=$(find . -maxdepth 1 -type d -name "backup-*" | wc -l)
if [ "$backup_count" -gt "$KEEP_BACKUPS" ]; then
local to_delete=$((backup_count - KEEP_BACKUPS))
log_info "当前有 $backup_count 个备份,保留 $KEEP_BACKUPS 个,删除 $to_delete"
find . -maxdepth 1 -type d -name "backup-*" | sort | head -n "$to_delete" | while read dir; do
log_info "删除旧备份: $dir"
rm -rf "$dir"
done
log_success "旧备份清理完成"
else
log_info "当前有 $backup_count 个备份,无需清理"
fi
}
###############################################################################
# 函数:部署到生产环境
###############################################################################
deploy_to_production() {
log_info "部署到生产环境..."
# 清空生产目录
log_info "清空生产目录: $PRODUCTION_PATH"
rm -rf "$PRODUCTION_PATH"/*
# 复制构建产物
log_info "复制构建产物..."
cp -r "$PROJECT_PATH/build"/* "$PRODUCTION_PATH/" || {
log_error "复制文件失败"
exit 1
}
# 设置权限
chmod -R 755 "$PRODUCTION_PATH"
log_success "部署完成"
}
###############################################################################
# 主函数
###############################################################################
main() {
local start_time=$(date +%s)
echo ""
echo "========================================"
echo " 服务器端部署脚本"
echo "========================================"
echo ""
# 创建目录
create_directories
# 检查 Git 仓库
check_git_repo
# 切换分支
checkout_branch
# 拉取最新代码
pull_latest_code
# 获取提交信息
get_commit_info
# 安装依赖
install_dependencies
# 构建项目
build_project
# 备份当前版本
backup_path=$(backup_current_version)
# 部署到生产环境
deploy_to_production
# 清理旧备份
cleanup_old_backups
# 计算耗时
local end_time=$(date +%s)
local duration=$((end_time - start_time))
local minutes=$((duration / 60))
local seconds=$((duration % 60))
echo ""
echo "========================================"
echo " 部署成功!"
echo "========================================"
echo "提交: $COMMIT_HASH - $COMMIT_MESSAGE"
echo "备份: $backup_path"
echo "耗时: ${minutes}${seconds}"
echo ""
# 输出结果供本地脚本解析
echo "DEPLOY_SUCCESS=true"
echo "COMMIT_HASH=$COMMIT_HASH"
echo "COMMIT_MESSAGE=$COMMIT_MESSAGE"
echo "DEPLOY_DURATION=${minutes}${seconds}"
}
# 执行主函数
main "$@"

234
scripts/notify-wechat.sh Executable file
View File

@@ -0,0 +1,234 @@
#!/bin/bash
###############################################################################
# 企业微信通知脚本
# 用于发送部署成功/失败通知到企业微信群
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# 加载配置文件
if [ -f "$PROJECT_ROOT/.env.deploy" ]; then
source "$PROJECT_ROOT/.env.deploy"
else
echo -e "${YELLOW}警告: 配置文件 .env.deploy 不存在,跳过通知${NC}"
exit 0
fi
# 检查是否启用通知
if [ "$ENABLE_WECHAT_NOTIFY" != "true" ]; then
echo "企业微信通知未启用"
exit 0
fi
# 检查 Webhook URL
if [ -z "$WECHAT_WEBHOOK_URL" ]; then
echo -e "${YELLOW}警告: 未配置企业微信 Webhook URL${NC}"
exit 0
fi
###############################################################################
# 函数:发送文本消息
###############################################################################
send_text_message() {
local content="$1"
local mentioned_list="${2:-[]}"
local json_data=$(cat <<EOF
{
"msgtype": "text",
"text": {
"content": "$content",
"mentioned_list": $mentioned_list
}
}
EOF
)
# 发送 HTTP 请求
response=$(curl -s -w "\n%{http_code}" \
-H "Content-Type: application/json" \
-d "$json_data" \
"$WECHAT_WEBHOOK_URL")
# 提取 HTTP 状态码和响应体
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
echo -e "${GREEN}✓ 企业微信通知发送成功${NC}"
return 0
else
echo -e "${RED}✗ 企业微信通知发送失败 (HTTP $http_code)${NC}"
echo "响应: $body"
return 1
fi
}
###############################################################################
# 函数:发送 Markdown 消息
###############################################################################
send_markdown_message() {
local content="$1"
local json_data=$(cat <<EOF
{
"msgtype": "markdown",
"markdown": {
"content": "$content"
}
}
EOF
)
# 发送 HTTP 请求
response=$(curl -s -w "\n%{http_code}" \
-H "Content-Type: application/json" \
-d "$json_data" \
"$WECHAT_WEBHOOK_URL")
# 提取 HTTP 状态码和响应体
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
echo -e "${GREEN}✓ 企业微信通知发送成功${NC}"
return 0
else
echo -e "${RED}✗ 企业微信通知发送失败 (HTTP $http_code)${NC}"
echo "响应: $body"
return 1
fi
}
###############################################################################
# 函数:部署成功通知
###############################################################################
notify_deploy_success() {
local branch="$1"
local commit="$2"
local message="$3"
local duration="$4"
local operator="${5:-unknown}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local content="【生产环境部署成功】
项目vf_react
环境:生产环境
分支:$branch
版本:$commit
提交信息:$message
部署时间:$timestamp
部署耗时:$duration
操作人:$operator
访问地址https://valuefrontier.cn"
# 处理 mentioned_list
local mentioned_list="[]"
if [ -n "$WECHAT_MENTIONED_LIST" ]; then
if [ "$WECHAT_MENTIONED_LIST" = "@all" ]; then
mentioned_list='["@all"]'
else
# 假设是逗号分隔的手机号或 userid
mentioned_list="[\"$WECHAT_MENTIONED_LIST\"]"
fi
fi
send_text_message "$content" "$mentioned_list"
}
###############################################################################
# 函数:部署失败通知
###############################################################################
notify_deploy_failure() {
local branch="$1"
local error_message="$2"
local operator="${3:-unknown}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local content="【⚠️ 生产环境部署失败】
项目vf_react
环境:生产环境
分支:$branch
失败原因:$error_message
失败时间:$timestamp
操作人:$operator
已自动回滚到上一版本"
# 处理 mentioned_list
local mentioned_list="[]"
if [ -n "$WECHAT_MENTIONED_LIST" ]; then
if [ "$WECHAT_MENTIONED_LIST" = "@all" ]; then
mentioned_list='["@all"]'
else
mentioned_list="[\"$WECHAT_MENTIONED_LIST\"]"
fi
fi
send_text_message "$content" "$mentioned_list"
}
###############################################################################
# 函数:回滚成功通知
###############################################################################
notify_rollback_success() {
local version="$1"
local operator="${2:-unknown}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local content="【版本回滚成功】
项目vf_react
环境:生产环境
回滚版本:$version
回滚时间:$timestamp
操作人:$operator"
send_text_message "$content"
}
###############################################################################
# 主程序
###############################################################################
main() {
local action="${1:-}"
case "$action" in
success)
notify_deploy_success "$2" "$3" "$4" "$5" "$6"
;;
failure)
notify_deploy_failure "$2" "$3" "$4"
;;
rollback)
notify_rollback_success "$2" "$3"
;;
test)
send_text_message "企业微信通知测试消息\n发送时间: $(date '+%Y-%m-%d %H:%M:%S')"
;;
*)
echo "用法: $0 {success|failure|rollback|test} [参数...]"
echo ""
echo "示例:"
echo " $0 success feature abc123 'feat: 新功能' '2分15秒' ubuntu"
echo " $0 failure feature '构建失败' ubuntu"
echo " $0 rollback backup-20250121-143020 ubuntu"
echo " $0 test"
exit 1
;;
esac
}
main "$@"

187
scripts/rollback-from-local.sh Executable file
View File

@@ -0,0 +1,187 @@
#!/bin/bash
###############################################################################
# 本地回滚脚本
# 在本地运行,通过 SSH 连接服务器并执行回滚
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
###############################################################################
# 函数:打印带颜色的消息
###############################################################################
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
log_error() { echo -e "${RED}[✗]${NC} $1"; }
###############################################################################
# 函数:加载配置文件
###############################################################################
load_config() {
if [ ! -f "$PROJECT_ROOT/.env.deploy" ]; then
log_error "配置文件不存在: $PROJECT_ROOT/.env.deploy"
echo ""
echo "请先运行以下命令进行配置:"
echo " npm run deploy:setup"
exit 1
fi
source "$PROJECT_ROOT/.env.deploy"
if [ -z "$SERVER_HOST" ] || [ -z "$SERVER_USER" ]; then
log_error "配置不完整,请检查 .env.deploy 文件"
exit 1
fi
log_success "配置加载完成"
}
###############################################################################
# 函数:列出可回滚的版本
###############################################################################
list_backup_versions() {
echo ""
echo "正在获取可用的备份版本..."
echo ""
local ssh_options=""
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="-i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ]; then
ssh_options="$ssh_options -p $SERVER_PORT"
fi
# 上传回滚脚本
scp $ssh_options -q \
"$SCRIPT_DIR/rollback-on-server.sh" \
"$SERVER_USER@$SERVER_HOST":/tmp/ || {
log_error "上传回滚脚本失败"
exit 1
}
# 执行列表命令
local env_vars="PRODUCTION_PATH=$PRODUCTION_PATH BACKUP_DIR=$BACKUP_DIR"
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "$env_vars bash /tmp/rollback-on-server.sh list"
}
###############################################################################
# 函数:执行回滚
###############################################################################
execute_rollback() {
local version_index="${1:-1}"
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 版本回滚工具 ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# 列出可用版本
list_backup_versions
# 询问确认
echo ""
read -p "确认回滚到版本 #$version_index? (yes/no): " -r
echo ""
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
log_info "回滚已取消"
exit 0
fi
# SSH 选项
local ssh_options=""
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="-i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ]; then
ssh_options="$ssh_options -p $SERVER_PORT"
fi
log_info "正在执行回滚..."
echo ""
# 执行回滚命令
local env_vars="PRODUCTION_PATH=$PRODUCTION_PATH BACKUP_DIR=$BACKUP_DIR"
local rollback_output=$(ssh $ssh_options "$SERVER_USER@$SERVER_HOST" \
"$env_vars bash /tmp/rollback-on-server.sh rollback $version_index" 2>&1)
if echo "$rollback_output" | grep -q "ROLLBACK_SUCCESS=true"; then
# 提取回滚版本
local rollback_version=$(echo "$rollback_output" | grep "ROLLBACK_VERSION=" | cut -d= -f2)
# 发送通知
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
bash "$SCRIPT_DIR/notify-wechat.sh" rollback \
"$rollback_version" \
"$USER" || {
log_warning "企业微信通知发送失败"
}
fi
# 显示结果
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 🎉 回滚成功! ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo -e "${BOLD}回滚信息:${NC}"
echo " 目标版本: $rollback_version"
echo " 回滚时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
echo -e "${BOLD}访问地址:${NC}"
echo " https://valuefrontier.cn"
echo ""
echo "════════════════════════════════════════════════════════════════"
echo ""
log_success "回滚完成"
else
log_error "回滚失败"
echo "$rollback_output"
exit 1
fi
# 清理临时文件
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "rm -f /tmp/rollback-on-server.sh" > /dev/null 2>&1 || true
}
###############################################################################
# 主函数
###############################################################################
main() {
local action="${1:-rollback}"
local version_index="${2:-1}"
# 加载配置
load_config
case "$action" in
list)
list_backup_versions
;;
rollback|*)
execute_rollback "$version_index"
;;
esac
}
# 错误处理
trap 'log_error "回滚过程中发生错误"; exit 1' ERR
# 执行主函数
main "$@"

176
scripts/rollback-on-server.sh Executable file
View File

@@ -0,0 +1,176 @@
#!/bin/bash
###############################################################################
# 服务器端回滚脚本
# 此脚本在服务器上执行,由本地回滚脚本远程调用
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
###############################################################################
# 配置变量(通过环境变量传入)
###############################################################################
PRODUCTION_PATH="${PRODUCTION_PATH:-/var/www/valuefrontier.cn}"
BACKUP_DIR="${BACKUP_DIR:-/home/ubuntu/deployments}"
###############################################################################
# 函数:打印带颜色的消息
###############################################################################
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
###############################################################################
# 函数:列出可用的备份版本
###############################################################################
list_backups() {
log_info "可用的备份版本:"
echo ""
if [ ! -d "$BACKUP_DIR" ]; then
log_error "备份目录不存在: $BACKUP_DIR"
return 1
fi
cd "$BACKUP_DIR"
# 获取所有备份目录,按时间倒序
local backups=($(find . -maxdepth 1 -type d -name "backup-*" | sort -r))
if [ ${#backups[@]} -eq 0 ]; then
log_warning "没有可用的备份版本"
return 1
fi
local index=1
for backup in "${backups[@]}"; do
local backup_name=$(basename "$backup")
local backup_time=$(echo "$backup_name" | sed 's/backup-//' | sed 's/-/ /')
local is_current=""
# 检查是否是当前版本
if [ -L "$BACKUP_DIR/current" ]; then
local current_link=$(readlink "$BACKUP_DIR/current")
if [ "$current_link" = "$backup" ] || [ "$current_link" = "$BACKUP_DIR/$backup_name" ]; then
is_current=" ${GREEN}[当前版本]${NC}"
fi
fi
echo -e " $index. $backup_name ($backup_time)$is_current"
((index++))
done
echo ""
return 0
}
###############################################################################
# 函数:回滚到指定版本
###############################################################################
rollback_to_version() {
local version_index="${1:-1}"
log_info "开始回滚到版本 #$version_index..."
if [ ! -d "$BACKUP_DIR" ]; then
log_error "备份目录不存在: $BACKUP_DIR"
exit 1
fi
cd "$BACKUP_DIR"
# 获取所有备份目录,按时间倒序
local backups=($(find . -maxdepth 1 -type d -name "backup-*" | sort -r))
if [ ${#backups[@]} -eq 0 ]; then
log_error "没有可用的备份版本"
exit 1
fi
# 检查索引是否有效
if [ "$version_index" -lt 1 ] || [ "$version_index" -gt "${#backups[@]}" ]; then
log_error "无效的版本索引: $version_index (可用范围: 1-${#backups[@]})"
exit 1
fi
# 获取目标备份
local target_index=$((version_index - 1))
local target_backup="${backups[$target_index]}"
local backup_name=$(basename "$target_backup")
log_info "目标版本: $backup_name"
# 检查备份是否存在
if [ ! -d "$target_backup" ]; then
log_error "备份版本不存在: $target_backup"
exit 1
fi
# 清空生产目录
log_info "清空生产目录: $PRODUCTION_PATH"
rm -rf "$PRODUCTION_PATH"/*
# 恢复备份
log_info "恢复备份文件..."
cp -r "$target_backup"/* "$PRODUCTION_PATH/" || {
log_error "恢复备份失败"
exit 1
}
# 设置权限
chmod -R 755 "$PRODUCTION_PATH"
# 更新 current 符号链接
ln -snf "$target_backup" "$BACKUP_DIR/current"
log_success "回滚完成"
echo ""
echo "========================================"
echo " 回滚成功!"
echo "========================================"
echo "目标版本: $backup_name"
echo ""
# 输出结果供本地脚本解析
echo "ROLLBACK_SUCCESS=true"
echo "ROLLBACK_VERSION=$backup_name"
}
###############################################################################
# 主函数
###############################################################################
main() {
local action="${1:-list}"
echo ""
echo "========================================"
echo " 服务器端回滚脚本"
echo "========================================"
echo ""
case "$action" in
list)
list_backups
;;
rollback)
local version_index="${2:-1}"
rollback_to_version "$version_index"
;;
*)
log_error "未知操作: $action"
echo "用法: $0 {list|rollback} [version_index]"
exit 1
;;
esac
}
# 执行主函数
main "$@"

407
scripts/setup-deployment.sh Executable file
View File

@@ -0,0 +1,407 @@
#!/bin/bash
###############################################################################
# 部署配置向导
# 首次使用时运行,引导用户完成部署配置
###############################################################################
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
CONFIG_FILE="$PROJECT_ROOT/.env.deploy"
EXAMPLE_FILE="$PROJECT_ROOT/.env.deploy.example"
###############################################################################
# 函数:打印带颜色的消息
###############################################################################
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
log_error() { echo -e "${RED}[✗]${NC} $1"; }
log_step() { echo -e "${CYAN}${BOLD}[$1]${NC} $2"; }
###############################################################################
# 函数:读取用户输入(带默认值)
###############################################################################
read_input() {
local prompt="$1"
local default="$2"
local result
if [ -n "$default" ]; then
read -p "$prompt [$default]: " result
echo "${result:-$default}"
else
read -p "$prompt: " result
echo "$result"
fi
}
###############################################################################
# 函数:读取密码(隐藏输入)
###############################################################################
read_password() {
local prompt="$1"
local result
read -sp "$prompt: " result
echo ""
echo "$result"
}
###############################################################################
# 函数:测试 SSH 连接
###############################################################################
test_ssh() {
local host="$1"
local user="$2"
local port="$3"
local key_path="$4"
local ssh_options="-o ConnectTimeout=10 -o BatchMode=yes"
if [ -n "$key_path" ]; then
ssh_options="$ssh_options -i $key_path"
fi
if [ -n "$port" ] && [ "$port" != "22" ]; then
ssh_options="$ssh_options -p $port"
fi
ssh $ssh_options "$user@$host" "echo 'SSH 连接测试成功'" 2>/dev/null
return $?
}
###############################################################################
# 函数:测试企业微信 Webhook
###############################################################################
test_wechat_webhook() {
local webhook_url="$1"
local test_message='{"msgtype":"text","text":{"content":"企业微信通知测试\n发送时间: '$(date +"%Y-%m-%d %H:%M:%S")'"}}'
local response=$(curl -s -w "\n%{http_code}" \
-H "Content-Type: application/json" \
-d "$test_message" \
"$webhook_url")
local http_code=$(echo "$response" | tail -n1)
if [ "$http_code" -eq 200 ]; then
return 0
else
return 1
fi
}
###############################################################################
# 函数:显示欢迎信息
###############################################################################
show_welcome() {
clear
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ VF React 部署配置向导 ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo "本向导将帮助您完成以下配置:"
echo " 1. 服务器连接配置 (SSH)"
echo " 2. 部署路径配置"
echo " 3. 企业微信通知配置 (可选)"
echo " 4. 初始化服务器环境"
echo ""
read -p "按 Enter 键继续..."
echo ""
}
###############################################################################
# 函数:配置服务器信息
###############################################################################
configure_server() {
log_step "1/4" "服务器配置"
echo ""
# 服务器地址
SERVER_HOST=$(read_input "请输入服务器 IP 或域名")
while [ -z "$SERVER_HOST" ]; do
log_error "服务器地址不能为空"
SERVER_HOST=$(read_input "请输入服务器 IP 或域名")
done
# SSH 用户名
SERVER_USER=$(read_input "请输入 SSH 用户名" "ubuntu")
# SSH 端口
SERVER_PORT=$(read_input "请输入 SSH 端口" "22")
# SSH 密钥路径
local default_key="$HOME/.ssh/id_rsa"
if [ -f "$default_key" ]; then
log_info "检测到 SSH 密钥: $default_key"
local use_default=$(read_input "是否使用该密钥? (y/n)" "y")
if [ "$use_default" = "y" ] || [ "$use_default" = "Y" ]; then
SSH_KEY_PATH="$default_key"
else
SSH_KEY_PATH=$(read_input "请输入 SSH 密钥路径")
fi
else
SSH_KEY_PATH=$(read_input "请输入 SSH 密钥路径 (留空使用默认)")
fi
# 测试 SSH 连接
echo ""
log_info "正在测试 SSH 连接..."
if test_ssh "$SERVER_HOST" "$SERVER_USER" "$SERVER_PORT" "$SSH_KEY_PATH"; then
log_success "SSH 连接测试成功"
else
log_error "SSH 连接测试失败"
echo ""
echo "请检查:"
echo " 1. 服务器地址是否正确"
echo " 2. SSH 用户名和端口是否正确"
echo " 3. SSH 密钥是否配置正确"
echo ""
read -p "是否继续配置? (y/n): " continue_setup
if [ "$continue_setup" != "y" ] && [ "$continue_setup" != "Y" ]; then
exit 1
fi
fi
echo ""
}
###############################################################################
# 函数:配置部署路径
###############################################################################
configure_paths() {
log_step "2/4" "部署路径配置"
echo ""
# Git 仓库路径
REMOTE_PROJECT_PATH=$(read_input "Git 仓库路径" "/home/ubuntu/vf_react")
# 生产环境路径
PRODUCTION_PATH=$(read_input "生产环境路径" "/var/www/valuefrontier.cn")
# 备份目录
BACKUP_DIR=$(read_input "备份目录" "/home/ubuntu/deployments")
# 日志目录
LOG_DIR=$(read_input "日志目录" "/home/ubuntu/deploy-logs")
# 部署分支
DEPLOY_BRANCH=$(read_input "部署分支" "feature")
# 保留备份数量
KEEP_BACKUPS=$(read_input "保留备份数量" "5")
echo ""
}
###############################################################################
# 函数:配置企业微信通知
###############################################################################
configure_wechat() {
log_step "3/4" "企业微信通知配置"
echo ""
local enable_notify=$(read_input "是否启用企业微信通知? (y/n)" "n")
if [ "$enable_notify" = "y" ] || [ "$enable_notify" = "Y" ]; then
ENABLE_WECHAT_NOTIFY="true"
echo ""
echo "请按以下步骤获取企业微信 Webhook URL:"
echo " 1. 打开企业微信群聊"
echo " 2. 点击群设置 -> 群机器人 -> 添加机器人"
echo " 3. 复制 Webhook URL"
echo ""
WECHAT_WEBHOOK_URL=$(read_input "请输入企业微信 Webhook URL")
if [ -n "$WECHAT_WEBHOOK_URL" ]; then
log_info "正在测试企业微信通知..."
if test_wechat_webhook "$WECHAT_WEBHOOK_URL"; then
log_success "企业微信通知测试成功"
else
log_warning "企业微信通知测试失败,请检查 Webhook URL"
fi
fi
WECHAT_MENTIONED_LIST=$(read_input "提及用户 (手机号/userid留空不提及)" "")
else
ENABLE_WECHAT_NOTIFY="false"
WECHAT_WEBHOOK_URL=""
WECHAT_MENTIONED_LIST=""
fi
echo ""
}
###############################################################################
# 函数:初始化服务器环境
###############################################################################
initialize_server() {
log_step "4/4" "初始化服务器环境"
echo ""
local ssh_options=""
if [ -n "$SSH_KEY_PATH" ]; then
ssh_options="-i $SSH_KEY_PATH"
fi
if [ -n "$SERVER_PORT" ] && [ "$SERVER_PORT" != "22" ]; then
ssh_options="$ssh_options -p $SERVER_PORT"
fi
log_info "正在创建服务器目录..."
# 创建必要的目录
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "
mkdir -p $BACKUP_DIR
mkdir -p $LOG_DIR
mkdir -p $PRODUCTION_PATH
" || {
log_error "创建目录失败"
return 1
}
log_success "服务器目录创建完成"
# 设置脚本执行权限
log_info "设置脚本执行权限..."
chmod +x "$SCRIPT_DIR"/*.sh
log_success "服务器环境初始化完成"
echo ""
}
###############################################################################
# 函数:保存配置文件
###############################################################################
save_config() {
log_info "保存配置文件..."
# 如果配置文件已存在,先备份
if [ -f "$CONFIG_FILE" ]; then
local backup_file="$CONFIG_FILE.backup.$(date +%Y%m%d%H%M%S)"
cp "$CONFIG_FILE" "$backup_file"
log_info "已备份原配置文件: $backup_file"
fi
# 从示例文件复制
if [ -f "$EXAMPLE_FILE" ]; then
cp "$EXAMPLE_FILE" "$CONFIG_FILE"
else
touch "$CONFIG_FILE"
fi
# 写入配置
cat > "$CONFIG_FILE" <<EOF
# 部署配置文件
# 由 setup-deployment.sh 自动生成
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# ==================== 服务器配置 ====================
SERVER_HOST=$SERVER_HOST
SERVER_USER=$SERVER_USER
SERVER_PORT=$SERVER_PORT
SSH_KEY_PATH=$SSH_KEY_PATH
# ==================== 路径配置 ====================
REMOTE_PROJECT_PATH=$REMOTE_PROJECT_PATH
PRODUCTION_PATH=$PRODUCTION_PATH
BACKUP_DIR=$BACKUP_DIR
LOG_DIR=$LOG_DIR
# ==================== Git 配置 ====================
DEPLOY_BRANCH=$DEPLOY_BRANCH
# ==================== 备份配置 ====================
KEEP_BACKUPS=$KEEP_BACKUPS
# ==================== 企业微信通知配置 ====================
ENABLE_WECHAT_NOTIFY=$ENABLE_WECHAT_NOTIFY
WECHAT_WEBHOOK_URL=$WECHAT_WEBHOOK_URL
WECHAT_MENTIONED_LIST=$WECHAT_MENTIONED_LIST
# ==================== 部署配置 ====================
RUN_NPM_INSTALL=true
RUN_NPM_TEST=false
BUILD_COMMAND=npm run build
# ==================== 高级配置 ====================
SSH_TIMEOUT=30
DEPLOY_TIMEOUT=600
EOF
log_success "配置文件已保存: $CONFIG_FILE"
}
###############################################################################
# 函数:显示完成信息
###############################################################################
show_completion() {
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ ✓ 配置完成! ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo -e "${BOLD}配置信息:${NC}"
echo " 服务器: $SERVER_USER@$SERVER_HOST:$SERVER_PORT"
echo " Git 仓库: $REMOTE_PROJECT_PATH"
echo " 生产环境: $PRODUCTION_PATH"
echo " 部署分支: $DEPLOY_BRANCH"
echo " 企业微信通知: $([ "$ENABLE_WECHAT_NOTIFY" = "true" ] && echo "已启用" || echo "未启用")"
echo ""
echo -e "${BOLD}接下来您可以:${NC}"
echo " • 部署到生产环境: ${GREEN}npm run deploy${NC}"
echo " • 查看备份版本: ${GREEN}npm run rollback -- list${NC}"
echo " • 回滚到上一版本: ${GREEN}npm run rollback${NC}"
echo " • 修改配置文件: ${GREEN}.env.deploy${NC}"
echo ""
echo "════════════════════════════════════════════════════════════════"
echo ""
}
###############################################################################
# 主函数
###############################################################################
main() {
# 显示欢迎信息
show_welcome
# 配置服务器
configure_server
# 配置路径
configure_paths
# 配置企业微信
configure_wechat
# 初始化服务器环境
initialize_server
# 保存配置
save_config
# 显示完成信息
show_completion
}
# 错误处理
trap 'log_error "配置过程中发生错误"; exit 1' ERR
# 执行主函数
main "$@"

View File

@@ -23,7 +23,6 @@ import theme from "theme/theme.js";
import PageLoader from "components/Loading/PageLoader";
// Layouts - 保持同步导入(需要立即加载)
import Admin from "layouts/Admin";
import Auth from "layouts/Auth";
import HomeLayout from "layouts/Home";
import MainLayout from "layouts/MainLayout";
@@ -44,18 +43,73 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
import { IndustryProvider } from "contexts/IndustryContext";
// Components
import ProtectedRoute from "components/ProtectedRoute";
import ProtectedRouteRedirect from "components/ProtectedRouteRedirect";
import ErrorBoundary from "components/ErrorBoundary";
import AuthModalManager from "components/Auth/AuthModalManager";
import NotificationContainer from "components/NotificationContainer";
import ConnectionStatusBar from "components/ConnectionStatusBar";
import NotificationTestTool from "components/NotificationTestTool";
import ScrollToTop from "components/ScrollToTop";
import { logger } from "utils/logger";
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以单独提取
*/
function ConnectionStatusBarWrapper() {
const { connectionStatus, reconnectAttempt, maxReconnectAttempts, retryConnection } = useNotification();
const [isDismissed, setIsDismissed] = React.useState(false);
// 监听连接状态变化
React.useEffect(() => {
// 重连成功后,清除 dismissed 状态
if (connectionStatus === 'connected' && isDismissed) {
setIsDismissed(false);
// 从 localStorage 清除 dismissed 标记
localStorage.removeItem('connection_status_dismissed');
}
// 从 localStorage 恢复 dismissed 状态
if (connectionStatus !== 'connected' && !isDismissed) {
const dismissed = localStorage.getItem('connection_status_dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}
}, [connectionStatus, isDismissed]);
const handleClose = () => {
// 用户手动关闭,保存到 localStorage
setIsDismissed(true);
localStorage.setItem('connection_status_dismissed', 'true');
logger.info('App', 'Connection status bar dismissed by user');
};
return (
<ConnectionStatusBar
status={connectionStatus}
reconnectAttempt={reconnectAttempt}
maxReconnectAttempts={maxReconnectAttempts}
onRetry={retryConnection}
onClose={handleClose}
isDismissed={isDismissed}
/>
);
}
function AppContent() {
const { colorMode } = useColorMode();
return (
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
{/* Socket 连接状态条 */}
<ConnectionStatusBarWrapper />
{/* 路由切换时自动滚动到顶部 */}
<ScrollToTop />
<Routes>
@@ -131,29 +185,67 @@ function AppContent() {
}
/>
{/* 事件详情独立页面路由 (不经 Admin 布局 */}
<Route path="event-detail/:eventId" element={<EventDetail />} />
{/* 事件详情独立页面路由 - 需要登录(跳转模式 */}
<Route
path="event-detail/:eventId"
element={
<ProtectedRouteRedirect>
<EventDetail />
</ProtectedRouteRedirect>
}
/>
{/* 公司相关页面 */}
<Route path="forecast-report" element={<ForecastReport />} />
<Route path="Financial" element={<FinancialPanorama />} />
<Route path="company" element={<CompanyIndex />} />
<Route path="company/:code" element={<CompanyIndex />} />
<Route path="market-data" element={<MarketDataView />} />
</Route>
{/* 财报预测 - 需要登录(跳转模式) */}
<Route
path="forecast-report"
element={
<ProtectedRouteRedirect>
<ForecastReport />
</ProtectedRouteRedirect>
}
/>
{/* 管理后台路由 - 需要登录,不使用 MainLayout */}
{/* 这些路由有自己的加载状态处理 */}
<Route
path="admin/*"
element={
<Suspense fallback={<PageLoader message="加载中..." />}>
{/* 财务全景 - 需要登录(弹窗模式) */}
<Route
path="Financial"
element={
<ProtectedRoute>
<Admin />
<FinancialPanorama />
</ProtectedRoute>
</Suspense>
}
/>
}
/>
{/* 公司页面 - 需要登录(弹窗模式) */}
<Route
path="company"
element={
<ProtectedRoute>
<CompanyIndex />
</ProtectedRoute>
}
/>
{/* 公司详情 - 需要登录(跳转模式) */}
<Route
path="company/:code"
element={
<ProtectedRouteRedirect>
<CompanyIndex />
</ProtectedRouteRedirect>
}
/>
{/* 市场数据 - 需要登录(弹窗模式) */}
<Route
path="market-data"
element={
<ProtectedRoute>
<MarketDataView />
</ProtectedRoute>
}
/>
</Route>
{/* 认证页面路由 - 不使用 MainLayout */}
<Route path="auth/*" element={<Auth />} />
@@ -172,13 +264,19 @@ export default function App() {
// 全局错误处理:捕获未处理的 Promise rejection
useEffect(() => {
const handleUnhandledRejection = (event) => {
console.error('未捕获的 Promise rejection:', event.reason);
logger.error('App', 'unhandledRejection', event.reason instanceof Error ? event.reason : new Error(String(event.reason)), {
promise: event.promise
});
// 阻止默认的错误处理(防止崩溃)
event.preventDefault();
};
const handleError = (event) => {
console.error('全局错误:', event.error);
logger.error('App', 'globalError', event.error || new Error(event.message), {
filename: event.filename,
lineno: event.lineno,
colno: event.colno
});
// 阻止默认的错误处理(防止崩溃)
event.preventDefault();
};
@@ -193,14 +291,29 @@ export default function App() {
}, []);
return (
<ChakraProvider theme={theme}>
<ChakraProvider
theme={theme}
toastOptions={{
defaultOptions: {
position: 'top',
duration: 3000,
isClosable: true,
}
}}
>
<ErrorBoundary>
<AuthProvider>
<AuthModalProvider>
<AppContent />
<AuthModalManager />
</AuthModalProvider>
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
</ChakraProvider>
);

View File

@@ -30,11 +30,13 @@ import {
import { FaLock, FaWeixin } from "react-icons/fa";
import { useAuth } from "../../contexts/AuthContext";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useNotification } from "../../contexts/NotificationContext";
import { authService } from "../../services/authService";
import AuthHeader from './AuthHeader';
import VerificationCodeInput from './VerificationCodeInput';
import WechatRegister from './WechatRegister';
import { setCurrentUser } from '../../mocks/data/users';
import { logger } from '../../utils/logger';
// 统一配置对象
const AUTH_CONFIG = {
@@ -65,6 +67,7 @@ export default function AuthFormContent() {
const navigate = useNavigate();
const { checkSession } = useAuth();
const { handleLoginSuccess } = useAuthModal();
const { showWelcomeGuide } = useNotification();
// 使用统一配置
const config = AUTH_CONFIG;
@@ -84,7 +87,7 @@ export default function AuthFormContent() {
// 响应式布局配置
const isMobile = useBreakpointValue({ base: true, md: false });
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
const stackSpacing = useBreakpointValue({ base: 4, md: 8 });
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px更紧凑
// 表单数据
const [formData, setFormData] = useState({
@@ -151,17 +154,22 @@ export default function AuthFormContent() {
try {
setSendingCode(true);
const requestData = {
credential: credential.trim(), // 添加 trim() 防止空格
type: 'phone',
purpose: config.api.purpose
};
logger.api.request('POST', '/api/auth/send-verification-code', requestData);
const response = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 必须包含以支持跨域 session cookie
body: JSON.stringify({
credential,
type: 'phone',
purpose: config.api.purpose // 根据模式使用不同的purpose
}),
credentials: 'include',
body: JSON.stringify(requestData),
});
if (!response) {
@@ -170,6 +178,8 @@ export default function AuthFormContent() {
const data = await response.json();
logger.api.response('POST', '/api/auth/send-verification-code', response.status, data);
if (!isMountedRef.current) return;
if (!data) {
@@ -177,26 +187,40 @@ export default function AuthFormContent() {
}
if (response.ok && data.success) {
toast({
title: "验证码发送",
description: "验证码已发送到您的手机号",
status: "success",
duration: 3000,
// ❌ 移除成功 toast静默处理
logger.info('AuthFormContent', '验证码发送成功', {
credential: credential.substring(0, 3) + '****' + credential.substring(7),
dev_code: data.dev_code
});
// ✅ 开发环境下在控制台显示验证码
if (data.dev_code) {
console.log(`%c✅ [验证码] ${credential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
}
setVerificationCodeSent(true);
setCountdown(60);
} else {
throw new Error(data.error || '发送验证码失败');
}
} catch (error) {
if (isMountedRef.current) {
toast({
title: "发送验证码失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
logger.api.error('POST', '/api/auth/send-verification-code', error, {
credential: credential.substring(0, 3) + '****' + credential.substring(7)
});
// ✅ 显示错误提示给用户
toast({
id: 'send-code-error',
title: "发送验证码失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
position: 'top',
containerStyle: {
zIndex: 10000,
}
});
} finally {
if (isMountedRef.current) {
setSendingCode(false);
@@ -210,7 +234,7 @@ export default function AuthFormContent() {
setIsLoading(true);
try {
const { phone, verificationCode, nickname } = formData;
const { phone, verificationCode } = formData;
// 表单验证
if (!phone || !verificationCode) {
@@ -234,18 +258,24 @@ export default function AuthFormContent() {
// 构建请求体
const requestBody = {
credential: phone,
verification_code: verificationCode,
credential: phone.trim(), // 添加 trim() 防止空格
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
login_type: 'phone',
};
logger.api.request('POST', '/api/auth/login-with-code', {
credential: phone.substring(0, 3) + '****' + phone.substring(7),
verification_code: verificationCode.substring(0, 2) + '****',
login_type: 'phone'
});
// 调用API根据模式选择不同的endpoint
const response = await fetch('/api/auth/login-with-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 必须包含以支持跨域 session cookie
credentials: 'include',
body: JSON.stringify(requestBody),
});
@@ -255,6 +285,11 @@ export default function AuthFormContent() {
const data = await response.json();
logger.api.response('POST', '/api/auth/login-with-code', response.status, {
...data,
user: data.user ? { id: data.user.id, phone: data.user.phone } : null
});
if (!isMountedRef.current) return;
if (!data) {
@@ -265,19 +300,29 @@ export default function AuthFormContent() {
// ⚡ Mock 模式:先在前端侧写入 localStorage确保时序正确
if (process.env.REACT_APP_ENABLE_MOCK === 'true' && data.user) {
setCurrentUser(data.user);
console.log('[Auth] 前端侧设置当前用户Mock模式:', data.user);
logger.debug('AuthFormContent', '前端侧设置当前用户(Mock模式)', {
userId: data.user?.id,
phone: data.user?.phone,
mockMode: true
});
}
// 更新session
await checkSession();
// ✅ 保留登录成功 toast关键操作提示
toast({
title: config.successTitle,
title: data.isNewUser ? '注册成功' : '登录成功',
description: config.successDescription,
status: "success",
duration: 2000,
});
logger.info('AuthFormContent', '登录成功', {
isNewUser: data.isNewUser,
userId: data.user?.id
});
// 检查是否为新注册用户
if (data.isNewUser) {
// 新注册用户,延迟后显示昵称设置引导
@@ -291,19 +336,37 @@ export default function AuthFormContent() {
handleLoginSuccess({ phone });
}, config.features.successDelay);
}
// ⚡ 延迟 10 秒显示权限引导(温和、非侵入)
setTimeout(() => {
if (showWelcomeGuide) {
logger.info('AuthFormContent', '显示欢迎引导');
showWelcomeGuide();
}
}, 10000);
} else {
throw new Error(data.error || `${config.errorTitle}`);
}
} catch (error) {
console.error('Auth error:', error);
if (isMountedRef.current) {
toast({
title: config.errorTitle,
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
});
}
const { phone, verificationCode } = formData;
logger.error('AuthFormContent', 'handleSubmit', error, {
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
hasVerificationCode: !!verificationCode
});
// ✅ 显示错误提示给用户
toast({
id: 'auth-verification-error',
title: config.errorTitle,
description: error.message || "请检查验证码是否正确",
status: "error",
duration: 3000,
isClosable: true,
position: 'top',
containerStyle: {
zIndex: 10000,
}
});
} finally {
if (isMountedRef.current) {
setIsLoading(false);
@@ -338,7 +401,7 @@ export default function AuthFormContent() {
window.location.href = response.auth_url;
}, 500);
} catch (error) {
console.error('微信H5登录失败:', error);
logger.error('AuthFormContent', 'handleWechatH5Login', error);
toast({
title: "跳转失败",
description: error.message || "请稍后重试",
@@ -438,8 +501,8 @@ export default function AuthFormContent() {
{/* 桌面端:右侧二维码扫描 */}
{!isMobile && (
<Box flex="1">
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
<Box flex={{ base: "1", md: "0 0 auto" }}> {/* ✅ 桌面端让右侧自适应宽度 */}
<Center width="100%"> {/* ✅ 移除bg和pWechatRegister自带白色背景和padding */}
<WechatRegister />
</Center>
</Box>

View File

@@ -1,5 +1,6 @@
import React from "react";
import { FormControl, FormErrorMessage, HStack, Input, Button, Spinner } from "@chakra-ui/react";
import { logger } from "../../utils/logger";
/**
* 通用验证码输入组件
@@ -26,7 +27,12 @@ export default function VerificationCodeInput({
}
} catch (error) {
// 错误已经在父组件处理,这里只需要防止未捕获的 Promise rejection
console.error('Send code error (caught in VerificationCodeInput):', error);
logger.error('VerificationCodeInput', 'handleSendCode', error, {
hasOnSendCode: !!onSendCode,
countdown,
isLoading,
isSending
});
}
};

View File

@@ -3,20 +3,47 @@ import {
Box,
Button,
VStack,
HStack,
Center,
Text,
Heading,
Icon,
useToast,
Spinner
} from "@chakra-ui/react";
import { FaQrcode } from "react-icons/fa";
import { FiAlertCircle } from "react-icons/fi";
import { useNavigate } from "react-router-dom";
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
import { logger } from "../../utils/logger";
// 配置常量
const POLL_INTERVAL = 2000; // 轮询间隔2秒
const BACKUP_POLL_INTERVAL = 3000; // 备用轮询间隔3秒
const QR_CODE_TIMEOUT = 300000; // 二维码超时5分钟
/**
* 获取状态文字颜色
*/
const getStatusColor = (status) => {
switch(status) {
case WECHAT_STATUS.WAITING: return "gray.600"; // ✅ 灰色文字
case WECHAT_STATUS.SCANNED: return "green.600"; // ✅ 绿色文字
case WECHAT_STATUS.AUTHORIZED: return "green.600"; // ✅ 绿色文字
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
default: return "gray.600";
}
};
/**
* 获取状态文字
*/
const getStatusText = (status) => {
return STATUS_MESSAGES[status] || "点击按钮获取二维码";
};
export default function WechatRegister() {
// 状态管理
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
@@ -109,7 +136,7 @@ export default function WechatRegister() {
throw new Error(response?.error || '登录失败');
}
} catch (error) {
console.error('登录失败:', error);
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
showError("登录失败", error.message || "请重试");
}
}, [navigate, showSuccess, showError]);
@@ -126,7 +153,7 @@ export default function WechatRegister() {
// 安全检查:确保 response 存在且包含 status
if (!response || typeof response.status === 'undefined') {
console.warn('微信状态检查返回无效数据:', response);
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
return;
}
@@ -140,6 +167,18 @@ export default function WechatRegister() {
// 处理成功状态
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
clearTimers(); // 停止轮询
// 显示"扫码成功,登录中"提示
if (isMountedRef.current) {
toast({
title: "扫码成功",
description: "正在登录,请稍候...",
status: "info",
duration: 2000,
isClosable: false,
});
}
await handleLoginSuccess(wechatSessionId, status);
}
// 处理过期状态
@@ -156,7 +195,7 @@ export default function WechatRegister() {
}
}
} catch (error) {
console.error("检查微信状态失败:", error);
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: wechatSessionId });
// 轮询过程中的错误不显示给用户,避免频繁提示
// 但如果错误持续发生,停止轮询避免无限重试
if (error.message.includes('网络连接失败')) {
@@ -222,7 +261,7 @@ export default function WechatRegister() {
// 启动轮询检查扫码状态
startPolling();
} catch (error) {
console.error('获取微信授权失败:', error);
logger.error('WechatRegister', 'getWechatQRCode', error);
if (isMountedRef.current) {
showError("获取微信授权失败", error.message || "请稍后重试");
}
@@ -241,7 +280,7 @@ export default function WechatRegister() {
await getWechatQRCode();
} catch (error) {
// 错误已经在 getWechatQRCode 中处理,这里只需要防止未捕获的 Promise rejection
console.error('QR code button click error (caught in handler):', error);
logger.error('WechatRegister', 'handleGetQRCodeClick', error);
}
}, [getWechatQRCode]);
@@ -264,20 +303,20 @@ export default function WechatRegister() {
useEffect(() => {
// 只在有auth_url、session_id且状态为waiting时启动备用轮询
if (wechatAuthUrl && wechatSessionId && wechatStatus === WECHAT_STATUS.WAITING) {
console.log('备用轮询:启动备用轮询机制');
logger.debug('WechatRegister', '备用轮询:启动备用轮询机制');
backupPollIntervalRef.current = setInterval(() => {
try {
if (wechatStatus === WECHAT_STATUS.WAITING && isMountedRef.current) {
console.log('备用轮询:检查微信状态');
logger.debug('WechatRegister', '备用轮询:检查微信状态');
// 添加 .catch() 静默处理异步错误,防止被 ErrorBoundary 捕获
checkWechatStatus().catch(error => {
console.warn('备用轮询检查失败(静默处理):', error);
logger.warn('WechatRegister', '备用轮询检查失败(静默处理)', { error: error.message });
});
}
} catch (error) {
// 捕获所有同步错误,防止被 ErrorBoundary 捕获
console.warn('备用轮询执行出错(静默处理):', error);
logger.warn('WechatRegister', '备用轮询执行出错(静默处理)', { error: error.message });
}
}, BACKUP_POLL_INTERVAL);
}
@@ -295,9 +334,9 @@ export default function WechatRegister() {
* 测量容器尺寸并计算缩放比例
*/
useLayoutEffect(() => {
// 微信授权页面的原始尺寸
const ORIGINAL_WIDTH = 600;
const ORIGINAL_HEIGHT = 800;
// 微信授权页面的原始尺寸需要与iframe实际尺寸匹配
const ORIGINAL_WIDTH = 300; // ✅ 修正与iframe width匹配
const ORIGINAL_HEIGHT = 350; // ✅ 修正与iframe height匹配
const calculateScale = () => {
if (containerRef.current) {
@@ -331,132 +370,158 @@ export default function WechatRegister() {
};
}, [wechatStatus]); // 当状态变化时重新计算
/**
* 渲染状态提示文本
*/
const renderStatusText = () => {
if (!wechatAuthUrl || wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) {
return null;
}
return (
<Text fontSize="xs" color="gray.500">
{STATUS_MESSAGES[wechatStatus]}
</Text>
);
};
// 渲染状态提示文本 - 已注释掉,如需使用可取消注释
// const renderStatusText = () => {
// if (!wechatAuthUrl || wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) {
// return null;
// }
// return (
// <Text fontSize="xs" color="gray.500">
// {STATUS_MESSAGES[wechatStatus]}
// </Text>
// );
// };
return (
<VStack spacing={2} display="flex" alignItems="center" justifyContent="center">
{wechatStatus === WECHAT_STATUS.WAITING ? (
<>
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
微信扫码
</Text>
<VStack
spacing={0} // ✅ 手动控制间距
alignItems="stretch" // ✅ 拉伸对齐
justifyContent="flex-start" // ✅ 顶部对齐(标题对齐关键)
width="auto" // ✅ 自适应宽度
>
{/* ========== 标题区域 ========== */}
<Heading
size="md" // ✅ 16px与左侧"登陆/注册"一致
fontWeight="600"
color="gray.800"
textAlign="center"
mb={3} // 12px底部间距
>
微信扫码
</Heading>
{/* ========== 二维码区域 ========== */}
<Box
ref={containerRef}
position="relative"
width="230px" // ✅ 升级尺寸
height="230px"
mx="auto"
overflow="hidden"
borderRadius="md"
border="1px solid"
borderColor="gray.200"
bg="gray.50"
boxShadow="sm" // ✅ 添加轻微阴影
>
{wechatStatus === WECHAT_STATUS.WAITING ? (
/* 已获取二维码显示iframe */
<iframe
src={wechatAuthUrl}
title="微信扫码登录"
width="300"
height="350"
style={{
border: 'none',
transform: 'scale(0.77) translateY(-20px)', // ✅ 裁剪顶部logo
transformOrigin: 'top left',
marginLeft: '-5px'
}}
/>
) : (
/* 未获取:显示占位符 */
<Center width="100%" height="100%" flexDirection="column">
<Icon as={FaQrcode} w={16} h={16} color="gray.300" mb={4} />
<Button
size="sm"
colorScheme="green"
onClick={handleGetQRCodeClick}
isLoading={isLoading}
>
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
</Button>
</Center>
)}
{/* ========== 过期蒙层 ========== */}
{wechatStatus === WECHAT_STATUS.EXPIRED && (
<Box
ref={containerRef}
position="relative"
width="150px"
height="100px"
maxWidth="100%"
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
bg="rgba(0,0,0,0.6)"
display="flex"
alignItems="center"
justifyContent="center"
overflow="hidden"
backdropFilter="blur(4px)"
>
<iframe
src={wechatAuthUrl}
title="微信扫码登录"
width="300"
height="350"
style={{
borderRadius: '8px',
border: 'none',
transform: `scale(${scale})`,
transformOrigin: 'center center'
}}
/>
<VStack spacing={2}>
<Icon as={FiAlertCircle} w={8} h={8} color="white" />
<Text color="white" fontSize="sm">二维码已过期</Text>
<Button
size="xs"
colorScheme="whiteAlpha"
onClick={handleGetQRCodeClick}
>
点击刷新
</Button>
</VStack>
</Box>
{/* {renderStatusText()} */}
</>
) : (
<>
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
微信扫码
)}
</Box>
{/* ========== 状态指示器 ========== */}
{wechatStatus !== WECHAT_STATUS.NONE && (
<Text
mt={3}
fontSize="sm"
fontWeight="500" // ✅ 半粗体
textAlign="center"
color={getStatusColor(wechatStatus)} // ✅ 根据状态显示不同颜色
>
{getStatusText(wechatStatus)}
</Text>
)}
{/* ========== Mock 模式控制按钮(仅开发环境) ========== */}
{process.env.REACT_APP_ENABLE_MOCK === 'true' && wechatStatus === WECHAT_STATUS.WAITING && wechatSessionId && (
<Box mt={3} pt={3} borderTop="1px solid" borderColor="gray.200">
<Button
size="xs"
width="100%"
colorScheme="purple"
variant="outline"
onClick={() => {
if (window.mockWechatScan) {
const success = window.mockWechatScan(wechatSessionId);
if (success) {
toast({
title: "Mock 模拟触发成功",
description: "正在模拟扫码登录...",
status: "info",
duration: 2000,
isClosable: false,
});
}
} else {
toast({
title: "Mock API 未加载",
description: "请刷新页面重试",
status: "warning",
duration: 2000,
});
}
}}
leftIcon={<Text fontSize="lg">🧪</Text>}
>
模拟扫码成功测试
</Button>
<Text fontSize="xs" color="gray.400" textAlign="center" mt={1}>
开发模式 | 自动登录: 5
</Text>
<Box
position="relative"
width="150px"
height="100px"
maxWidth="100%"
display="flex"
alignItems="center"
justifyContent="center"
overflow="hidden"
>
{/* 灰色二维码底图 - 始终显示 */}
<Icon as={FaQrcode} w={24} h={24} color="gray.300" />
{/* 加载动画 */}
{isLoading && (
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
display="flex"
alignItems="center"
justifyContent="center"
>
<Spinner
size="lg"
color="green.500"
thickness="4px"
/>
</Box>
)}
{/* 显示获取/刷新二维码按钮 */}
{(wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) && (
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
display="flex"
alignItems="center"
justifyContent="center"
bg="rgba(255, 255, 255, 0.3)"
backdropFilter="blur(2px)"
>
<VStack spacing={2}>
<Button
variant="outline"
colorScheme="green"
size="sm"
onClick={handleGetQRCodeClick}
isLoading={isLoading}
leftIcon={<Icon as={FaQrcode} />}
_hover={{ bg: "green.50" }}
>
{wechatStatus === WECHAT_STATUS.EXPIRED ? "点击刷新" : "获取二维码"}
</Button>
{wechatStatus === WECHAT_STATUS.EXPIRED && (
<Text fontSize="xs" color="gray.500">
二维码已过期
</Text>
)}
</VStack>
</Box>
)}
</Box>
{/* 扫码状态提示 */}
{/* {renderStatusText()} */}
</>
</Box>
)}
</VStack>
);

View File

@@ -53,10 +53,18 @@ const CitationMark = ({ citationId, citation }) => {
paddingBottom: 8,
borderBottom: '1px solid #f0f0f0'
}}>
{/* 左侧:作者 */}
{/* 左侧:券商 · 作者(或仅作者) */}
<Space size={4}>
<UserOutlined style={{ color: '#1890ff', fontSize: 12 }} />
<Text style={{ fontSize: 12, color: '#595959' }}>
{citation.organization && (
<>
<Text strong style={{ fontSize: 12, color: '#262626' }}>
{citation.organization}
</Text>
<Text style={{ margin: '0 4px', color: '#bfbfbf' }}> · </Text>
</>
)}
{citation.author}
</Text>
</Space>
@@ -116,6 +124,8 @@ const CitationMark = ({ citationId, citation }) => {
overlayInnerStyle={{ maxWidth: 340, padding: '8px' }}
open={popoverVisible}
onOpenChange={setPopoverVisible}
zIndex={2000}
getPopupContainer={(trigger) => trigger.parentElement || document.body}
>
<sup
style={{

View File

@@ -1,27 +1,33 @@
// src/components/Citation/CitedContent.js
import React from 'react';
import { Typography, Space, Tag } from 'antd';
import { RobotOutlined, FileSearchOutlined } from '@ant-design/icons';
import { Typography, Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import CitationMark from './CitationMark';
import { processCitationData } from '../../utils/citationUtils';
import { logger } from '../../utils/logger';
const { Text } = Typography;
/**
* 带引用标注的内容组件
* 带引用标注的内容组件(块级模式)
* 展示拼接的文本每句话后显示上标引用【1】【2】【3】
* 支持鼠标悬浮和点击查看引用来源
* AI 标识统一显示在右上角,不占用布局高度
*
* @param {Object} props
* @param {Object} props.data - API 返回的原始数据 { data: [...] }
* @param {string} props.title - 标题文本,默认 "AI 分析结果"
* @param {boolean} props.showAIBadge - 是否显示 AI 生成标识,默认 true
* @param {string} props.prefix - 内容前的前缀标签,如 "机制:"(可选)
* @param {Object} props.prefixStyle - 前缀标签的自定义样式(可选)
* @param {boolean} props.showAIBadge - 是否显示右上角 AI 标识,默认 true可选
* @param {Object} props.containerStyle - 容器额外样式(可选)
*
* @example
* <CitedContent
* data={apiData}
* title="关联描述"
* prefix="机制:"
* prefixStyle={{ color: '#666' }}
* showAIBadge={true}
* containerStyle={{ marginTop: 16 }}
* />
@@ -29,6 +35,8 @@ const { Text } = Typography;
const CitedContent = ({
data,
title = 'AI 分析结果',
prefix = '',
prefixStyle = {},
showAIBadge = true,
containerStyle = {}
}) => {
@@ -37,50 +45,76 @@ const CitedContent = ({
// 如果数据无效,不渲染
if (!processed) {
console.warn('CitedContent: Invalid data, not rendering');
logger.warn('CitedContent', '无效数据,不渲染', {
hasData: !!data,
title
});
return null;
}
return (
<div
style={{
position: 'relative',
width: '100%',
backgroundColor: '#f5f5f5',
borderRadius: 6,
padding: 16,
paddingTop: title ? 16 : 20,
...containerStyle
}}
>
{/* AI 标识 - 固定在右上角 */}
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{
position: 'absolute',
top: 12,
right: 12,
margin: 0,
zIndex: 10,
fontSize: 12,
padding: '2px 8px'
}}
className="ai-badge-responsive"
>
AI合成
</Tag>
)}
{/* 标题栏 */}
<Space
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12
}}
>
<Space>
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
{title && (
<div style={{ marginBottom: 12, paddingRight: 80 }}>
<Text strong style={{ fontSize: 14 }}>
{title}
</Text>
</Space>
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{ margin: 0 }}
>
AI 生成
</Tag>
)}
</Space>
</div>
)}
{/* 带引用的文本内容 */}
<div style={{ lineHeight: 1.8 }}>
<div style={{
lineHeight: 1.8,
paddingRight: title ? 0 : (showAIBadge ? 80 : 0)
}}>
{/* 前缀标签(如果有) */}
{prefix && (
<Text style={{
fontSize: 14,
fontWeight: 'bold',
display: 'inline',
marginRight: 4,
...prefixStyle
}}>
{prefix}
</Text>
)}
{processed.segments.map((segment, index) => (
<React.Fragment key={`segment-${segment.citationId}`}>
{/* 文本片段 */}
<Text style={{ fontSize: 14 }}>
<Text style={{ fontSize: 14, display: 'inline' }}>
{segment.text}
</Text>
@@ -92,11 +126,21 @@ const CitedContent = ({
{/* 在片段之间添加逗号分隔符(最后一个不加) */}
{index < processed.segments.length - 1 && (
<Text style={{ fontSize: 14 }}></Text>
<Text style={{ fontSize: 14, display: 'inline' }}></Text>
)}
</React.Fragment>
))}
</div>
{/* 响应式样式 */}
<style jsx>{`
@media (max-width: 768px) {
.ai-badge-responsive {
font-size: 10px !important;
padding: 1px 6px !important;
}
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,144 @@
// src/components/ConnectionStatusBar/index.js
/**
* Socket 连接状态栏组件
* 显示 Socket 连接状态并提供重试功能
*/
import React from 'react';
import {
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
Box,
HStack,
useColorModeValue,
Slide,
} from '@chakra-ui/react';
import { MdRefresh } from 'react-icons/md';
/**
* 连接状态枚举
*/
export const CONNECTION_STATUS = {
CONNECTED: 'connected', // 已连接
DISCONNECTED: 'disconnected', // 已断开
RECONNECTING: 'reconnecting', // 重连中
FAILED: 'failed', // 连接失败
RECONNECTED: 'reconnected', // 重连成功显示2秒后自动消失
};
/**
* 连接状态栏组件
*/
const ConnectionStatusBar = ({
status = CONNECTION_STATUS.CONNECTED,
reconnectAttempt = 0,
maxReconnectAttempts = 5,
onRetry,
onClose,
isDismissed = false, // 用户是否手动关闭
}) => {
// 显示条件:非正常状态 且 用户未手动关闭
const shouldShow = status !== CONNECTION_STATUS.CONNECTED && !isDismissed;
// 状态配置
const statusConfig = {
[CONNECTION_STATUS.DISCONNECTED]: {
status: 'warning',
title: '连接已断开',
description: '正在尝试重新连接...',
},
[CONNECTION_STATUS.RECONNECTING]: {
status: 'warning',
title: '正在重新连接',
description: maxReconnectAttempts === Infinity
? `尝试重连中 (第 ${reconnectAttempt} 次)`
: `尝试重连中 (第 ${reconnectAttempt}/${maxReconnectAttempts} 次)`,
},
[CONNECTION_STATUS.FAILED]: {
status: 'error',
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
},
[CONNECTION_STATUS.RECONNECTED]: {
status: 'success',
title: '已重新连接',
description: '连接已恢复',
},
};
const config = statusConfig[status] || statusConfig[CONNECTION_STATUS.DISCONNECTED];
// 颜色配置
const bg = useColorModeValue(
{
warning: 'orange.50',
error: 'red.50',
success: 'green.50',
}[config.status],
{
warning: 'rgba(251, 146, 60, 0.15)', // orange with transparency
error: 'rgba(239, 68, 68, 0.15)', // red with transparency
success: 'rgba(34, 197, 94, 0.15)', // green with transparency
}[config.status]
);
return (
<Slide
direction="top"
in={shouldShow}
style={{ zIndex: 1050 }} // 降低 zIndex避免遮挡 modal
>
<Alert
status={config.status}
variant="subtle"
bg={bg}
borderBottom="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.700')}
py={2} // 减小高度,更紧凑
px={{ base: 4, md: 6 }}
opacity={0.95} // 半透明
>
<AlertIcon />
<Box flex="1">
<HStack spacing={2} align="center" flexWrap="wrap">
<AlertTitle fontSize="sm" fontWeight="bold" mb={0}>
{config.title}
</AlertTitle>
<AlertDescription fontSize="sm" mb={0}>
{config.description}
</AlertDescription>
</HStack>
</Box>
{/* 重试按钮(仅失败状态显示) */}
{status === CONNECTION_STATUS.FAILED && onRetry && (
<Button
size="sm"
colorScheme="red"
leftIcon={<MdRefresh />}
onClick={onRetry}
mr={2}
flexShrink={0}
>
立即重试
</Button>
)}
{/* 关闭按钮(所有非正常状态都显示) */}
{status !== CONNECTION_STATUS.CONNECTED && onClose && (
<CloseButton
onClick={onClose}
size="sm"
flexShrink={0}
/>
)}
</Alert>
</Slide>
);
};
export default ConnectionStatusBar;

View File

@@ -9,6 +9,7 @@ import {
VStack,
Container
} from '@chakra-ui/react';
import { logger } from '../utils/logger';
class ErrorBoundary extends React.Component {
constructor(props) {
@@ -28,14 +29,20 @@ class ErrorBoundary extends React.Component {
componentDidCatch(error, errorInfo) {
// 开发环境:打印错误到控制台,但不显示错误边界
if (process.env.NODE_ENV === 'development') {
console.error('🔴 ErrorBoundary 捕获到错误(开发模式,不拦截):');
console.error('错误:', error);
console.error('错误信息:', errorInfo);
logger.error('ErrorBoundary', 'componentDidCatch', error, {
componentStack: errorInfo.componentStack,
developmentMode: true
});
// 不更新 state让错误继续抛出
return;
}
// 生产环境:保存错误信息到 state
logger.error('ErrorBoundary', 'componentDidCatch', error, {
componentStack: errorInfo.componentStack,
productionMode: true
});
this.setState({
error: error,
errorInfo: errorInfo

View File

@@ -1,222 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra Imports
import {
Box,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Flex,
Link,
useColorModeValue,
} from "@chakra-ui/react";
import { SidebarContext } from "contexts/SidebarContext";
import PropTypes from "prop-types";
import React, { useState, useEffect, useContext } from "react";
import AdminNavbarLinks from "./AdminNavbarLinks";
import { HamburgerIcon } from "@chakra-ui/icons";
export default function AdminNavbar(props) {
const [scrolled, setScrolled] = useState(false);
const {
sidebarWidth,
setSidebarWidth,
toggleSidebar,
setToggleSidebar,
} = useContext(SidebarContext);
useEffect(() => {
window.addEventListener("scroll", changeNavbar);
return () => {
window.removeEventListener("scroll", changeNavbar);
};
});
const {
variant,
children,
fixed,
secondary,
brandText,
onOpen,
...rest
} = props;
// Here are all the props that may change depending on navbar's type or state.(secondary, variant, scrolled)
let mainText =
fixed && scrolled
? useColorModeValue("gray.700", "gray.200")
: useColorModeValue("white", "gray.200");
let secondaryText =
fixed && scrolled
? useColorModeValue("gray.700", "gray.200")
: useColorModeValue("white", "gray.200");
let navbarPosition = "absolute";
let navbarFilter = "none";
let navbarBackdrop = "blur(20px)";
let navbarShadow = "none";
let navbarBg = "none";
let navbarBorder = "transparent";
let secondaryMargin = "0px";
let paddingX = "15px";
if (props.fixed === true)
if (scrolled === true) {
navbarPosition = "fixed";
navbarShadow = useColorModeValue(
"0px 7px 23px rgba(0, 0, 0, 0.05)",
"none"
);
navbarBg = useColorModeValue(
"linear-gradient(112.83deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.8) 110.84%)",
"linear-gradient(112.83deg, rgba(255, 255, 255, 0.21) 0%, rgba(255, 255, 255, 0) 110.84%)"
);
navbarBorder = useColorModeValue("#FFFFFF", "rgba(255, 255, 255, 0.31)");
navbarFilter = useColorModeValue(
"none",
"drop-shadow(0px 7px 23px rgba(0, 0, 0, 0.05))"
);
}
if (props.secondary) {
navbarBackdrop = "none";
navbarPosition = "absolute";
mainText = "white";
secondaryText = "white";
secondaryMargin = "22px";
paddingX = "30px";
}
const changeNavbar = () => {
if (window.scrollY > 1) {
setScrolled(true);
} else {
setScrolled(false);
}
};
return (
<Flex
position={navbarPosition}
boxShadow={navbarShadow}
bg={navbarBg}
borderColor={navbarBorder}
filter={navbarFilter}
backdropFilter={navbarBackdrop}
borderWidth="1.5px"
borderStyle="solid"
transitionDelay="0s, 0s, 0s, 0s"
transitionDuration=" 0.25s, 0.25s, 0.25s, 0s"
transition-property="box-shadow, background-color, filter, border"
transitionTimingFunction="linear, linear, linear, linear"
alignItems={{ xl: "center" }}
borderRadius="16px"
display="flex"
minH="75px"
justifyContent={{ xl: "center" }}
lineHeight="25.6px"
mx="auto"
mt={secondaryMargin}
pb="8px"
left={document.documentElement.dir === "rtl" ? "30px" : ""}
right={document.documentElement.dir === "rtl" ? "" : "30px"}
px={{
sm: paddingX,
md: "30px",
}}
ps={{
xl: "12px",
}}
pt="8px"
top="18px"
w={{ sm: "calc(100vw - 30px)", xl: "calc(100vw - 75px - 275px)" }}
>
<Flex
w="100%"
flexDirection={{
sm: "column",
md: "row",
}}
alignItems={{ xl: "center" }}
>
<Box mb={{ sm: "8px", md: "0px" }}>
<Breadcrumb>
<BreadcrumbItem color={mainText}>
<BreadcrumbLink href="#" color={secondaryText}>
Pages
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem color={mainText}>
<BreadcrumbLink href="#" color={mainText}>
{brandText}
</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
{/* Here we create navbar brand, based on route name */}
<Link
color={mainText}
href="#"
bg="inherit"
borderRadius="inherit"
fontWeight="bold"
_hover={{ color: { mainText } }}
_active={{
bg: "inherit",
transform: "none",
borderColor: "transparent",
}}
_focus={{
boxShadow: "none",
}}
>
{brandText}
</Link>
</Box>
<HamburgerIcon
w="100px"
h="20px"
ms="20px"
color="#fff"
cursor="pointer"
display={{ sm: "none", xl: "block" }}
onClick={() => {
setSidebarWidth(sidebarWidth === 275 ? 120 : 275);
setToggleSidebar(!toggleSidebar);
}}
/>
<Box ms="auto" w={{ sm: "100%", md: "unset" }}>
<AdminNavbarLinks
onOpen={props.onOpen}
logoText={props.logoText}
secondary={props.secondary}
fixed={props.fixed}
scrolled={scrolled}
/>
</Box>
</Flex>
</Flex>
);
}
AdminNavbar.propTypes = {
brandText: PropTypes.string,
variant: PropTypes.string,
secondary: PropTypes.bool,
fixed: PropTypes.bool,
onOpen: PropTypes.func,
};

View File

@@ -1,253 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra Icons
import { BellIcon } from "@chakra-ui/icons";
// Chakra Imports
import {
Button,
Flex,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
Stack,
Box,
useColorMode,
useColorModeValue,
Avatar,
HStack,
Divider,
} from "@chakra-ui/react";
// Assets
import avatar1 from "assets/img/avatars/avatar1.png";
import avatar2 from "assets/img/avatars/avatar2.png";
import avatar3 from "assets/img/avatars/avatar3.png";
// Custom Icons
import { ProfileIcon, SettingsIcon } from "components/Icons/Icons";
// Custom Components
import { ItemContent } from "components/Menu/ItemContent";
import { SearchBar } from "components/Navbars/SearchBar/SearchBar";
import { SidebarResponsive } from "components/Sidebar/Sidebar";
import PropTypes from "prop-types";
import React from "react";
import { NavLink, useNavigate } from "react-router-dom";
import routes from "routes.js";
import {
ArgonLogoDark,
ChakraLogoDark,
ArgonLogoLight,
ChakraLogoLight,
} from "components/Icons/Icons";
import { useAuth } from "contexts/AuthContext";
export default function HeaderLinks(props) {
const {
variant,
children,
fixed,
scrolled,
secondary,
onOpen,
...rest
} = props;
const { colorMode } = useColorMode();
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
// Chakra Color Mode
let navbarIcon =
fixed && scrolled
? useColorModeValue("gray.700", "gray.200")
: useColorModeValue("white", "gray.200");
let menuBg = useColorModeValue("white", "navy.800");
if (secondary) {
navbarIcon = "white";
}
const handleLogout = () => {
logout();
navigate("/auth/signin");
};
return (
<Flex
pe={{ sm: "0px", md: "16px" }}
w={{ sm: "100%", md: "auto" }}
alignItems="center"
flexDirection="row"
>
<SearchBar me="18px" />
{/* 用户认证状态 */}
{isAuthenticated ? (
// 已登录用户 - 显示用户菜单
<Menu>
<MenuButton>
<HStack spacing={2} cursor="pointer">
<Avatar
size="sm"
name={user?.name}
src={user?.avatar}
bg="blue.500"
/>
<Text
display={{ sm: "none", md: "flex" }}
color={navbarIcon}
fontSize="sm"
fontWeight="medium"
>
{user?.name || user?.email}
</Text>
</HStack>
</MenuButton>
<MenuList p="16px 8px" bg={menuBg}>
<Flex flexDirection="column">
<MenuItem borderRadius="8px" mb="10px" onClick={() => navigate("/admin/profile")}>
<HStack spacing={3}>
<Avatar size="sm" name={user?.name} src={user?.avatar} />
<Box>
<Text fontWeight="bold" fontSize="sm">{user?.name}</Text>
<Text fontSize="xs" color="gray.500">{user?.email}</Text>
</Box>
</HStack>
</MenuItem>
<Divider my={2} />
<MenuItem borderRadius="8px" mb="10px" onClick={() => navigate("/admin/profile")}>
<Text>个人资料</Text>
</MenuItem>
<MenuItem borderRadius="8px" mb="10px" onClick={() => navigate("/admin/settings")}>
<Text>设置</Text>
</MenuItem>
<Divider my={2} />
<MenuItem borderRadius="8px" onClick={handleLogout}>
<Text color="red.500">退出登录</Text>
</MenuItem>
</Flex>
</MenuList>
</Menu>
) : (
// 未登录用户 - 显示登录按钮
<NavLink to="/auth/signin">
<Button
ms="0px"
px="0px"
me={{ sm: "2px", md: "16px" }}
color={navbarIcon}
variant="no-effects"
rightIcon={
document.documentElement.dir ? (
""
) : (
<ProfileIcon color={navbarIcon} w="22px" h="22px" me="0px" />
)
}
leftIcon={
document.documentElement.dir ? (
<ProfileIcon color={navbarIcon} w="22px" h="22px" me="0px" />
) : (
""
)
}
>
<Text display={{ sm: "none", md: "flex" }}>登录</Text>
</Button>
</NavLink>
)}
<SidebarResponsive
logo={
<Stack direction="row" spacing="12px" align="center" justify="center">
{colorMode === "dark" ? (
<ArgonLogoLight w="74px" h="27px" />
) : (
<ArgonLogoDark w="74px" h="27px" />
)}
<Box
w="1px"
h="20px"
bg={colorMode === "dark" ? "white" : "gray.700"}
/>
{colorMode === "dark" ? (
<ChakraLogoLight w="82px" h="21px" />
) : (
<ChakraLogoDark w="82px" h="21px" />
)}
</Stack>
}
colorMode={colorMode}
secondary={props.secondary}
routes={routes}
{...rest}
/>
<SettingsIcon
cursor="pointer"
ms={{ base: "16px", xl: "0px" }}
me="16px"
onClick={props.onOpen}
color={navbarIcon}
w="18px"
h="18px"
/>
<Menu>
<MenuButton>
<BellIcon color={navbarIcon} w="18px" h="18px" />
</MenuButton>
<MenuList p="16px 8px" bg={menuBg}>
<Flex flexDirection="column">
<MenuItem borderRadius="8px" mb="10px">
<ItemContent
time="13 minutes ago"
info="from Alicia"
boldInfo="New Message"
aName="Alicia"
aSrc={avatar1}
/>
</MenuItem>
<MenuItem borderRadius="8px" mb="10px">
<ItemContent
time="2 days ago"
info="by Josh Henry"
boldInfo="New Album"
aName="Josh Henry"
aSrc={avatar2}
/>
</MenuItem>
<MenuItem borderRadius="8px">
<ItemContent
time="3 days ago"
info="Payment succesfully completed!"
boldInfo=""
aName="Kara"
aSrc={avatar3}
/>
</MenuItem>
</Flex>
</MenuList>
</Menu>
</Flex>
);
}
HeaderLinks.propTypes = {
variant: PropTypes.string,
fixed: PropTypes.bool,
secondary: PropTypes.bool,
onOpen: PropTypes.func,
};

View File

@@ -31,33 +31,49 @@ import {
useColorMode,
useColorModeValue,
useToast,
Tooltip,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
} from '@chakra-ui/react';
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
import { FiStar, FiCalendar } from 'react-icons/fi';
import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-icons/fi';
import { FaCrown } from 'react-icons/fa';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useAuthModal } from '../../contexts/AuthModalContext';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import SubscriptionButton from '../Subscription/SubscriptionButton';
import SubscriptionModal from '../Subscription/SubscriptionModal';
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar';
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
const SecondaryNav = () => {
const SecondaryNav = ({ showCompletenessAlert }) => {
const navigate = useNavigate();
const location = useLocation();
const navbarBg = useColorModeValue('gray.50', 'gray.700');
const itemHoverBg = useColorModeValue('white', 'gray.600');
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
// 定义二级导航结构
const secondaryNavConfig = {
'/community': {
title: '高频跟踪',
items: [
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
'/concepts': {
title: '高频跟踪',
items: [
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
@@ -101,10 +117,10 @@ const SecondaryNav = () => {
<Box
bg={navbarBg}
borderBottom="1px"
borderColor={useColorModeValue('gray.200', 'gray.600')}
borderColor={borderColorValue}
py={2}
position="sticky"
top="60px"
top={showCompletenessAlert ? "120px" : "60px"}
zIndex={100}
>
<Container maxW="container.xl" px={4}>
@@ -179,6 +195,112 @@ const SecondaryNav = () => {
);
};
/** 中屏"更多"菜单 - 用于平板和小笔记本 */
const MoreNavMenu = ({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
return paths.some(path => location.pathname.includes(path));
}, [location.pathname]);
if (!isAuthenticated || !user) return null;
return (
<Menu>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
fontWeight="medium"
>
更多
</MenuButton>
<MenuList minW="300px" p={2}>
{/* 高频跟踪组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
<MenuItem
onClick={() => navigate('/community')}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/concepts')}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">概念中心</Text>
<Badge size="sm" colorScheme="red">NEW</Badge>
</Flex>
</MenuItem>
<MenuDivider />
{/* 行情复盘组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
<MenuItem
onClick={() => navigate('/limit-analyse')}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">涨停分析</Text>
<Badge size="sm" colorScheme="blue">FREE</Badge>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">个股中心</Text>
<Badge size="sm" colorScheme="green">HOT</Badge>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">模拟盘</Text>
<Badge size="sm" colorScheme="red">NEW</Badge>
</Flex>
</MenuItem>
<MenuDivider />
{/* AGENT社群组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">今日热议</Text>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">个股社区</Text>
</MenuItem>
<MenuDivider />
{/* 联系我们 */}
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">联系我们</Text>
</MenuItem>
</MenuList>
</Menu>
);
};
/** 桌面端导航 - 完全按照原网站
* @TODO 添加逻辑 不展示导航case
* 1.未登陆状态 && 是首页
@@ -188,6 +310,9 @@ const NavItems = ({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
return paths.some(path => location.pathname.includes(path));
@@ -220,7 +345,7 @@ const NavItems = ({ isAuthenticated, user }) => {
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
@@ -339,7 +464,7 @@ const NavItems = ({ isAuthenticated, user }) => {
联系我们
</MenuButton>
<MenuList minW="260px" p={4}>
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>敬请期待</Text>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>
</HStack>
@@ -349,13 +474,12 @@ const NavItems = ({ isAuthenticated, user }) => {
}
};
// 计算 API 基础地址(移到组件外部,避免每次 render 重新创建)
const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001'));
export default function HomeNavbar() {
const { isOpen, onOpen, onClose } = useDisclosure();
const navigate = useNavigate();
const isMobile = useBreakpointValue({ base: true, md: false });
const isTablet = useBreakpointValue({ base: false, md: true, lg: false });
const isDesktop = useBreakpointValue({ base: false, md: false, lg: true });
const { user, isAuthenticated, logout, isLoading } = useAuth();
const { openAuthModal } = useAuthModal();
const { colorMode, toggleColorMode } = useColorMode();
@@ -365,12 +489,17 @@ export default function HomeNavbar() {
const brandHover = useColorModeValue('blue.600', 'blue.300');
const toast = useToast();
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
// 添加调试信息
console.log('HomeNavbar Debug:', {
user,
logger.debug('HomeNavbar', '组件渲染状态', {
hasUser: !!user,
isAuthenticated,
isLoading,
userKeys: user ? Object.keys(user) : 'no user'
userId: user?.id
});
// 获取显示名称的函数
@@ -389,7 +518,9 @@ export default function HomeNavbar() {
setShowCompletenessAlert(false);
// logout函数已经包含了跳转逻辑这里不需要额外处理
} catch (error) {
console.error('Logout error:', error);
logger.error('HomeNavbar', 'handleLogout', error, {
userId: user?.id
});
}
};
@@ -407,6 +538,9 @@ export default function HomeNavbar() {
const WATCHLIST_PAGE_SIZE = 10;
const EVENTS_PAGE_SIZE = 8;
// 投资日历 Modal 状态
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
// 用户信息完整性状态
const [profileCompleteness, setProfileCompleteness] = useState(null);
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
@@ -414,6 +548,15 @@ export default function HomeNavbar() {
// 添加标志位:追踪是否已经检查过资料完整性(避免重复请求)
const hasCheckedCompleteness = React.useRef(false);
// 订阅信息状态
const [subscriptionInfo, setSubscriptionInfo] = React.useState({
type: 'free',
status: 'active',
days_left: 0,
is_active: true
});
const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = React.useState(false);
const loadWatchlistQuotes = useCallback(async () => {
try {
setWatchlistLoading(true);
@@ -434,7 +577,9 @@ export default function HomeNavbar() {
setWatchlistQuotes([]);
}
} catch (e) {
console.warn('加载自选股实时行情失败:', e);
logger.warn('HomeNavbar', '加载自选股实时行情失败', {
error: e.message
});
setWatchlistQuotes([]);
} finally {
setWatchlistLoading(false);
@@ -482,7 +627,9 @@ export default function HomeNavbar() {
setFollowingEvents([]);
}
} catch (e) {
console.warn('加载关注事件失败:', e);
logger.warn('HomeNavbar', '加载关注事件失败', {
error: e.message
});
setFollowingEvents([]);
} finally {
setEventsLoading(false);
@@ -550,12 +697,16 @@ export default function HomeNavbar() {
// 如果已经检查过,跳过(避免重复请求)
if (hasCheckedCompleteness.current) {
console.log('[Profile] 已检查过资料完整性,跳过重复请求');
logger.debug('HomeNavbar', '已检查过资料完整性,跳过重复请求', {
userId: user?.id
});
return;
}
try {
console.log('[Profile] 开始检查资料完整性...');
logger.debug('HomeNavbar', '开始检查资料完整性', {
userId: user?.id
});
const base = getApiBase();
const resp = await fetch(base + '/api/account/profile-completeness', {
credentials: 'include'
@@ -569,32 +720,93 @@ export default function HomeNavbar() {
setShowCompletenessAlert(data.data.needsAttention);
// 标记为已检查
hasCheckedCompleteness.current = true;
console.log('[Profile] 资料完整性检查完成:', data.data);
logger.debug('HomeNavbar', '资料完整性检查完成', {
userId: user?.id,
completeness: data.data.completenessPercentage
});
}
}
} catch (error) {
console.warn('检查资料完整性失败:', error);
logger.warn('HomeNavbar', '检查资料完整性失败', {
userId: user?.id,
error: error.message
});
}
}, [isAuthenticated, user]); // 移除 getApiBase 依赖,因为它现在在组件外部
}, [isAuthenticated, userId]); // ⚡ 使用 userId 而不是 user?.id
// 监听用户变化,重置检查标志(用户切换或退出登录时)
React.useEffect(() => {
if (!isAuthenticated || !user) {
// 用户退出登录,重置标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
if (!isAuthenticated || !user) {
// 用户退出登录,重置标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
}
}
}, [isAuthenticated, user?.id]); // 监听用户 ID 变化
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId
// 用户登录后检查资料完整性
React.useEffect(() => {
if (isAuthenticated && user) {
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if ((userIdChanged || authChanged) && isAuthenticated && user) {
// 延迟检查,避免过于频繁
const timer = setTimeout(checkProfileCompleteness, 1000);
return () => clearTimeout(timer);
}
}, [isAuthenticated, user, checkProfileCompleteness]);
}, [isAuthenticated, userId, checkProfileCompleteness, user]); // ⚡ 使用 userId
// 加载订阅信息
React.useEffect(() => {
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
if (isAuthenticated && user) {
const loadSubscriptionInfo = async () => {
try {
const base = getApiBase();
const response = await fetch(base + '/api/subscription/current', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 数据标准化处理确保type字段是小写的 'free', 'pro', 或 'max'
const normalizedData = {
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
status: data.data.status || 'active',
days_left: data.data.days_left || 0,
is_active: data.data.is_active !== false,
end_date: data.data.end_date || null
};
setSubscriptionInfo(normalizedData);
}
}
} catch (error) {
logger.error('HomeNavbar', '加载订阅信息失败', error);
}
};
loadSubscriptionInfo();
} else {
// 用户未登录时,重置为免费版
setSubscriptionInfo({
type: 'free',
status: 'active',
days_left: 0,
is_active: true
});
}
}
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId防重复通过 ref 判断
return (
<>
@@ -603,44 +815,46 @@ export default function HomeNavbar() {
<Box
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
color="white"
py={2}
px={4}
py={{ base: 2, md: 2 }}
px={{ base: 2, md: 4 }}
position="sticky"
top={0}
zIndex={1001}
>
<Container maxW="container.xl">
<HStack justify="space-between" align="center">
<HStack spacing={3}>
<Icon as={FiStar} />
<VStack spacing={0} align="start">
<Text fontSize="sm" fontWeight="bold">
<HStack justify="space-between" align="center" spacing={{ base: 2, md: 4 }}>
<HStack spacing={{ base: 2, md: 3 }} flex={1} minW={0}>
<Icon as={FiStar} display={{ base: 'none', sm: 'block' }} />
<VStack spacing={0} align="start" flex={1} minW={0}>
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold" noOfLines={1}>
完善资料享受更好服务
</Text>
<Text fontSize="xs" opacity={0.9}>
<Text fontSize={{ base: '2xs', md: 'xs' }} opacity={0.9} noOfLines={1}>
您还需要设置{profileCompleteness.missingItems.join('、')}
</Text>
</VStack>
<Text fontSize="xs" bg="whiteAlpha.300" px={2} py={1} borderRadius="full">
<Text fontSize="2xs" bg="whiteAlpha.300" px={2} py={1} borderRadius="full" display={{ base: 'none', md: 'block' }}>
{profileCompleteness.completenessPercentage}% 完成
</Text>
</HStack>
<HStack spacing={2}>
<HStack spacing={{ base: 1, md: 2 }}>
<Button
size="sm"
size={{ base: 'xs', md: 'sm' }}
colorScheme="whiteAlpha"
variant="ghost"
onClick={() => navigate('/home/settings')}
minH={{ base: '32px', md: '40px' }}
>
立即完善
</Button>
<IconButton
size="sm"
size={{ base: 'xs', md: 'sm' }}
variant="ghost"
colorScheme="whiteAlpha"
icon={<Text>×</Text>}
icon={<Text fontSize={{ base: 'xl', md: '2xl' }}>×</Text>}
onClick={() => setShowCompletenessAlert(false)}
aria-label="关闭提醒"
minW={{ base: '32px', md: '40px' }}
/>
</HStack>
</HStack>
@@ -656,103 +870,77 @@ export default function HomeNavbar() {
backdropFilter="blur(10px)"
borderBottom="1px"
borderColor={navbarBorder}
py={3}
py={{ base: 2, md: 3 }}
>
<Container maxW="container.xl" px={4}>
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
<Flex justify="space-between" align="center">
{/* Logo - 价小前投研 */}
<HStack spacing={6}>
<HStack spacing={{ base: 3, md: 6 }}>
<Text
fontSize="xl"
fontSize={{ base: 'lg', md: 'xl' }}
fontWeight="bold"
color={brandText}
cursor="pointer"
_hover={{ color: brandHover }}
onClick={() => navigate('/home')}
style={{ minWidth: '140px' }}
style={{ minWidth: isMobile ? '100px' : '140px' }}
noOfLines={1}
>
价小前投研
</Text>
</HStack>
{/* 移动端菜单按钮 */}
{/* 中间导航区域 - 响应式 */}
{isMobile ? (
// 移动端:汉堡菜单
<IconButton
icon={<HamburgerIcon />}
variant="ghost"
onClick={onOpen}
aria-label="Open menu"
/>
) : <NavItems isAuthenticated={isAuthenticated} user={user} />}
) : isTablet ? (
// 中屏(平板):"更多"下拉菜单
<MoreNavMenu isAuthenticated={isAuthenticated} user={user} />
) : (
// 大屏(桌面):完整导航菜单
<NavItems isAuthenticated={isAuthenticated} user={user} />
)}
{/* 右侧:日夜模式切换 + 登录/用户区 */}
<HStack spacing={4}>
<HStack spacing={{ base: 2, md: 4 }}>
<IconButton
aria-label="切换主题"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
variant="ghost"
size="sm"
minW={{ base: '36px', md: '40px' }}
minH={{ base: '36px', md: '40px' }}
/>
{/* 显示加载状态 */}
{isLoading ? (
<HStack spacing={2}>
<Spinner size="sm" />
<Text fontSize="sm" color="gray.500">检查登录状态...</Text>
</HStack>
<Spinner size="sm" color="blue.500" />
) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={3}>
<Menu>
<MenuButton
as={Button}
bg="gray.800"
color="white"
<HStack spacing={{ base: 2, md: 3 }}>
{/* 投资日历 - 仅大屏显示 */}
{isDesktop && (
<Button
size="sm"
colorScheme="orange"
variant="solid"
borderRadius="full"
_hover={{ bg: 'gray.700' }}
leftIcon={
<Avatar
size="xs"
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
/>
}
leftIcon={<FiCalendar />}
onClick={() => setCalendarModalOpen(true)}
>
{getDisplayName()}
</MenuButton>
<MenuList>
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
<Text fontSize="xs" color="gray.500">{user.email}</Text>
{user.phone && (
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
)}
{user.has_wechat && (
<Badge size="sm" colorScheme="green" mt={1}>微信已绑定</Badge>
)}
</Box>
<MenuItem onClick={() => navigate('/home/profile')}>
👤 个人资料
</MenuItem>
<MenuItem onClick={() => navigate('/home/pages/account/subscription')}>
💎 订阅管理
</MenuItem>
<MenuItem onClick={() => navigate('/home/settings')}>
账户设置
</MenuItem>
<MenuItem onClick={() => navigate('/home/center')}>
🏠 个人中心
</MenuItem>
<MenuDivider />
<MenuItem onClick={handleLogout} color="red.500">
🚪 退出登录
</MenuItem>
</MenuList>
</Menu>
投资日历
</Button>
)}
{/* 自选股 - 头像右侧 */}
{/* 自选股 - 仅大屏显示 */}
{isDesktop && (
<Menu onOpen={loadWatchlistQuotes}>
<MenuButton
as={Button}
@@ -827,8 +1015,10 @@ export default function HomeNavbar() {
)}
</MenuList>
</Menu>
)}
{/* 关注的事件 - 头像右侧 */}
{/* 关注的事件 - 仅大屏显示 */}
{isDesktop && (
<Menu onOpen={loadFollowingEvents}>
<MenuButton
as={Button}
@@ -909,6 +1099,208 @@ export default function HomeNavbar() {
)}
</MenuList>
</Menu>
)}
{/* 头像区域 - 响应式 */}
{isDesktop ? (
// 大屏:头像点击打开订阅弹窗
<>
<Tooltip
label={<TooltipContent subscriptionInfo={subscriptionInfo} />}
placement="bottom"
hasArrow
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
border="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.600')}
boxShadow="lg"
p={3}
>
<Box
position="relative"
cursor="pointer"
onClick={() => setIsSubscriptionModalOpen(true)}
>
<CrownIcon subscriptionInfo={subscriptionInfo} />
<Avatar
size="sm"
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
border={subscriptionInfo.type !== 'free' ? '2.5px solid' : 'none'}
borderColor={
subscriptionInfo.type === 'max' ? '#667eea' :
subscriptionInfo.type === 'pro' ? '#667eea' : 'transparent'
}
_hover={{
transform: 'scale(1.05)',
boxShadow: subscriptionInfo.type !== 'free'
? '0 4px 12px rgba(102, 126, 234, 0.4)'
: 'md',
}}
transition="all 0.2s"
/>
</Box>
</Tooltip>
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={() => setIsSubscriptionModalOpen(false)}
subscriptionInfo={subscriptionInfo}
/>
)}
</>
) : (
// 中屏:头像作为下拉菜单,包含所有功能
<Menu>
<MenuButton>
<Box position="relative">
<CrownIcon subscriptionInfo={subscriptionInfo} />
<Avatar
size="sm"
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
border={subscriptionInfo.type !== 'free' ? '2.5px solid' : 'none'}
borderColor={
subscriptionInfo.type === 'max' ? '#667eea' :
subscriptionInfo.type === 'pro' ? '#667eea' : 'transparent'
}
_hover={{ transform: 'scale(1.05)' }}
transition="all 0.2s"
/>
</Box>
</MenuButton>
<MenuList minW="320px">
{/* 用户信息区 */}
<Box px={3} py={2} borderBottom="1px" borderColor={useColorModeValue('gray.200', 'gray.600')}>
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
<Text fontSize="xs" color="gray.500">{user.email}</Text>
{user.phone && (
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
)}
{user.has_wechat && (
<Badge size="sm" colorScheme="green" mt={1}>微信已绑定</Badge>
)}
</Box>
{/* 订阅管理 */}
<MenuItem icon={<FaCrown />} onClick={() => setIsSubscriptionModalOpen(true)}>
<Flex justify="space-between" align="center" w="100%">
<Text>订阅管理</Text>
<Badge colorScheme={subscriptionInfo.type === 'free' ? 'gray' : 'purple'}>
{subscriptionInfo.type === 'max' ? 'MAX' :
subscriptionInfo.type === 'pro' ? 'PRO' : '免费版'}
</Badge>
</Flex>
</MenuItem>
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={() => setIsSubscriptionModalOpen(false)}
subscriptionInfo={subscriptionInfo}
/>
)}
<MenuDivider />
{/* 投资日历 */}
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/community')}>
<Text>投资日历</Text>
</MenuItem>
{/* 自选股 */}
<MenuItem icon={<FiStar />} onClick={() => navigate('/home/center')}>
<Flex justify="space-between" align="center" w="100%">
<Text>我的自选股</Text>
{watchlistQuotes && watchlistQuotes.length > 0 && (
<Badge>{watchlistQuotes.length}</Badge>
)}
</Flex>
</MenuItem>
{/* 自选事件 */}
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/home/center')}>
<Flex justify="space-between" align="center" w="100%">
<Text>我的自选事件</Text>
{followingEvents && followingEvents.length > 0 && (
<Badge>{followingEvents.length}</Badge>
)}
</Flex>
</MenuItem>
<MenuDivider />
{/* 个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
个人中心
</MenuItem>
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
账户设置
</MenuItem>
<MenuDivider />
{/* 退出登录 */}
<MenuItem icon={<FiLogOut />} onClick={handleLogout} color="red.500">
退出登录
</MenuItem>
</MenuList>
</Menu>
)}
{/* 个人中心下拉菜单 - 仅大屏显示 */}
{isDesktop && (
<Menu>
<MenuButton
as={Button}
size="sm"
variant="ghost"
rightIcon={<ChevronDownIcon />}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
>
个人中心
</MenuButton>
<MenuList>
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
<Text fontSize="xs" color="gray.500">{user.email}</Text>
{user.phone && (
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
)}
{user.has_wechat && (
<Badge size="sm" colorScheme="green" mt={1}>微信已绑定</Badge>
)}
</Box>
{/* 前往个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
前往个人中心
</MenuItem>
<MenuDivider />
{/* 账户管理组 */}
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
账户设置
</MenuItem>
<MenuDivider />
{/* 功能入口组 */}
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
订阅管理
</MenuItem>
<MenuDivider />
{/* 退出 */}
<MenuItem icon={<FiLogOut />} onClick={handleLogout} color="red.500">
退出登录
</MenuItem>
</MenuList>
</Menu>
)}
</HStack>
) : (
// 未登录状态 - 单一按钮
@@ -1015,7 +1407,7 @@ export default function HomeNavbar() {
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">HOT</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
@@ -1178,7 +1570,23 @@ export default function HomeNavbar() {
</Box>
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
{!isMobile && <SecondaryNav />}
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
{/* 投资日历 Modal */}
<Modal
isOpen={calendarModalOpen}
onClose={() => setCalendarModalOpen(false)}
size="6xl"
>
<ModalOverlay />
<ModalContent maxW="1200px">
<ModalHeader>投资日历</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<InvestmentCalendar />
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,750 @@
// src/components/NotificationContainer/index.js
/**
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
VStack,
HStack,
Text,
IconButton,
Icon,
Badge,
Button,
Spinner,
useColorModeValue,
} from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import { motion, AnimatePresence } from 'framer-motion';
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess, MdPerson, MdAccessTime } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import {
NOTIFICATION_TYPE_CONFIGS,
NOTIFICATION_TYPES,
PRIORITY_CONFIGS,
PRIORITY_LEVELS,
NOTIFICATION_CONFIG,
formatNotificationTime,
getPriorityBgOpacity,
getPriorityBorderWidth,
} from '../../constants/notificationTypes';
/**
* 自定义 Hook带过期时间的 localStorage 持久化状态
* @param {string} key - localStorage 的 key
* @param {*} initialValue - 初始值
* @param {number} expiryMs - 过期时间毫秒0 表示不过期
* @returns {[*, Function]} - [状态值, 设置函数]
*/
const useLocalStorageWithExpiry = (key, initialValue, expiryMs = 0) => {
// 从 localStorage 读取带过期时间的值
const readValue = () => {
try {
const item = window.localStorage.getItem(key);
if (!item) {
return initialValue;
}
const { value, timestamp } = JSON.parse(item);
// 检查是否过期(仅当设置了过期时间时)
if (expiryMs > 0 && timestamp) {
const now = Date.now();
const elapsed = now - timestamp;
if (elapsed > expiryMs) {
// 已过期,删除并返回初始值
window.localStorage.removeItem(key);
return initialValue;
}
}
return value;
} catch (error) {
console.error(`Error reading ${key} from localStorage:`, error);
return initialValue;
}
};
const [storedValue, setStoredValue] = useState(readValue);
// 保存值到 localStorage带时间戳
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
const item = {
value: valueToStore,
timestamp: Date.now(), // 保存时间戳
};
window.localStorage.setItem(key, JSON.stringify(item));
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error);
}
};
// 监听 storage 事件(跨标签页同步)
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue !== null) {
try {
const { value, timestamp } = JSON.parse(e.newValue);
// 检查是否过期
if (expiryMs > 0 && timestamp) {
const now = Date.now();
const elapsed = now - timestamp;
if (elapsed > expiryMs) {
// 过期,设置为初始值
setStoredValue(initialValue);
return;
}
}
// 更新状态
setStoredValue(value);
} catch (error) {
console.error(`Error parsing storage event for ${key}:`, error);
}
} else if (e.key === key && e.newValue === null) {
// 其他标签页删除了该值
setStoredValue(initialValue);
}
};
// 添加事件监听
window.addEventListener('storage', handleStorageChange);
// 清理函数
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key, expiryMs, initialValue]);
// 定时检查过期(可选,更精确的过期控制)
useEffect(() => {
if (expiryMs <= 0) return; // 不需要过期检查
const checkExpiry = () => {
try {
const item = window.localStorage.getItem(key);
if (!item) return;
const { value, timestamp } = JSON.parse(item);
const now = Date.now();
const elapsed = now - timestamp;
if (elapsed > expiryMs) {
// 已过期,重置状态
setStoredValue(initialValue);
window.localStorage.removeItem(key);
}
} catch (error) {
console.error(`Error checking expiry for ${key}:`, error);
}
};
// 每10秒检查一次过期
const intervalId = setInterval(checkExpiry, 10000);
// 立即执行一次检查
checkExpiry();
return () => clearInterval(intervalId);
}, [key, expiryMs, initialValue]);
return [storedValue, setValue];
};
/**
* 辅助函数:生成通知的完整无障碍描述
* @param {object} notification - 通知对象
* @returns {string} - ARIA 描述文本
*/
const getNotificationDescription = (notification) => {
const { type, priority, title, content, isAIGenerated, publishTime, pushTime, extra } = notification;
// 获取配置
const typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
const priorityConfig = PRIORITY_CONFIGS[priority];
// 构建描述片段
const parts = [];
// 优先级(如果需要显示)
if (priorityConfig?.show) {
parts.push(`${priorityConfig.label}通知`);
}
// 类型
parts.push(typeConfig.name);
// 标题
parts.push(title);
// 内容
if (content) {
parts.push(content);
}
// AI 生成标识
if (isAIGenerated) {
parts.push('由AI生成');
}
// 预测标识
if (extra?.isPrediction) {
parts.push('预测状态');
if (extra?.statusHint) {
parts.push(extra.statusHint);
}
}
// 时间信息
const time = publishTime || pushTime;
if (time) {
parts.push(`时间:${formatNotificationTime(time)}`);
}
// 操作提示
if (notification.clickable && notification.link) {
parts.push('按回车键或空格键查看详情');
}
return parts.join('');
};
/**
* 辅助函数处理键盘按键事件Enter / Space
* @param {KeyboardEvent} event - 键盘事件
* @param {Function} callback - 回调函数
*/
const handleKeyPress = (event, callback) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback();
}
};
/**
* 紧急通知脉冲动画 - 边框颜色脉冲效果
*/
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;
}
`;
/**
* 单个通知项组件
* 使用 React.memo 优化,避免不必要的重渲染
*/
const NotificationItem = React.memo(({ notification, onClose, isNewest = false }) => {
const navigate = useNavigate();
const { trackNotificationClick } = useNotification();
// 加载状态管理 - 点击跳转时显示 loading
const [isNavigating, setIsNavigating] = useState(false);
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
const isActuallyClickable = clickable && link;
// 判断是否为预测通知
const isPrediction = extra?.isPrediction;
// 获取类型配置
let typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
// 股票动向需要根据涨跌动态配置
if (type === NOTIFICATION_TYPES.STOCK_ALERT && extra?.priceChange) {
const priceChange = extra.priceChange;
typeConfig = {
...typeConfig,
icon: typeConfig.getIcon(priceChange),
colorScheme: typeConfig.getColorScheme(priceChange),
// 亮色模式
bg: typeConfig.getBg(priceChange),
borderColor: typeConfig.getBorderColor(priceChange),
iconColor: typeConfig.getIconColor(priceChange),
hoverBg: typeConfig.getHoverBg(priceChange),
// 暗色模式
darkBg: typeConfig.getDarkBg(priceChange),
darkBorderColor: typeConfig.getDarkBorderColor(priceChange),
darkIconColor: typeConfig.getDarkIconColor(priceChange),
darkHoverBg: typeConfig.getDarkHoverBg(priceChange),
};
}
// 获取优先级配置
const priorityConfig = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS.normal;
// 判断是否显示图标(仅紧急和重要通知)
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
// 获取优先级样式
const priorityBorderWidth = getPriorityBorderWidth(priority);
const isDark = useColorModeValue(false, true);
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
// 根据优先级调整背景色深度(使用 useMemo 缓存计算结果)
const priorityBgColor = useMemo(() => {
const colorScheme = typeConfig.colorScheme;
// 亮色模式:根据优先级使用不同深度的颜色
if (!isDark) {
if (priority === PRIORITY_LEVELS.URGENT) {
return `${colorScheme}.200`; // 紧急:深色背景 + 脉冲动画
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
return `${colorScheme}.100`; // 重要:中色背景
} else {
// 普通:极淡背景(使用 white 或 gray.50,降低视觉干扰)
return 'white';
}
} else {
// 暗色模式:使用 typeConfig 的 darkBg 或回退
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';
}
}
}, [isDark, priority, typeConfig]);
// 颜色配置 - 支持亮色/暗色模式
// ⚠️ 必须在组件顶层调用 useColorModeValue不能在 useMemo 内部调用
const borderColor = useColorModeValue(
typeConfig.borderColor,
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
);
const iconColor = useColorModeValue(
typeConfig.iconColor,
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
);
const textColor = useColorModeValue('gray.800', 'gray.100');
const subTextColor = useColorModeValue('gray.600', 'gray.300');
const metaTextColor = useColorModeValue('gray.500', 'gray.500');
const hoverBgColor = useColorModeValue(
typeConfig.hoverBg,
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
);
const closeButtonHoverBgColor = useColorModeValue(
`${typeConfig.colorScheme}.200`,
`${typeConfig.colorScheme}.700`
);
// 使用 useMemo 缓存颜色对象(避免不必要的重新创建)
const colors = useMemo(() => ({
bg: priorityBgColor,
border: borderColor,
icon: iconColor,
text: textColor,
subText: subTextColor,
metaText: metaTextColor,
hoverBg: hoverBgColor,
closeButtonHoverBg: closeButtonHoverBgColor,
}), [priorityBgColor, borderColor, iconColor, textColor, subTextColor, metaTextColor, hoverBgColor, closeButtonHoverBgColor]);
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
const handleClick = useCallback(() => {
if (isActuallyClickable && !isNavigating) {
// 设置加载状态
setIsNavigating(true);
// 追踪点击(监控埋点)
trackNotificationClick(id);
// 导航到目标页面
navigate(link);
// 延迟关闭通知(给用户足够的视觉反馈 - 300ms
setTimeout(() => {
onClose(id, true);
}, 300);
}
}, [id, link, isActuallyClickable, isNavigating, trackNotificationClick, navigate, onClose]);
// 生成完整的无障碍描述
const ariaDescription = getNotificationDescription(notification);
return (
<Box
// 无障碍属性
role={priority === 'urgent' ? 'alert' : 'status'}
aria-live={priority === 'urgent' ? 'assertive' : 'polite'}
aria-atomic="true"
aria-label={ariaDescription}
tabIndex={isActuallyClickable ? 0 : -1}
onKeyDown={(e) => isActuallyClickable && handleKeyPress(e, handleClick)}
// 样式属性
bg={colors.bg}
borderLeft={`${priorityBorderWidth} solid`}
borderColor={colors.border}
// 可点击的通知添加完整边框提示
{...(isActuallyClickable && {
border: '1px solid',
borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
})}
borderRadius="md"
// 可点击的通知使用更明显的阴影(悬浮感)
boxShadow={isActuallyClickable
? (isNewest ? '2xl' : 'md')
: (isNewest ? 'xl' : 'sm')}
// 紧急通知添加脉冲动画
animation={priority === PRIORITY_LEVELS.URGENT ? `${pulseAnimation} 2s ease-in-out infinite` : undefined}
p={{ base: 3, md: 4 }}
w={{ base: "calc(100vw - 32px)", sm: "360px", md: "380px", lg: "400px" }}
maxW="400px"
position="relative"
cursor={isActuallyClickable ? (isNavigating ? 'wait' : 'pointer') : 'default'}
onClick={isActuallyClickable && !isNavigating ? handleClick : undefined}
opacity={isNavigating ? 0.7 : 1}
pointerEvents={isNavigating ? 'none' : 'auto'}
_hover={isActuallyClickable && !isNavigating ? {
boxShadow: 'xl',
transform: 'translateY(-2px)',
bg: colors.hoverBg,
} : {}} // 不可点击时无 hover 效果
_focus={{
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '2px',
}}
transition="all 0.2s"
willChange="transform, opacity" // 性能优化GPU 加速
{...(isNewest && {
borderRight: '1px solid',
borderRightColor: colors.border,
borderTop: '1px solid',
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
})}
>
{/* 头部区域:标题 + 可选标识 */}
<HStack spacing={2} align="start" mb={2}>
{/* 类型图标 - 仅紧急和重要通知显示 */}
{shouldShowIcon && (
<Icon
as={typeConfig.icon}
w={5}
h={5}
color={colors.icon} // 使用响应式颜色
mt={0.5}
flexShrink={0}
/>
)}
{/* 标题 */}
<Text
fontSize="sm"
fontWeight="bold"
color={colors.text}
lineHeight="short"
flex={1}
noOfLines={{ base: 1, md: 2 }}
pl={shouldShowIcon ? 0 : 0}
>
{title}
</Text>
{/* 优先级标签 */}
{priorityConfig.show && (
<Badge
colorScheme={priorityConfig.colorScheme}
size="sm"
flexShrink={0}
>
{priorityConfig.label}
</Badge>
)}
{/* 关闭按钮 */}
<IconButton
icon={<MdClose />}
size="xs"
variant="ghost"
colorScheme={typeConfig.colorScheme}
aria-label={`关闭通知:${title}`}
onClick={(e) => {
e.stopPropagation();
onClose(id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onClose(id);
}
}}
flexShrink={0}
_hover={{
bg: colors.closeButtonHoverBg,
}}
/>
</HStack>
{/* 内容区域 */}
<Text
fontSize="sm"
color={colors.subText}
lineHeight="short"
noOfLines={{ base: 2, md: 3 }}
mb={3}
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
>
{content}
</Text>
{/* 底部元数据区域 */}
<HStack
spacing={2}
fontSize="xs"
color={colors.metaText}
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
flexWrap="wrap"
>
{/* 时间信息 */}
<HStack spacing={1}>
<Icon as={MdAccessTime} w={3} h={3} />
<Text>
{publishTime && formatNotificationTime(publishTime)}
{!publishTime && pushTime && formatNotificationTime(pushTime)}
</Text>
</HStack>
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
{isAIGenerated && (
<>
<Text display={{ base: "none", sm: "inline" }}>|</Text>
<Badge
colorScheme="purple"
size="xs"
display={{ base: "none", sm: "inline-flex" }}
>
AI
</Badge>
</>
)}
{/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/}
{isPrediction && (
<>
<Text display={{ base: "none", sm: "inline" }}>|</Text>
<HStack spacing={1} display={{ base: "none", sm: "flex" }}>
<Badge colorScheme="gray" size="xs">预测</Badge>
{extra?.statusHint && (
<>
<Icon as={MdSchedule} w={3} h={3} color="gray.400" />
<Text color="gray.400">{extra.statusHint}</Text>
</>
)}
</HStack>
</>
)}
{/* 作者信息 - 仅当数据存在时显示(平板及以上)*/}
{author && (
<>
<Text display={{ base: "none", md: "inline" }}>|</Text>
<HStack spacing={1} display={{ base: "none", md: "flex" }}>
<Icon as={MdPerson} w={3} h={3} />
<Text>{author.name} - {author.organization}</Text>
</HStack>
</>
)}
{/* 可点击提示(仅真正可点击的通知)*/}
{isActuallyClickable && (
<>
<Text>|</Text>
<HStack spacing={1}>
{/* Loading 时显示 Spinner否则显示图标 */}
{isNavigating ? (
<Spinner size="xs" />
) : (
<Icon as={MdOpenInNew} w={3} h={3} />
)}
{/* Loading 时显示"跳转中...",否则显示"查看详情" */}
<Text color={isNavigating ? 'blue.500' : undefined}>
{isNavigating ? '跳转中...' : '查看详情'}
</Text>
</HStack>
</>
)}
</HStack>
</Box>
);
}, (prevProps, nextProps) => {
// 自定义比较函数:只在 id 或 isNewest 变化时重渲染
return (
prevProps.notification.id === nextProps.notification.id &&
prevProps.isNewest === nextProps.isNewest
);
});
/**
* 通知容器组件 - 主组件
*/
const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification();
// 使用带过期时间的 localStorage2分钟 = 120000 毫秒)
const [isExpanded, setIsExpanded] = useLocalStorageWithExpiry(
'notification-expanded-state',
false,
120000
);
// 追踪新通知(性能优化:只对新通知做动画)
const prevNotificationIdsRef = useRef(new Set());
const isFirstRenderRef = useRef(true);
const [newNotificationIds, setNewNotificationIds] = useState(new Set());
useEffect(() => {
// 首次渲染跳过动画检测
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
const currentIds = new Set(notifications.map(n => n.id));
prevNotificationIdsRef.current = currentIds;
return;
}
const currentIds = new Set(notifications.map(n => n.id));
const prevIds = prevNotificationIdsRef.current;
// 找出新增的通知 ID
const newIds = new Set();
currentIds.forEach(id => {
if (!prevIds.has(id)) {
newIds.add(id);
}
});
setNewNotificationIds(newIds);
// 更新引用
prevNotificationIdsRef.current = currentIds;
// 1秒后清除新通知标记动画完成
if (newIds.size > 0) {
const timer = setTimeout(() => {
setNewNotificationIds(new Set());
}, 1000);
return () => clearTimeout(timer);
}
}, [notifications]);
// ⚠️ 颜色配置 - 必须在条件return之前调用所有Hooks
const collapseBg = useColorModeValue('gray.100', 'gray.700');
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
// 如果没有通知,不渲染
if (notifications.length === 0) {
return null;
}
// 根据展开状态决定显示的通知
const maxVisible = NOTIFICATION_CONFIG.maxVisible;
const hasMore = notifications.length > maxVisible;
const visibleNotifications = isExpanded ? notifications : notifications.slice(0, maxVisible);
const hiddenCount = notifications.length - maxVisible;
// 构建无障碍描述
const containerAriaLabel = hasMore
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航Enter键或空格键查看详情。`
: `通知中心,共有 ${notifications.length} 条通知。使用Tab键导航Enter键或空格键查看详情。`;
return (
<Box
role="region"
aria-label={containerAriaLabel}
position="fixed"
bottom={{ base: 3, md: 28 }}
right={{ base: 3, md: 6 }}
zIndex={9999}
pointerEvents="none"
>
<VStack
spacing={3} // 消息之间间距 12px
align="flex-end"
pointerEvents="auto"
>
<AnimatePresence mode="popLayout">
{visibleNotifications.map((notification, index) => (
<motion.div
key={notification.id}
layout // 自动处理位置变化(流畅重排)
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 300, scale: 0.9 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
}}
style={{
position: 'relative',
zIndex: 9999 - index,
}}
>
<NotificationItem
notification={notification}
onClose={removeNotification}
isNewest={index === 0}
/>
</motion.div>
))}
</AnimatePresence>
{/* 折叠/展开按钮 */}
{hasMore && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.2 }}
>
<Button
size={{ base: "xs", md: "sm" }}
variant="solid"
bg={collapseBg}
color={collapseTextColor}
_hover={{ bg: collapseHoverBg }}
_focus={{
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '2px',
}}
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
boxShadow="md"
borderRadius="md"
>
{isExpanded
? '收起通知'
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
}
</Button>
</motion.div>
)}
</VStack>
</Box>
);
};
export default NotificationContainer;

View File

@@ -0,0 +1,664 @@
// 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 { SOCKET_TYPE } from '../../services/socket';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
const NotificationTestTool = () => {
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();
};
// 只在开发环境显示
if (process.env.NODE_ENV !== 'development') {
return null;
}
// 公告通知测试数据
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">
{SOCKET_TYPE}
</Badge>
<Badge colorScheme={getPermissionColor()}>
浏览器: {getPermissionLabel()}
</Badge>
<IconButton
icon={isOpen ? <MdClose /> : <MdNotifications />}
size="xs"
variant="ghost"
colorScheme="whiteAlpha"
aria-label={isOpen ? '关闭' : '打开'}
/>
</HStack>
{/* 工具面板 */}
<Collapse in={isOpen} animateOpacity>
<VStack p={4} spacing={3} align="stretch" minW="280px">
<Text fontSize="xs" color="gray.600" fontWeight="bold">
通知类型测试
</Text>
{/* 公告通知 */}
<Button
size="sm"
colorScheme="blue"
leftIcon={<MdCampaign />}
onClick={testAnnouncement}
>
公告通知
</Button>
{/* 事件动向 */}
<Button
size="sm"
colorScheme="orange"
leftIcon={<MdArticle />}
onClick={testEventAlert}
>
事件动向
</Button>
{/* 分析报告 */}
<Button
size="sm"
colorScheme="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
>
分析报告
</Button>
{/* 预测通知 */}
<Button
size="sm"
colorScheme="gray"
leftIcon={<MdArticle />}
onClick={testPrediction}
>
预测通知不可跳转
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
组合测试
</Text>
{/* 预测→详情流程测试 */}
<Button
size="sm"
colorScheme="cyan"
onClick={testPredictionFlow}
>
预测详情流程5秒延迟
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
浏览器通知
</Text>
{/* 请求权限按钮 */}
{browserPermission !== 'granted' && (
<Button
size="sm"
colorScheme={browserPermission === 'denied' ? 'red' : 'blue'}
onClick={handleRequestPermission}
isDisabled={browserPermission === 'denied'}
>
{browserPermission === 'denied' ? '权限已拒绝' : '请求浏览器权限'}
</Button>
)}
{/* 测试浏览器通知按钮 */}
{browserPermission === 'granted' && (
<Button
size="sm"
colorScheme="green"
leftIcon={<MdNotifications />}
onClick={() => {
console.log('测试浏览器通知按钮被点击');
console.log('Notification support:', 'Notification' in window);
console.log('Notification permission:', Notification?.permission);
console.log('Platform:', navigator.platform);
console.log('Fullscreen:', !!document.fullscreenElement);
// 直接使用原生 Notification API 测试
if (!('Notification' in window)) {
alert('您的浏览器不支持桌面通知');
return;
}
if (Notification.permission !== 'granted') {
alert('浏览器通知权限未授予\n当前权限状态' + Notification.permission);
return;
}
// 重置状态
setNotificationShown(null);
setIsTestingNotification(true);
setTestCountdown(8); // 8秒倒计时
try {
console.log('正在创建浏览器通知...');
const notification = new Notification('【测试】浏览器通知测试', {
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
icon: '/logo192.png',
badge: '/badge.png',
tag: 'test_notification_' + Date.now(),
requireInteraction: false,
});
console.log('浏览器通知创建成功:', notification);
// 监听通知显示(成功显示)
notification.onshow = () => {
console.log('✅ 浏览器通知已显示onshow 事件触发)');
setNotificationShown(true);
};
// 监听通知错误
notification.onerror = (error) => {
console.error('❌ 浏览器通知错误:', error);
setNotificationShown(false);
};
// 监听通知关闭
notification.onclose = () => {
console.log('浏览器通知已关闭');
};
// 8秒后自动关闭
setTimeout(() => {
notification.close();
console.log('浏览器通知已自动关闭');
}, 8000);
// 点击通知时聚焦窗口
notification.onclick = () => {
console.log('浏览器通知被点击');
window.focus();
notification.close();
setNotificationShown(true);
};
setTestCount(prev => prev + 1);
} catch (error) {
console.error('创建浏览器通知失败:', error);
alert('创建浏览器通知失败:' + error.message);
setIsTestingNotification(false);
setNotificationShown(false);
}
}}
isLoading={isTestingNotification}
loadingText={`等待通知... ${testCountdown}s`}
>
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
</Button>
)}
{/* 浏览器通知状态说明 */}
{browserPermission === 'granted' && (
<Text fontSize="xs" color="green.500">
浏览器通知已启用
</Text>
)}
{browserPermission === 'denied' && (
<Text fontSize="xs" color="red.500">
请在浏览器设置中允许通知
</Text>
)}
{/* 实时权限状态 */}
<HStack spacing={2} justify="center">
<Text fontSize="xs" color="gray.500">
实际权限
</Text>
<Badge
colorScheme={
('Notification' in window && Notification.permission === 'granted') ? 'green' :
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
}
>
{('Notification' in window) ? Notification.permission : '不支持'}
</Badge>
</HStack>
{/* 环境警告 */}
{isFullscreen && (
<Alert status="warning" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">全屏模式</Text>
<Text>某些浏览器在全屏模式下不显示通知</Text>
</Box>
</Alert>
)}
{isMacOS && notificationShown === false && (
<Alert status="error" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">未检测到通知显示</Text>
<Text>可能是专注模式阻止了通知</Text>
</Box>
</Alert>
)}
<Divider />
{/* 故障排查面板 */}
<VStack spacing={2} align="stretch">
<Button
size="sm"
variant="outline"
colorScheme="orange"
leftIcon={<MdWarning />}
onClick={onTroubleshootToggle}
>
{isTroubleshootOpen ? '收起' : '故障排查指南'}
</Button>
<Collapse in={isTroubleshootOpen} animateOpacity>
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
<Text fontSize="xs" fontWeight="bold" color="orange.800">
如果看不到浏览器通知请检查
</Text>
{/* macOS 专注模式 */}
{isMacOS && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>点击右上角控制中心</ListItem>
<ListItem>关闭专注模式勿扰模式</ListItem>
<ListItem>或者系统设置 专注模式 关闭</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* macOS 系统通知设置 */}
{isMacOS && (
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>系统设置 通知</ListItem>
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> <Code fontSize="xs">Microsoft Edge</Code></ListItem>
<ListItem>确保允许通知已开启</ListItem>
<ListItem>通知样式设置为横幅提醒</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* Chrome 浏览器设置 */}
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
<ListItem>确保网站可以请求发送通知已开启</ListItem>
<ListItem>检查本站点是否在允许列表中</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
{/* 全屏模式提示 */}
{isFullscreen && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
<AlertDescription>
<Code fontSize="xs">ESC</Code> 退
</AlertDescription>
</Box>
</Alert>
)}
{/* 测试结果反馈 */}
{notificationShown === true && (
<Alert status="success" size="sm">
<AlertIcon />
<Text fontSize="xs"> 通知功能正常</Text>
</Alert>
)}
</VStack>
</Collapse>
</VStack>
<Divider />
{/* 功能按钮 */}
<HStack spacing={2}>
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={clearAllNotifications}
flex={1}
>
清空全部
</Button>
<IconButton
size="sm"
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
colorScheme={soundEnabled ? 'blue' : 'gray'}
onClick={toggleSound}
aria-label="切换音效"
/>
</HStack>
{/* 统计信息 */}
<VStack spacing={1}>
<HStack justify="space-between" w="full">
<Text fontSize="xs" color="gray.500">
当前队列:
</Text>
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
{notifications.length} / 5
</Badge>
</HStack>
<Text fontSize="xs" color="gray.400" textAlign="center">
已测试: {testCount} 条通知
</Text>
</VStack>
</VStack>
</Collapse>
</Box>
);
};
export default NotificationTestTool;

View File

@@ -1,5 +1,5 @@
// src/components/ProtectedRoute.js - 弹窗拦截版本
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import { useAuthModal } from '../contexts/AuthModalContext';
@@ -8,15 +8,17 @@ const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading, user } = useAuth();
const { openAuthModal, isAuthModalOpen } = useAuthModal();
// 记录当前路径,登录成功后可以跳转回来
const currentPath = window.location.pathname + window.location.search;
// ⚡ 使用 useRef 保存当前路径,避免每次渲染创建新字符串导致 useEffect 无限循环
const currentPathRef = useRef(window.location.pathname + window.location.search);
// 未登录时自动弹出认证窗口
useEffect(() => {
if (!isLoading && !isAuthenticated && !user && !isAuthModalOpen) {
openAuthModal(currentPath);
openAuthModal(currentPathRef.current);
}
}, [isAuthenticated, user, isLoading, isAuthModalOpen, currentPath, openAuthModal]);
// ⚠️ 移除 user 依赖,因为 user 对象每次从 API 返回都是新引用,会导致无限循环
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, isLoading, isAuthModalOpen, openAuthModal]);
// 显示加载状态
if (isLoading) {

View File

@@ -0,0 +1,38 @@
// src/components/ProtectedRouteRedirect.js - 跳转版本
// 未登录时跳转到首页,用于三级页面(详情页)
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
const ProtectedRouteRedirect = ({ children }) => {
const { isAuthenticated, isLoading, user } = useAuth();
// 显示加载状态
if (isLoading) {
return (
<Box
height="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text fontSize="lg" color="gray.600">正在验证登录状态...</Text>
</VStack>
</Box>
);
}
// 未登录,直接跳转到首页
if (!isAuthenticated || !user) {
return <Navigate to="/home" replace />;
}
// 已登录,正常渲染子组件
return children;
};
export default ProtectedRouteRedirect;

View File

@@ -1,719 +0,0 @@
/* eslint-disable */
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
import { HamburgerIcon } from '@chakra-ui/icons';
// chakra imports
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerOverlay,
Flex,
HStack,
Icon,
List,
ListItem,
Stack,
Text,
useColorModeValue,
useDisclosure
} from '@chakra-ui/react';
import IconBox from 'components/Icons/IconBox';
import {
renderThumbDark,
renderThumbLight,
renderTrack,
renderTrackRTL,
renderView,
renderViewRTL
} from 'components/Scrollbar/Scrollbar';
import { HSeparator } from 'components/Separator/Separator';
import { SidebarContext } from 'contexts/SidebarContext';
import React from 'react';
import { Scrollbars } from 'react-custom-scrollbars-2';
import { FaCircle } from 'react-icons/fa';
import { NavLink, useLocation } from 'react-router-dom';
import SidebarDocs from './SidebarDocs';
// FUNCTIONS
function Sidebar(props) {
// to check for active links and opened collapses
let location = useLocation();
const { routes, landing } = props;
// this is for the rest of the collapses
const { sidebarWidth, setSidebarWidth, toggleSidebar } = React.useContext(SidebarContext);
let variantChange = '0.2s linear';
// verifies if routeName is the one active (in browser input)
const activeRoute = (routeName) => {
return location.pathname.includes(routeName);
};
// this function creates the links and collapses that appear in the sidebar (left menu)
const createLinks = (routes) => {
// Chakra Color Mode
let activeBg = 'blue.500';
let inactiveBg = useColorModeValue('transparent', 'navy.700');
let activeColor = useColorModeValue('gray.700', 'white');
let inactiveColor = useColorModeValue('gray.400', 'gray.400');
let sidebarActiveShadow = '0px 7px 11px rgba(0, 0, 0, 0.04)';
let activeAccordionBg = useColorModeValue('white', 'navy.700');
let activeColorIcon = 'white';
let inactiveColorIcon = 'blue.500';
if (landing) {
activeBg = 'white';
inactiveBg = 'transparent';
activeColor = 'white';
inactiveColor = 'white';
sidebarActiveShadow = '0px 7px 11px rgba(0, 0, 0, 0.04)';
activeAccordionBg = 'rgba(255, 255, 255, 0.11)';
activeColorIcon = 'blue.500';
inactiveColorIcon = 'white';
}
return routes.map((prop, key) => {
if (prop.category) {
return (
<Box key={key}>
<Text
fontSize={sidebarWidth === 275 ? 'md' : 'xs'}
color={activeColor}
fontWeight='bold'
mx='auto'
ps={{
sm: '10px',
xl: '16px'
}}
pt='18px'
pb='12px'
key={key}>
{prop.name}
</Text>
{createLinks(prop.items)}
</Box>
);
}
if (prop.collapse) {
return (
<Accordion key={key} allowToggle>
<AccordionItem border='none'>
<AccordionButton
display='flex'
align='center'
justify='center'
boxShadow={activeRoute(prop.path) && prop.icon ? sidebarActiveShadow : null}
_hover={{
boxShadow: activeRoute(prop.path) && prop.icon ? sidebarActiveShadow : null
}}
_focus={{
boxShadow: 'none'
}}
borderRadius='8px'
w={{
sm: sidebarWidth === 275 ? '100%' : '77%',
xl: sidebarWidth === 275 ? '90%' : '70%',
'2xl': sidebarWidth === 275 ? '95%' : '77%'
}}
px={prop.icon ? null : '0px'}
py={prop.icon ? '12px' : null}
bg={activeRoute(prop.path) && prop.icon ? activeAccordionBg : 'transparent'}
ms={sidebarWidth !== 275 ? !prop.icon ? '12px' : '8px' : null}>
{activeRoute(prop.path) ? (
<Flex
fontWeight='bold'
boxSize='initial'
justifyContent='flex-start'
alignItems='center'
bg='transparent'
transition={variantChange}
mx={{
xl: 'auto'
}}
px='0px'
borderRadius='8px'
w='100%'
_hover={{}}
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
border: 'none'
}}
_focus={{
transform: 'none',
borderColor: 'transparent',
border: 'none'
}}>
{prop.icon ? (
<Flex justify={sidebarWidth === 275 ? 'flex-start' : 'center'}>
<IconBox
bg={activeBg}
color={activeColorIcon}
h='30px'
w='30px'
me={sidebarWidth === 275 ? '12px' : '0px'}
transition={variantChange}>
{prop.icon}
</IconBox>
<Text
color={activeColor}
my='auto'
fontSize='sm'
display={sidebarWidth === 275 ? 'block' : 'none'}>
{prop.name}
</Text>
</Flex>
) : (
<HStack
spacing={sidebarWidth === 275 ? '22px' : '0px'}
ps={sidebarWidth === 275 ? '10px' : '0px'}
ms={sidebarWidth === 275 ? '0px' : '8px'}>
<Icon
as={FaCircle}
w='10px'
color='blue.500'
display={sidebarWidth === 275 ? 'block' : 'none'}
/>
<Text color={activeColor} my='auto' fontSize='sm'>
{sidebarWidth === 275 ? prop.name : prop.name[0]}
</Text>
</HStack>
)}
</Flex>
) : (
<Flex
fontWeight='bold'
boxSize='initial'
justifyContent='flex-start'
alignItems='center'
bg='transparent'
mx={{
xl: 'auto'
}}
px='0px'
borderRadius='8px'
w='100%'
_hover={{}}
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent'
}}
_focus={{
borderColor: 'transparent',
boxShadow: 'none'
}}>
{prop.icon ? (
<Flex justify={sidebarWidth === 275 ? 'flex-start' : 'center'}>
<IconBox
bg={inactiveBg}
color={inactiveColorIcon}
h='30px'
w='30px'
me={sidebarWidth === 275 ? '12px' : '0px'}
transition={variantChange}>
{prop.icon}
</IconBox>
<Text
color={inactiveColor}
my='auto'
fontSize='sm'
display={sidebarWidth === 275 ? 'block' : 'none'}>
{prop.name}
</Text>
</Flex>
) : (
<HStack
spacing={sidebarWidth === 275 ? '26px' : '0px'}
ps={sidebarWidth === 275 ? '10px' : '0px'}
ms={sidebarWidth === 275 ? '0px' : '8px'}>
<Icon
as={FaCircle}
w='6px'
color={landing ? 'white' : 'blue.500'}
display={sidebarWidth === 275 ? 'block' : 'none'}
/>
<Text color={inactiveColor} my='auto' fontSize='md' fontWeight='normal'>
{sidebarWidth === 275 ? prop.name : prop.name[0]}
</Text>
</HStack>
)}
</Flex>
)}
<AccordionIcon
color={landing ? 'white' : 'gray.400'}
display={
prop.icon ? sidebarWidth === 275 ? (
'block'
) : (
'none'
) : sidebarWidth === 275 ? (
'block'
) : (
'none'
)
}
transform={prop.icon ? null : sidebarWidth === 275 ? null : 'translateX(-70%)'}
/>
</AccordionButton>
<AccordionPanel
pe={prop.icon ? null : '0px'}
pb='8px'
ps={prop.icon ? null : sidebarWidth === 275 ? null : '8px'}>
<List>
{prop.icon ? (
createLinks(prop.items) // for bullet accordion links
) : (
createAccordionLinks(prop.items)
) // for non-bullet accordion links
}
</List>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
} else {
return (
<NavLink key={key} to={prop.layout + prop.path}>
{prop.icon ? (
<Box>
<HStack spacing='14px' py='15px' px='15px'>
<IconBox bg='blue.500' color='white' h='30px' w='30px' transition={variantChange}>
{prop.icon}
</IconBox>
<Text
color={activeRoute(prop.path.toLowerCase()) ? activeColor : inactiveColor}
fontWeight={activeRoute(prop.name) ? 'bold' : 'normal'}
fontSize='sm'>
{prop.name}
</Text>
</HStack>
</Box>
) : (
<ListItem key={key} ms={sidebarWidth === 275 ? null : '10px'}>
<HStack
spacing={
sidebarWidth === 275 ? activeRoute(prop.path.toLowerCase()) ? (
'22px'
) : (
'26px'
) : (
'8px'
)
}
py='5px'
px={sidebarWidth === 275 ? '10px' : '0px'}>
<Icon
as={FaCircle}
w={activeRoute(prop.path.toLowerCase()) ? '10px' : '6px'}
color={landing ? 'white' : 'blue.500'}
display={sidebarWidth === 275 ? 'block' : 'none'}
/>
<Text
color={activeRoute(prop.path.toLowerCase()) ? activeColor : inactiveColor}
fontWeight={activeRoute(prop.path.toLowerCase()) ? 'bold' : 'normal'}>
{sidebarWidth === 275 ? prop.name : prop.name[0]}
</Text>
</HStack>
</ListItem>
)}
</NavLink>
);
}
});
};
const createAccordionLinks = (routes) => {
let inactiveColor = useColorModeValue('gray.400', 'gray.400');
let activeColor = useColorModeValue('gray.700', 'white');
if (landing) {
inactiveColor = 'white';
activeColor = 'white';
}
return routes.map((prop, key) => {
return (
<NavLink key={key} to={prop.layout + prop.path}>
<ListItem key={key} pt='5px' ms={sidebarWidth === 275 ? '26px' : '12px'}>
<Text
mb='4px'
color={activeRoute(prop.path.toLowerCase()) ? activeColor : inactiveColor}
fontWeight={activeRoute(prop.path.toLowerCase()) ? 'bold' : 'normal'}
fontSize='sm'>
{sidebarWidth === 275 ? prop.name : prop.name[0]}
</Text>
</ListItem>
</NavLink>
);
});
};
let isWindows = navigator.platform.startsWith('Win');
let links = <Box>{createLinks(routes)}</Box>;
// BRAND
// Chakra Color Mode
let sidebarBg = useColorModeValue('white', 'navy.800');
let sidebarRadius = '20px';
let sidebarMargins = '0px';
var brand = (
<Flex align='center' direction='column' pt={'25px'}>
{props.logo}
<HSeparator my='20px' />
</Flex>
);
let sidebarContent = (
<Box>
<Box>{brand}</Box>
<Stack direction='column' mb='40px'>
<Box>{links}</Box>
</Stack>
<SidebarDocs landing={landing} />
</Box>
);
// SIDEBAR
return (
<Box
onMouseEnter={toggleSidebar ? () => setSidebarWidth(sidebarWidth === 120 ? 275 : 120) : null}
onMouseLeave={toggleSidebar ? () => setSidebarWidth(sidebarWidth === 275 ? 120 : 275) : null}>
<Box display={{ sm: 'none', xl: 'block' }} position='fixed'>
<Box
bg={landing ? 'transparent' : sidebarBg}
transition={variantChange}
w={`${sidebarWidth}px`}
ms={{
sm: '16px'
}}
my={{
sm: '16px'
}}
h='calc(100vh - 32px)'
ps='20px'
pe='20px'
m={sidebarMargins}
borderRadius={sidebarRadius}>
<Scrollbars
autoHide
renderTrackVertical={document.documentElement.dir === 'rtl' ? renderTrackRTL : renderTrack}
renderThumbVertical={useColorModeValue(renderThumbLight, renderThumbDark)}
renderView={document.documentElement.dir === 'rtl' ? renderViewRTL : renderView}>
{sidebarContent}
</Scrollbars>
</Box>
</Box>
</Box>
);
}
// FUNCTIONS
export function SidebarResponsive(props) {
// to check for active links and opened collapses
let location = useLocation();
let variantChange = '0.2s linear';
// verifies if routeName is the one active (in browser input)
const activeRoute = (routeName) => {
return location.pathname.includes(routeName);
};
// Chakra Color Mode
let activeBg = 'blue.500';
let inactiveBg = useColorModeValue('transparent', 'navy.700');
let activeColor = useColorModeValue('gray.700', 'white');
let inactiveColor = useColorModeValue('gray.400', 'gray.400');
let activeAccordionBg = useColorModeValue('white', 'navy.700');
let sidebarActiveShadow = useColorModeValue('0px 7px 11px rgba(0, 0, 0, 0.04)', 'none');
let activeColorIcon = 'white';
let inactiveColorIcon = 'blue.500';
let sidebarBackgroundColor = useColorModeValue('white', 'navy.900');
// this function creates the links and collapses that appear in the sidebar (left menu)
const createLinks = (routes) => {
return routes.map((prop, key) => {
if (prop.category) {
return (
<Box key={key}>
<Text
fontSize={'md'}
color={activeColor}
fontWeight='bold'
mx='auto'
ps={{
sm: '10px',
xl: '16px'
}}
py='12px'
key={key}>
{prop.name}
</Text>
{createLinks(prop.items)}
</Box>
);
}
if (prop.collapse) {
return (
<Accordion key={key} allowToggle>
<AccordionItem border='none'>
<AccordionButton
as='div'
display='flex'
align='center'
justify='center'
key={key}
borderRadius='8px'
px={prop.icon ? null : '0px'}
py={prop.icon ? '12px' : null}
boxShadow={activeRoute(prop.path) && prop.icon ? sidebarActiveShadow : 'none'}
bg={activeRoute(prop.path) && prop.icon ? activeAccordionBg : 'transparent'}>
{activeRoute(prop.path) ? (
<Flex
fontWeight='bold'
boxSize='initial'
justifyContent='flex-start'
alignItems='center'
bg='transparent'
transition={variantChange}
mx={{
xl: 'auto'
}}
px='0px'
borderRadius='8px'
_hover={{}}
w='100%'
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent'
}}>
{prop.icon ? (
<Flex>
<IconBox
bg={activeBg}
color={activeColorIcon}
h='30px'
w='30px'
me='12px'
transition={variantChange}>
{prop.icon}
</IconBox>
<Text color={activeColor} my='auto' fontSize='sm' display={'block'}>
{prop.name}
</Text>
</Flex>
) : (
<HStack spacing={'22px'} ps='10px' ms='0px'>
<Icon as={FaCircle} w='10px' color='blue.500' />
<Text as='span' color={activeColor} my='auto' fontSize='sm'>
{prop.name}
</Text>
</HStack>
)}
</Flex>
) : (
<Text
as='span'
fontWeight='bold'
boxSize='initial'
justifyContent='flex-start'
alignItems='center'
bg='transparent'
mx={{
xl: 'auto'
}}
px='0px'
borderRadius='8px'
_hover={{}}
w='100%'
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent'
}}
_focus={{
boxShadow: 'none'
}}>
{prop.icon ? (
<Flex>
<IconBox
bg={inactiveBg}
color={inactiveColorIcon}
h='30px'
w='30px'
me='12px'
transition={variantChange}>
{prop.icon}
</IconBox>
<Text color={inactiveColor} my='auto' fontSize='sm'>
{prop.name}
</Text>
</Flex>
) : (
<HStack spacing={'26px'} ps={'10px'} ms={'0px'}>
<Icon as={FaCircle} w='6px' color='blue.500' />
<Text color={inactiveColor} my='auto' fontSize='sm' fontWeight='normal'>
{prop.name}
</Text>
</HStack>
)}
</Text>
)}
<AccordionIcon color='gray.400' />
</AccordionButton>
<AccordionPanel pe={prop.icon ? null : '0px'} pb='8px'>
<List>
{prop.icon ? (
createLinks(prop.items) // for bullet accordion links
) : (
createAccordionLinks(prop.items)
) // for non-bullet accordion links
}
</List>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
} else {
return (
<NavLink key={key} to={prop.layout + prop.path}>
{prop.icon ? (
<Box>
<HStack spacing='14px' py='15px' px='15px'>
<IconBox bg='blue.500' color='white' h='30px' w='30px' transition={variantChange}>
{prop.icon}
</IconBox>
<Text
color={activeRoute(prop.path.toLowerCase()) ? activeColor : inactiveColor}
fontWeight={activeRoute(prop.name) ? 'bold' : 'normal'}
fontSize='sm'>
{prop.name}
</Text>
</HStack>
</Box>
) : (
<ListItem>
<HStack spacing='22px' py='5px' px='10px'>
<Icon
as={FaCircle}
w={activeRoute(prop.path.toLowerCase()) ? '10px' : '6px'}
color='blue.500'
/>
<Text
color={activeRoute(prop.path.toLowerCase()) ? activeColor : inactiveColor}
fontSize='sm'
fontWeight={activeRoute(prop.path.toLowerCase()) ? 'bold' : 'normal'}>
{prop.name}
</Text>
</HStack>
</ListItem>
)}
</NavLink>
);
}
});
};
const createAccordionLinks = (routes) => {
return routes.map((prop, key) => {
return (
<NavLink key={key} to={prop.layout + prop.path}>
<ListItem pt='5px' ms='26px' key={key}>
<Text
color={activeRoute(prop.path.toLowerCase()) ? activeColor : inactiveColor}
fontWeight={activeRoute(prop.path.toLowerCase()) ? 'bold' : 'normal'}
fontSize='sm'>
{prop.name}
</Text>
</ListItem>
</NavLink>
);
});
};
const { logo, display, routes } = props;
let links = <Box>{createLinks(routes)}</Box>;
// BRAND
// Chakra Color Mode
let hamburgerColor = 'white';
var brand = (
<Box pt={'25px'} mb='12px'>
{logo}
<HSeparator my='26px' />
</Box>
);
// SIDEBAR
const { isOpen, onOpen, onClose } = useDisclosure();
const btnRef = React.useRef();
// Color variables
return (
<Box display={display}>
<Box display={{ sm: 'flex', xl: 'none' }} ms='8px'>
<HamburgerIcon
color={hamburgerColor}
w='18px'
h='18px'
me='16px'
ref={btnRef}
cursor='pointer'
onClick={onOpen}
/>
<Drawer
placement={document.documentElement.dir === 'rtl' ? 'right' : 'left'}
isOpen={isOpen}
onClose={onClose}
finalFocusRef={btnRef}>
<DrawerOverlay />
<DrawerContent
w='250px'
bg={sidebarBackgroundColor}
maxW='250px'
ms={{
sm: '16px'
}}
my={{
sm: '16px'
}}
borderRadius='16px'>
<DrawerCloseButton _focus={{ boxShadow: 'none' }} _hover={{ boxShadow: 'none' }} />
<DrawerBody maxW='250px' px='1rem'>
<Box maxW='100%' h='100vh'>
<Box mb='20px'>{brand}</Box>
<Stack direction='column' mb='40px'>
<Box>{links}</Box>
</Stack>
<SidebarDocs />
</Box>
</DrawerBody>
</DrawerContent>
</Drawer>
</Box>
</Box>
);
}
// PROPS
export default Sidebar;

View File

@@ -1,45 +0,0 @@
import {
Button,
Flex,
Image,
Link,
Stack,
Text,
useColorModeValue,
} from "@chakra-ui/react";
import SidebarHelpImage from "assets/img/SidebarHelpImage.png";
import { SidebarContext } from "contexts/SidebarContext";
import React, { useContext } from "react";
export default function SidebarDocs({ landing }) {
const textColor = useColorModeValue("gray.700", "white");
const { sidebarWidth } = useContext(SidebarContext);
return (
<Flex
justify='center'
direction='column'
align='center'
display={sidebarWidth !== 275 && "none"}
>
<Image src={SidebarHelpImage} w='165px' ms="24px" />
<Flex direction='column' align="center" textAlign='center' mb="12px" me="24px">
<Text fontSize='14px' color={landing ? "white" : textColor} fontWeight='bold'>
Need help?
</Text>
<Text fontSize='12px' color={landing ? "white" : 'gray.500'}>
Please check our docs.
</Text>
</Flex>
<Link href='#' >
<Button variant={landing ? "light" : 'primary'} mb={{ sm: "12px", xl: "16px" }} color={landing && "blue.500"} fontWeight="bold" minW="185px" ms="24px">
DOCUMENTATION
</Button>
</Link>
</Flex>
);
}

View File

@@ -6,6 +6,7 @@ import * as echarts from 'echarts';
import moment from 'moment';
import { stockService } from '../../services/eventService';
import CitedContent from '../Citation/CitedContent';
import { logger } from '../../utils/logger';
const { Text } = Typography;
@@ -43,14 +44,25 @@ const StockChartAntdModal = ({
}
}
} catch (e) {
console.warn('事件时间解析失败:', e);
logger.warn('StockChartAntdModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
setPreloadedData(prev => ({...prev, [type]: response}));
logger.debug('StockChartAntdModal', '数据预加载成功', {
stockCode: stock.stock_code,
type,
dataLength: response?.data?.length || 0
});
} catch (err) {
console.error(`预加载${type}数据失败:`, err);
logger.error('StockChartAntdModal', 'preloadData', err, {
stockCode: stock?.stock_code,
type
});
}
};
@@ -89,7 +101,10 @@ const StockChartAntdModal = ({
}
}
} catch (e) {
console.warn('事件时间解析失败:', e);
logger.warn('StockChartAntdModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
@@ -97,8 +112,16 @@ const StockChartAntdModal = ({
}
setChartData(data);
logger.debug('StockChartAntdModal', '图表数据加载成功', {
stockCode: stock.stock_code,
chartType: activeChartType,
dataLength: data?.data?.length || 0
});
} catch (error) {
console.error('加载图表数据失败:', error);
logger.error('StockChartAntdModal', 'loadChartData', error, {
stockCode: stock?.stock_code,
chartType: activeChartType
});
} finally {
setLoading(false);
}
@@ -530,7 +553,6 @@ const StockChartAntdModal = ({
<CitedContent
data={stock.relation_desc}
title="关联描述"
showAIBadge={true}
containerStyle={{ marginTop: 16 }}
/>
) : stock?.relation_desc ? (

View File

@@ -5,6 +5,7 @@ import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import moment from 'moment';
import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
const StockChartModal = ({
isOpen,
@@ -36,14 +37,20 @@ const StockChartModal = ({
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
} catch (e) {
console.warn('事件时间解析失败:', e);
logger.warn('StockChartModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
setPreloadedData(prev => ({...prev, [type]: response}));
} catch (err) {
console.error(`预加载${type}数据失败:`, err);
logger.error('StockChartModal', 'preloadData', err, {
stockCode: stock?.stock_code,
type
});
}
};
@@ -91,7 +98,10 @@ const StockChartModal = ({
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
} catch (e) {
console.warn('事件时间解析失败:', e);
logger.warn('StockChartModal', '事件时间解析失败', {
eventTime,
error: e.message
});
}
}
@@ -113,7 +123,10 @@ const StockChartModal = ({
chartInstanceRef.current.setOption(option, true);
}
} catch (err) {
console.error('加载图表数据失败:', err);
logger.error('StockChartModal', 'loadChartData', err, {
stockCode: stock?.stock_code,
chartType: type
});
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,137 @@
// src/components/Subscription/CrownTooltip.js
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Divider,
useColorModeValue
} from '@chakra-ui/react';
import PropTypes from 'prop-types';
/**
* Tooltip 内容组件 - 显示详细的会员信息
* 导出此组件供头像也使用相同的 Tooltip 内容
*/
export const TooltipContent = ({ subscriptionInfo }) => {
const tooltipText = useColorModeValue('gray.700', 'gray.100');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
const { type, days_left, is_active } = subscriptionInfo;
// 基础版用户
if (type === 'free') {
return (
<VStack spacing={2} align="stretch" minW="200px">
<Text fontSize="md" fontWeight="bold" color={tooltipText}>
基础版用户
</Text>
<Divider borderColor={dividerColor} />
<Text fontSize="sm" color={tooltipText} opacity={0.8}>
解锁更多高级功能
</Text>
<Text fontSize="xs" color={tooltipText} opacity={0.6} textAlign="center" mt={1}>
点击头像升级会员
</Text>
</VStack>
);
}
// 付费用户
const isExpired = !is_active;
const isUrgent = days_left < 7;
const isWarning = days_left < 30;
return (
<VStack spacing={2} align="stretch" minW="220px">
<HStack justify="space-between">
<Text fontSize="md" fontWeight="bold" color={tooltipText}>
{type === 'pro' ? '💎 Pro 会员' : '👑 Max 会员'}
</Text>
{isExpired && <Text fontSize="xs" color="red.500">已过期</Text>}
</HStack>
<Divider borderColor={dividerColor} />
{/* 状态信息 */}
{isExpired ? (
<HStack spacing={2}>
<Text fontSize="sm" color="red.500"></Text>
<Text fontSize="sm" color={tooltipText}>
会员已过期续费恢复权益
</Text>
</HStack>
) : (
<VStack spacing={1} align="stretch">
<HStack spacing={2}>
<Text fontSize="sm">
{isUrgent ? '⚠️' : isWarning ? '⏰' : '📅'}
</Text>
<Text fontSize="sm" color={tooltipText}>
{isUrgent && <Text as="span" color="red.500" fontWeight="600">紧急! </Text>}
还有 <Text as="span" fontWeight="600" color={isUrgent ? 'red.500' : isWarning ? 'orange.500' : tooltipText}>{days_left}</Text>
</Text>
</HStack>
<Text fontSize="xs" color={tooltipText} opacity={0.7} pl={6}>
享受全部高级功能
</Text>
</VStack>
)}
{/* 操作提示 */}
<Text fontSize="xs" color={tooltipText} opacity={0.6} textAlign="center" mt={1}>
{isExpired || isUrgent ? '点击头像立即续费' : '点击头像管理订阅'}
</Text>
</VStack>
);
};
/**
* 皇冠图标组件 - 显示在头像左上角的会员标识(纯图标,无 Tooltip
* Tooltip 由外层统一包裹
*
* @param {Object} subscriptionInfo - 订阅信息
* @param {string} subscriptionInfo.type - 订阅类型: 'free' | 'pro' | 'max'
*/
export function CrownIcon({ subscriptionInfo }) {
// 基础版用户不显示皇冠
if (subscriptionInfo.type === 'free') {
return null;
}
return (
<Box
position="absolute"
top="-8px"
left="-8px"
zIndex={2}
display="flex"
alignItems="center"
justifyContent="center"
w="24px"
h="24px"
_hover={{
transform: 'scale(1.2)',
}}
transition="all 0.3s ease"
>
<Text fontSize="20px" filter="drop-shadow(0 2px 4px rgba(0,0,0,0.3))">
{subscriptionInfo.type === 'max' ? '👑' : '💎'}
</Text>
</Box>
);
}
CrownIcon.propTypes = {
subscriptionInfo: PropTypes.shape({
type: PropTypes.oneOf(['free', 'pro', 'max']).isRequired,
}).isRequired,
};
TooltipContent.propTypes = {
subscriptionInfo: PropTypes.shape({
type: PropTypes.oneOf(['free', 'pro', 'max']).isRequired,
days_left: PropTypes.number,
is_active: PropTypes.bool,
}).isRequired,
};

View File

@@ -0,0 +1,124 @@
// src/components/Subscription/SubscriptionBadge.js
import React from 'react';
import { Box, Text, Tooltip, useColorModeValue } from '@chakra-ui/react';
import PropTypes from 'prop-types';
export default function SubscriptionBadge({ subscriptionInfo, onClick }) {
// 根据订阅类型返回样式配置
const getBadgeStyles = () => {
if (subscriptionInfo.type === 'max') {
return {
bg: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
icon: '👑',
label: 'Max',
shadow: '0 4px 12px rgba(118, 75, 162, 0.4)',
hoverShadow: '0 6px 16px rgba(118, 75, 162, 0.5)',
};
}
if (subscriptionInfo.type === 'pro') {
return {
bg: 'linear-gradient(135deg, #667eea 0%, #3182CE 100%)',
color: 'white',
icon: '💎',
label: 'Pro',
shadow: '0 4px 12px rgba(49, 130, 206, 0.4)',
hoverShadow: '0 6px 16px rgba(49, 130, 206, 0.5)',
};
}
// 基础版
return {
bg: 'transparent',
color: useColorModeValue('gray.600', 'gray.400'),
icon: '',
label: '基础版',
border: '1.5px solid',
borderColor: useColorModeValue('gray.300', 'gray.600'),
shadow: 'none',
hoverShadow: 'none',
};
};
const styles = getBadgeStyles();
// 智能动态 Tooltip 文本
const getTooltipText = () => {
const { type, days_left, is_active } = subscriptionInfo;
// 基础版用户
if (type === 'free') {
return '💡 升级到 Pro 或 Max\n解锁高级功能';
}
// 已过期
if (!is_active) {
return `${type === 'pro' ? 'Pro' : 'Max'} 会员已过期\n点击续费恢复权益`;
}
// 紧急状态 (<7 天)
if (days_left < 7) {
return `⚠️ ${type === 'pro' ? 'Pro' : 'Max'} 会员 ${days_left} 天后到期!\n立即续费保持权益`;
}
// 提醒状态 (7-30 天)
if (days_left < 30) {
return `${type === 'pro' ? 'Pro' : 'Max'} 会员即将到期\n还有 ${days_left} 天 · 点击续费`;
}
// 正常状态 (>30 天)
return `${type === 'pro' ? '💎' : '👑'} ${type === 'pro' ? 'Pro' : 'Max'} 会员 · ${days_left} 天后到期\n点击查看详情`;
};
return (
<Tooltip
label={getTooltipText()}
hasArrow
placement="bottom"
fontSize="sm"
px={3}
py={2}
borderRadius="md"
bg={useColorModeValue('gray.700', 'gray.300')}
color={useColorModeValue('white', 'gray.800')}
whiteSpace="pre-line"
textAlign="center"
maxW="250px"
>
<Box
as="button"
onClick={onClick}
px={3}
py={1.5}
borderRadius="full"
bg={styles.bg}
color={styles.color}
border={styles.border}
borderColor={styles.borderColor}
fontWeight="600"
fontSize="sm"
cursor="pointer"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
boxShadow={styles.shadow}
_hover={{
transform: 'translateY(-2px)',
boxShadow: styles.hoverShadow || styles.shadow,
}}
_active={{
transform: 'translateY(0)',
}}
>
{styles.icon && <span style={{ marginRight: '4px' }}>{styles.icon}</span>}
{styles.label}
</Box>
</Tooltip>
);
}
SubscriptionBadge.propTypes = {
subscriptionInfo: PropTypes.shape({
type: PropTypes.oneOf(['free', 'pro', 'max']).isRequired,
days_left: PropTypes.number,
is_active: PropTypes.bool,
}).isRequired,
onClick: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,211 @@
// src/components/Subscription/SubscriptionButton.js
import React from 'react';
import { Box, VStack, HStack, Text, Tooltip, Divider, useColorModeValue } from '@chakra-ui/react';
import PropTypes from 'prop-types';
/**
* 订阅徽章按钮组件 - 用于导航栏头像旁边
* 简洁显示订阅等级,hover 显示详细卡片式 Tooltip
*/
export default function SubscriptionButton({ subscriptionInfo, onClick }) {
const tooltipBg = useColorModeValue('white', 'gray.800');
const tooltipBorder = useColorModeValue('gray.200', 'gray.600');
const tooltipText = useColorModeValue('gray.700', 'gray.100');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
// 根据订阅类型返回样式配置
const getButtonStyles = () => {
if (subscriptionInfo.type === 'max') {
return {
bg: 'transparent',
color: '#3182CE',
icon: '👑',
label: 'Max',
shadow: 'none',
hoverShadow: '0 2px 8px rgba(49, 130, 206, 0.2)',
border: '1.5px solid',
borderColor: '#4299E1',
accentColor: '#3182CE',
};
}
if (subscriptionInfo.type === 'pro') {
return {
bg: 'transparent',
color: '#667eea',
icon: '💎',
label: 'Pro',
shadow: 'none',
hoverShadow: '0 2px 8px rgba(102, 126, 234, 0.2)',
border: '1.5px solid',
borderColor: '#667eea',
accentColor: '#667eea',
};
}
// 基础版
return {
bg: 'transparent',
color: useColorModeValue('gray.600', 'gray.400'),
icon: '✨',
label: '基础版',
shadow: 'none',
hoverShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1.5px solid',
borderColor: useColorModeValue('gray.300', 'gray.600'),
accentColor: useColorModeValue('#718096', '#A0AEC0'),
};
};
const styles = getButtonStyles();
// 增强的卡片式 Tooltip 内容
const TooltipContent = () => {
const { type, days_left, is_active } = subscriptionInfo;
// 基础版用户
if (type === 'free') {
return (
<VStack spacing={2} align="stretch" minW="200px">
<Text fontSize="md" fontWeight="bold" color={tooltipText}>
基础版用户
</Text>
<Divider borderColor={dividerColor} />
<Text fontSize="sm" color={tooltipText} opacity={0.8}>
解锁更多高级功能
</Text>
<Box
mt={1}
px={3}
py={2}
borderRadius="md"
bg="linear-gradient(135deg, #667eea 0%, #3182CE 100%)"
color="white"
textAlign="center"
fontWeight="600"
fontSize="sm"
cursor="pointer"
_hover={{ transform: 'scale(1.02)' }}
transition="transform 0.2s"
>
🚀 立即升级
</Box>
</VStack>
);
}
// 付费用户
const isExpired = !is_active;
const isUrgent = days_left < 7;
const isWarning = days_left < 30;
return (
<VStack spacing={2} align="stretch" minW="200px">
<HStack justify="space-between">
<Text fontSize="md" fontWeight="bold" color={tooltipText}>
{type === 'pro' ? '💎 Pro 会员' : '👑 Max 会员'}
</Text>
{isExpired && <Text fontSize="xs" color="red.500">已过期</Text>}
</HStack>
<Divider borderColor={dividerColor} />
{/* 状态信息 */}
{isExpired ? (
<HStack spacing={2}>
<Text fontSize="sm" color="red.500"></Text>
<Text fontSize="sm" color={tooltipText}>
会员已过期,续费恢复权益
</Text>
</HStack>
) : (
<VStack spacing={1} align="stretch">
<HStack spacing={2}>
<Text fontSize="sm" color={tooltipText}>
{isUrgent ? '⚠️' : isWarning ? '⏰' : '📅'}
</Text>
<Text fontSize="sm" color={tooltipText}>
{isUrgent && <Text as="span" color="red.500" fontWeight="600">紧急!</Text>}
{' '}还有 <Text as="span" fontWeight="600">{days_left}</Text>
</Text>
</HStack>
<Text fontSize="xs" color={tooltipText} opacity={0.7} pl={6}>
享受全部高级功能
</Text>
</VStack>
)}
{/* 行动按钮 */}
<Box
mt={1}
px={3}
py={2}
borderRadius="md"
bg={isExpired || isUrgent ? 'linear-gradient(135deg, #FC8181 0%, #F56565 100%)' : `linear-gradient(135deg, ${styles.accentColor} 0%, ${styles.accentColor}dd 100%)`}
color="white"
textAlign="center"
fontWeight="600"
fontSize="sm"
cursor="pointer"
_hover={{ transform: 'scale(1.02)' }}
transition="transform 0.2s"
>
{isExpired ? '💳 立即续费' : isUrgent ? '⚡ 紧急续费' : '💼 管理订阅'}
</Box>
</VStack>
);
};
return (
<Tooltip
label={<TooltipContent />}
hasArrow
placement="bottom"
bg={tooltipBg}
color={tooltipText}
borderRadius="lg"
border="1px solid"
borderColor={tooltipBorder}
boxShadow="lg"
p={3}
>
<Box
as="button"
onClick={onClick}
px={2}
py={1}
w="70px"
h="32px"
borderRadius="md"
bg={styles.bg}
color={styles.color}
border={styles.border}
borderColor={styles.borderColor}
cursor="pointer"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
boxShadow={styles.shadow}
display="flex"
alignItems="center"
justifyContent="center"
_hover={{
transform: 'translateY(-1px)',
boxShadow: styles.hoverShadow,
}}
_active={{
transform: 'translateY(0)',
}}
>
<Text fontSize="sm" fontWeight="600" lineHeight="1">
<Text as="span" fontSize="md">{styles.icon}</Text> {styles.label}
</Text>
</Box>
</Tooltip>
);
}
SubscriptionButton.propTypes = {
subscriptionInfo: PropTypes.shape({
type: PropTypes.oneOf(['free', 'pro', 'max']).isRequired,
days_left: PropTypes.number,
is_active: PropTypes.bool,
}).isRequired,
onClick: PropTypes.func.isRequired,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
// src/components/Subscription/SubscriptionModal.js
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Icon,
HStack,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { FiStar } from 'react-icons/fi';
import PropTypes from 'prop-types';
import SubscriptionContent from './SubscriptionContent';
export default function SubscriptionModal({ isOpen, onClose }) {
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl" isCentered scrollBehavior="inside">
<ModalOverlay backdropFilter="blur(4px)" />
<ModalContent maxH="90vh">
<ModalHeader borderBottomWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.600')}>
<HStack>
<Icon as={FiStar} color="blue.500" boxSize={5} />
<Text>订阅管理</Text>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody py={6}>
<SubscriptionContent />
</ModalBody>
</ModalContent>
</Modal>
);
}
SubscriptionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -1,568 +0,0 @@
// src/contexts/AuthContext.js - Session版本
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react';
// 创建认证上下文
const AuthContext = createContext();
// 自定义Hook
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// 认证提供者组件
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const navigate = useNavigate();
const toast = useToast();
// 检查Session状态
const checkSession = async () => {
try {
console.log('🔍 检查Session状态...');
const response = await fetch(`/api/auth/session`, {
method: 'GET',
credentials: 'include', // 重要包含cookie
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Session检查失败');
}
const data = await response.json();
console.log('📦 Session数据:', data);
if (data.isAuthenticated && data.user) {
setUser(data.user);
setIsAuthenticated(true);
} else {
setUser(null);
setIsAuthenticated(false);
}
} catch (error) {
console.error('❌ Session检查错误:', error);
setUser(null);
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
};
// 初始化时检查Session
useEffect(() => {
checkSession();
}, []);
// 监听路由变化检查session处理微信登录回调
useEffect(() => {
const handleRouteChange = () => {
// 如果是从微信回调返回的重新检查session
if (window.location.pathname === '/home' && !isAuthenticated) {
checkSession();
}
};
window.addEventListener('popstate', handleRouteChange);
return () => window.removeEventListener('popstate', handleRouteChange);
}, [isAuthenticated]);
// 更新本地用户的便捷方法
const updateUser = (partial) => {
setUser((prev) => ({ ...(prev || {}), ...partial }));
};
// 传统登录方法
const login = async (credential, password, loginType = 'email') => {
try {
setIsLoading(true);
console.log('🔐 开始登录流程:', { credential, loginType });
const formData = new URLSearchParams();
formData.append('password', password);
if (loginType === 'username') {
formData.append('username', credential);
} else if (loginType === 'email') {
formData.append('email', credential);
} else if (loginType === 'phone') {
formData.append('username', credential);
}
console.log('📤 发送登录请求到:', `/api/auth/login`);
console.log('📝 请求数据:', {
credential,
loginType,
formData: formData.toString()
});
const response = await fetch(`/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include', // 包含cookie
body: formData
});
console.log('📨 响应状态:', response.status, response.statusText);
console.log('📨 响应头:', Object.fromEntries(response.headers.entries()));
// 获取响应文本然后尝试解析JSON
const responseText = await response.text();
console.log('📨 响应原始内容:', responseText);
let data;
try {
data = JSON.parse(responseText);
console.log('✅ JSON解析成功:', data);
} catch (parseError) {
console.error('❌ JSON解析失败:', parseError);
console.error('📄 响应内容:', responseText);
throw new Error(`服务器响应格式错误: ${responseText.substring(0, 100)}...`);
}
if (!response.ok || !data.success) {
throw new Error(data.error || '登录失败');
}
// 更新状态
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "登录成功",
description: "欢迎回来!",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
console.error('❌ 登录错误:', error);
toast({
title: "登录失败",
description: error.message || "请检查您的登录信息",
status: "error",
duration: 3000,
isClosable: true,
});
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// 注册方法
const register = async (username, email, password) => {
try {
setIsLoading(true);
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('email', email);
formData.append('password', password);
const response = await fetch(`/api/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include',
body: formData
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '注册失败');
}
// 注册成功后自动登录
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
console.error('注册错误:', error);
toast({
title: "注册失败",
description: error.message || "注册失败,请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// 手机号注册
const registerWithPhone = async (phone, code, username, password) => {
try {
setIsLoading(true);
const response = await fetch(`/api/auth/register/phone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
phone,
code,
username,
password
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '注册失败');
}
// 注册成功后自动登录
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
console.error('手机注册错误:', error);
toast({
title: "注册失败",
description: error.message || "注册失败,请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// 邮箱注册
const registerWithEmail = async (email, code, username, password) => {
try {
setIsLoading(true);
const response = await fetch(`/api/auth/register/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
email,
code,
username,
password
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || '注册失败');
}
// 注册成功后自动登录
setUser(data.user);
setIsAuthenticated(true);
toast({
title: "注册成功",
description: "欢迎加入价值前沿!",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
console.error('邮箱注册错误:', error);
toast({
title: "注册失败",
description: error.message || "注册失败,请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// 发送手机验证码
const sendSmsCode = async (phone) => {
try {
const response = await fetch(`/api/auth/send-sms-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 必须包含以支持跨域 session cookie
body: JSON.stringify({ phone })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '发送失败');
}
toast({
title: "验证码已发送",
description: "请查收短信",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
console.error('SMS code error:', error);
toast({
title: "发送失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
return { success: false, error: error.message };
}
};
// 发送邮箱验证码
const sendEmailCode = async (email) => {
try {
const response = await fetch(`/api/auth/send-email-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 必须包含以支持跨域 session cookie
body: JSON.stringify({ email })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '发送失败');
}
toast({
title: "验证码已发送",
description: "请查收邮件",
status: "success",
duration: 3000,
isClosable: true,
});
return { success: true };
} catch (error) {
console.error('Email code error:', error);
toast({
title: "发送失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
return { success: false, error: error.message };
}
};
// 登出方法
const logout = async () => {
try {
// 调用后端登出API
await fetch(`/api/auth/logout`, {
method: 'POST',
credentials: 'include'
});
// 清除本地状态
setUser(null);
setIsAuthenticated(false);
toast({
title: "已登出",
description: "您已成功退出登录",
status: "info",
duration: 2000,
isClosable: true,
});
// 跳转到登录页面
navigate('/auth/signin');
} catch (error) {
console.error('Logout error:', error);
// 即使API调用失败也清除本地状态
setUser(null);
setIsAuthenticated(false);
navigate('/auth/signin');
}
};
// 检查用户是否有特定权限
const hasRole = (role) => {
return user && user.role === role;
};
// 检查用户订阅权限
const hasSubscriptionPermission = async (featureName) => {
try {
// 如果用户未登录,返回免费权限
if (!isAuthenticated) {
const freePermissions = {
'related_stocks': false,
'related_concepts': false,
'transmission_chain': false,
'historical_events': 'limited'
};
return freePermissions[featureName] || false;
}
// 获取用户权限信息
const response = await fetch(`/api/subscription/permissions`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('获取权限信息失败');
}
const data = await response.json();
if (data.success && data.data) {
return data.data.permissions[featureName] || false;
}
// 如果API调用失败返回默认权限
const defaultPermissions = {
'related_stocks': user?.subscription_type === 'pro' || user?.subscription_type === 'max',
'related_concepts': user?.subscription_type === 'pro' || user?.subscription_type === 'max',
'transmission_chain': user?.subscription_type === 'max',
'historical_events': user?.subscription_type === 'pro' || user?.subscription_type === 'max' ? 'full' : 'limited'
};
return defaultPermissions[featureName] || false;
} catch (error) {
console.error('检查订阅权限失败:', error);
// 降级处理:基于用户现有的订阅信息
const fallbackPermissions = {
'related_stocks': user?.subscription_type === 'pro' || user?.subscription_type === 'max',
'related_concepts': user?.subscription_type === 'pro' || user?.subscription_type === 'max',
'transmission_chain': user?.subscription_type === 'max',
'historical_events': user?.subscription_type === 'pro' || user?.subscription_type === 'max' ? 'full' : 'limited'
};
return fallbackPermissions[featureName] || false;
}
};
// 获取用户订阅级别
const getSubscriptionLevel = () => {
if (!isAuthenticated) return 'free';
return user?.subscription_type || 'free';
};
// 检查是否需要升级订阅
const requiresUpgrade = (requiredLevel) => {
const currentLevel = getSubscriptionLevel();
const levels = { 'free': 0, 'pro': 1, 'max': 2 };
const currentLevelValue = levels[currentLevel] || 0;
const requiredLevelValue = levels[requiredLevel] || 0;
return currentLevelValue < requiredLevelValue;
};
// 刷新session可选
const refreshSession = async () => {
await checkSession();
};
// 提供给子组件的值
const value = {
user,
isAuthenticated,
isLoading,
updateUser,
login,
register,
registerWithPhone,
registerWithEmail,
sendSmsCode,
sendEmailCode,
logout,
hasRole,
hasSubscriptionPermission,
getSubscriptionLevel,
requiresUpgrade,
refreshSession,
checkSession
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,77 @@
// src/constants/importanceLevels.js
// 事件重要性等级配置
import {
WarningIcon,
WarningTwoIcon,
InfoIcon,
CheckCircleIcon,
} from '@chakra-ui/icons';
/**
* 重要性等级配置
* 用于事件列表展示和重要性说明
*/
export const IMPORTANCE_LEVELS = {
'S': {
level: 'S',
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
description: '重大事件,市场影响深远',
antdColor: '#722ed1', // 对应 Ant Design 的紫色
},
'A': {
level: 'A',
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
description: '重要事件,影响较大',
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
},
'B': {
level: 'B',
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
description: '普通事件,有一定影响',
antdColor: '#faad14', // 对应 Ant Design 的橙色
},
'C': {
level: 'C',
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
description: '参考事件,影响有限',
antdColor: '#52c41a', // 对应 Ant Design 的绿色
}
};
/**
* 获取重要性等级配置
* @param {string} importance - 重要性等级 (S/A/B/C)
* @returns {Object} 重要性配置对象
*/
export const getImportanceConfig = (importance) => {
return IMPORTANCE_LEVELS[importance] || IMPORTANCE_LEVELS['C'];
};
/**
* 获取所有等级配置(用于说明列表)
* @returns {Array} 所有等级配置数组
*/
export const getAllImportanceLevels = () => {
return Object.values(IMPORTANCE_LEVELS);
};

View File

@@ -0,0 +1,254 @@
// src/constants/notificationTypes.js
/**
* 金融资讯通知系统 - 类型定义和常量
*/
import { MdCampaign, MdTrendingUp, MdTrendingDown, MdArticle, MdAssessment } from 'react-icons/md';
// 通知类型
export const NOTIFICATION_TYPES = {
ANNOUNCEMENT: 'announcement', // 公告通知
STOCK_ALERT: 'stock_alert', // 股票动向
EVENT_ALERT: 'event_alert', // 事件动向
ANALYSIS_REPORT: 'analysis_report', // 分析报告
};
// 优先级
export const PRIORITY_LEVELS = {
URGENT: 'urgent', // 紧急
IMPORTANT: 'important', // 重要
NORMAL: 'normal', // 普通
};
// 通知状态(用于预测通知)
export const NOTIFICATION_STATUS = {
PREDICTION: 'prediction', // 预测状态(详情未就绪)
READY: 'ready', // 详情已就绪
};
// 通知系统配置
export const NOTIFICATION_CONFIG = {
// 显示策略
maxVisible: 3, // 最多显示3条通知
maxHistory: 15, // 历史保留15条折叠区
// 自动关闭时长(毫秒)- 按优先级区分
autoCloseDuration: {
[PRIORITY_LEVELS.URGENT]: 0, // 紧急:不自动关闭
[PRIORITY_LEVELS.IMPORTANT]: 30000, // 重要30秒
[PRIORITY_LEVELS.NORMAL]: 15000, // 普通15秒
},
// 推送频率配置(测试模式)
mockPush: {
interval: 60000, // 60秒推送一次
minBatch: 1, // 最少1条
maxBatch: 2, // 最多2条
},
// 折叠配置
collapse: {
threshold: 3, // 超过3条开始折叠
textTemplate: '还有 {count} 条通知', // 折叠提示文案
},
};
// 优先级标签配置
export const PRIORITY_CONFIGS = {
[PRIORITY_LEVELS.URGENT]: {
label: '紧急',
colorScheme: 'red',
show: false, // 不再显示标签,改用边框+背景色表示
borderWidth: '6px', // 紧急:粗边框
bgOpacity: 0.25, // 紧急:深色背景
darkBgOpacity: 0.30, // 暗色模式下更明显
},
[PRIORITY_LEVELS.IMPORTANT]: {
label: '重要',
colorScheme: 'orange',
show: false, // 不再显示标签,改用边框+背景色表示
borderWidth: '4px', // 重要:中等边框
bgOpacity: 0.15, // 重要:中色背景
darkBgOpacity: 0.20, // 暗色模式
},
[PRIORITY_LEVELS.NORMAL]: {
label: '',
colorScheme: 'gray',
show: false, // 普通优先级不显示标签
borderWidth: '2px', // 普通:细边框
bgOpacity: 0.08, // 普通:浅色背景
darkBgOpacity: 0.12, // 暗色模式
},
};
/**
* 根据优先级获取背景色透明度
* @param {string} priority - 优先级
* @param {boolean} isDark - 是否暗色模式
* @returns {number} - 透明度值 (0-1)
*/
export const getPriorityBgOpacity = (priority, isDark = false) => {
const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
return isDark ? config.darkBgOpacity : config.bgOpacity;
};
/**
* 根据优先级获取边框宽度
* @param {string} priority - 优先级
* @returns {string} - 边框宽度
*/
export const getPriorityBorderWidth = (priority) => {
const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
return config.borderWidth;
};
// 通知类型样式配置
export const NOTIFICATION_TYPE_CONFIGS = {
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
name: '公告通知',
icon: MdCampaign,
colorScheme: 'blue',
// 亮色模式
bg: 'blue.50',
borderColor: 'blue.400',
iconColor: 'blue.500',
hoverBg: 'blue.100',
// 暗色模式
darkBg: 'rgba(59, 130, 246, 0.15)', // blue.500 + 15% 透明度
darkBorderColor: 'blue.400',
darkIconColor: 'blue.300',
darkHoverBg: 'rgba(59, 130, 246, 0.25)', // Hover 时 25% 透明度
},
[NOTIFICATION_TYPES.STOCK_ALERT]: {
name: '股票动向',
// 图标根据涨跌动态设置
getIcon: (priceChange) => {
if (!priceChange) return MdTrendingUp;
return priceChange.startsWith('+') ? MdTrendingUp : MdTrendingDown;
},
// 颜色根据涨跌动态设置
getColorScheme: (priceChange) => {
if (!priceChange) return 'red';
return priceChange.startsWith('+') ? 'red' : 'green';
},
// 亮色模式
getBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.50`;
},
getBorderColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.400`;
},
getIconColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.500`;
},
getHoverBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.100`;
},
// 暗色模式
getDarkBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
// red (上涨): rgba(239, 68, 68, 0.15), green (下跌): rgba(34, 197, 94, 0.15)
return scheme === 'red'
? 'rgba(239, 68, 68, 0.15)'
: 'rgba(34, 197, 94, 0.15)';
},
getDarkBorderColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.400`;
},
getDarkIconColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.300`;
},
getDarkHoverBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return scheme === 'red'
? 'rgba(239, 68, 68, 0.25)'
: 'rgba(34, 197, 94, 0.25)';
},
},
[NOTIFICATION_TYPES.EVENT_ALERT]: {
name: '事件动向',
icon: MdArticle,
colorScheme: 'orange',
// 亮色模式
bg: 'orange.50',
borderColor: 'orange.400',
iconColor: 'orange.500',
hoverBg: 'orange.100',
// 暗色模式
darkBg: 'rgba(249, 115, 22, 0.15)', // orange.500 + 15% 透明度
darkBorderColor: 'orange.400',
darkIconColor: 'orange.300',
darkHoverBg: 'rgba(249, 115, 22, 0.25)',
},
[NOTIFICATION_TYPES.ANALYSIS_REPORT]: {
name: '分析报告',
icon: MdAssessment,
colorScheme: 'purple',
// 亮色模式
bg: 'purple.50',
borderColor: 'purple.400',
iconColor: 'purple.500',
hoverBg: 'purple.100',
// 暗色模式
darkBg: 'rgba(168, 85, 247, 0.15)', // purple.500 + 15% 透明度
darkBorderColor: 'purple.400',
darkIconColor: 'purple.300',
darkHoverBg: 'rgba(168, 85, 247, 0.25)',
},
};
// 时间格式化辅助函数
export const formatNotificationTime = (timestamp) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
// 小于1分钟
if (diff < 60000) {
return '刚刚';
}
// 小于1小时
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`;
}
// 小于24小时
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`;
}
// 今天
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateDay.getTime() === today.getTime()) {
return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
// 昨天
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (dateDay.getTime() === yesterday.getTime()) {
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
// 其他
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
};
export default {
NOTIFICATION_TYPES,
PRIORITY_LEVELS,
NOTIFICATION_STATUS,
PRIORITY_CONFIGS,
NOTIFICATION_TYPE_CONFIGS,
formatNotificationTime,
getPriorityBgOpacity,
getPriorityBorderWidth,
};

View File

@@ -2,6 +2,8 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext';
// 创建认证上下文
const AuthContext = createContext();
@@ -22,11 +24,15 @@ export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const navigate = useNavigate();
const toast = useToast();
const { showWelcomeGuide } = useNotification();
// ⚡ 使用 ref 保存最新的 isAuthenticated 值,避免事件监听器重复注册
const isAuthenticatedRef = React.useRef(isAuthenticated);
// 检查Session状态
const checkSession = async () => {
try {
console.log('🔍 检查Session状态...');
logger.debug('AuthContext', '检查Session状态');
// 创建超时控制器
const controller = new AbortController();
@@ -34,11 +40,11 @@ export const AuthProvider = ({ children }) => {
const response = await fetch(`/api/auth/session`, {
method: 'GET',
credentials: 'include', // 重要包含cookie
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal // 添加超时信号
signal: controller.signal
});
clearTimeout(timeoutId);
@@ -48,23 +54,33 @@ export const AuthProvider = ({ children }) => {
}
const data = await response.json();
console.log('📦 Session数据:', data);
logger.debug('AuthContext', 'Session数据', {
isAuthenticated: data.isAuthenticated,
userId: data.user?.id
});
if (data.isAuthenticated && data.user) {
setUser(data.user);
setIsAuthenticated(true);
// ⚡ 只在 user 数据真正变化时才更新状态,避免无限循环
setUser((prevUser) => {
// 比较用户 ID如果相同则不更新
if (prevUser && prevUser.id === data.user.id) {
return prevUser;
}
return data.user;
});
setIsAuthenticated((prev) => prev === true ? prev : true);
} else {
setUser(null);
setIsAuthenticated(false);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
}
} catch (error) {
console.error('Session检查错误:', error);
logger.error('AuthContext', 'checkSession', error);
// 网络错误或超时,设置为未登录状态
setUser(null);
setIsAuthenticated(false);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
} finally {
// ⚡ Session 检查完成后,停止加载状态
setIsLoading(false);
// ⚡ 只在 isLoading 为 true 时才设置为 false避免不必要的状态更新
setIsLoading((prev) => prev === false ? prev : false);
}
};
@@ -74,11 +90,17 @@ export const AuthProvider = ({ children }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ⚡ 同步 isAuthenticated 到 ref
useEffect(() => {
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated]);
// 监听路由变化检查session处理微信登录回调
// ⚡ 移除 isAuthenticated 依赖,使用 ref 避免重复注册事件监听器
useEffect(() => {
const handleRouteChange = () => {
// 如果是从微信回调返回的重新检查session
if (window.location.pathname === '/home' && !isAuthenticated) {
// 使用 ref 获取最新的认证状态
if (window.location.pathname === '/home' && !isAuthenticatedRef.current) {
checkSession();
}
};
@@ -86,7 +108,7 @@ export const AuthProvider = ({ children }) => {
window.addEventListener('popstate', handleRouteChange);
return () => window.removeEventListener('popstate', handleRouteChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
}, []); // ✅ 空依赖数组,只注册一次事件监听器
// 更新本地用户的便捷方法
const updateUser = (partial) => {
@@ -97,7 +119,7 @@ export const AuthProvider = ({ children }) => {
const login = async (credential, password, loginType = 'email') => {
try {
setIsLoading(true);
console.log('🔐 开始登录流程:', { credential, loginType });
logger.debug('AuthContext', '开始登录流程', { credential: credential.substring(0, 3) + '***', loginType });
const formData = new URLSearchParams();
formData.append('password', password);
@@ -110,11 +132,9 @@ export const AuthProvider = ({ children }) => {
formData.append('username', credential);
}
console.log('📤 发送登录请求到:', `/api/auth/login`);
console.log('📝 请求数据:', {
credential,
loginType,
formData: formData.toString()
logger.api.request('POST', '/api/auth/login', {
credential: credential.substring(0, 3) + '***',
loginType
});
const response = await fetch(`/api/auth/login`, {
@@ -122,24 +142,19 @@ export const AuthProvider = ({ children }) => {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include', // 包含cookie
credentials: 'include',
body: formData
});
console.log('📨 响应状态:', response.status, response.statusText);
console.log('📨 响应头:', Object.fromEntries(response.headers.entries()));
// 获取响应文本然后尝试解析JSON
const responseText = await response.text();
console.log('📨 响应原始内容:', responseText);
let data;
try {
data = JSON.parse(responseText);
console.log('✅ JSON解析成功:', data);
logger.api.response('POST', '/api/auth/login', response.status, data);
} catch (parseError) {
console.error('❌ JSON解析失败:', parseError);
console.error('📄 响应内容:', responseText);
logger.error('AuthContext', 'login', parseError, { responseText: responseText.substring(0, 100) });
throw new Error(`服务器响应格式错误: ${responseText.substring(0, 100)}...`);
}
@@ -160,20 +175,15 @@ export const AuthProvider = ({ children }) => {
// isClosable: true,
// });
// ⚡ 登录成功后显示欢迎引导延迟2秒避免与登录Toast冲突
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
console.error('❌ 登录错误:', error);
// ⚡ 移除toast让调用者处理错误显示避免重复toast和并发更新
// toast({
// title: "登录失败",
// description: error.message || "请检查您的登录信息",
// status: "error",
// duration: 3000,
// isClosable: true,
// });
logger.error('AuthContext', 'login', error, { loginType });
return { success: false, error: error.message };
} finally {
setIsLoading(false);
@@ -217,21 +227,19 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
console.error('注册错误:', error);
toast({
title: "注册失败",
description: error.message || "注册失败,请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
logger.error('AuthContext', 'register', error);
// ❌ 移除错误 toast静默失败
return { success: false, error: error.message };
} finally {
} finally{
setIsLoading(false);
}
};
@@ -273,19 +281,15 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
console.error('手机注册错误:', error);
toast({
title: "注册失败",
description: error.message || "注册失败,请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
logger.error('AuthContext', 'registerWithPhone', error, { phone: phone.substring(0, 3) + '****' });
return { success: false, error: error.message };
} finally {
setIsLoading(false);
@@ -329,19 +333,15 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
console.error('邮箱注册错误:', error);
toast({
title: "注册失败",
description: error.message || "注册失败,请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
logger.error('AuthContext', 'registerWithEmail', error);
return { success: false, error: error.message };
} finally {
setIsLoading(false);
@@ -356,7 +356,7 @@ export const AuthProvider = ({ children }) => {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 必须包含以支持跨域 session cookie
credentials: 'include',
body: JSON.stringify({ phone })
});
@@ -366,27 +366,13 @@ export const AuthProvider = ({ children }) => {
throw new Error(data.error || '发送失败');
}
toast({
title: "验证码已发送",
description: "请查收短信",
status: "success",
duration: 3000,
isClosable: true,
});
// ❌ 移除成功 toast
logger.info('AuthContext', '验证码已发送', { phone: phone.substring(0, 3) + '****' });
return { success: true };
} catch (error) {
console.error('SMS code error:', error);
toast({
title: "发送失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
// ❌ 移除错误 toast
logger.error('AuthContext', 'sendSmsCode', error, { phone: phone.substring(0, 3) + '****' });
return { success: false, error: error.message };
}
};
@@ -399,7 +385,7 @@ export const AuthProvider = ({ children }) => {
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 必须包含以支持跨域 session cookie
credentials: 'include',
body: JSON.stringify({ email })
});
@@ -409,27 +395,13 @@ export const AuthProvider = ({ children }) => {
throw new Error(data.error || '发送失败');
}
toast({
title: "验证码已发送",
description: "请查收邮件",
status: "success",
duration: 3000,
isClosable: true,
});
// ❌ 移除成功 toast
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
return { success: true };
} catch (error) {
console.error('Email code error:', error);
toast({
title: "发送失败",
description: error.message || "请稍后重试",
status: "error",
duration: 3000,
isClosable: true,
});
// ❌ 移除错误 toast
logger.error('AuthContext', 'sendEmailCode', error);
return { success: false, error: error.message };
}
};
@@ -447,6 +419,7 @@ export const AuthProvider = ({ children }) => {
setUser(null);
setIsAuthenticated(false);
// ✅ 保留登出成功 toast关键操作提示
toast({
title: "已登出",
description: "您已成功退出登录",
@@ -455,14 +428,11 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// 不再跳转,用户留在当前页面
} catch (error) {
console.error('Logout error:', error);
logger.error('AuthContext', 'logout', error);
// 即使API调用失败也清除本地状态
setUser(null);
setIsAuthenticated(false);
// 不再跳转,用户留在当前页面
}
};

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
import { logger } from '../utils/logger';
const AuthModalContext = createContext();
@@ -69,7 +70,10 @@ export const AuthModalProvider = ({ children }) => {
try {
onSuccessCallback(user);
} catch (error) {
console.error('Success callback error:', error);
logger.error('AuthModalContext', 'handleLoginSuccess', error, {
userId: user?.id,
hasCallback: !!onSuccessCallback
});
}
}

View File

@@ -0,0 +1,176 @@
// src/contexts/IndustryContext.js
// 行业分类数据全局上下文 - 使用API获取 + 缓存机制
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { industryData as staticIndustryData } from '../data/industryData';
import { industryService } from '../services/industryService';
import { logger } from '../utils/logger';
const IndustryContext = createContext();
// 缓存配置
const CACHE_KEY = 'industry_classifications_cache';
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 1天24小时
/**
* useIndustry Hook
* 在任何组件中使用行业数据
*/
export const useIndustry = () => {
const context = useContext(IndustryContext);
if (!context) {
throw new Error('useIndustry must be used within IndustryProvider');
}
return context;
};
/**
* 从 localStorage 读取缓存
*/
const loadFromCache = () => {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期1天
if (now - timestamp > CACHE_DURATION) {
localStorage.removeItem(CACHE_KEY);
logger.debug('IndustryContext', '缓存已过期,清除缓存');
return null;
}
logger.debug('IndustryContext', '从缓存加载行业数据', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + ' 分钟前'
});
return data;
} catch (error) {
logger.error('IndustryContext', 'loadFromCache', error);
return null;
}
};
/**
* 保存到 localStorage
*/
const saveToCache = (data) => {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('IndustryContext', '行业数据已缓存', {
count: data?.length || 0
});
} catch (error) {
logger.error('IndustryContext', 'saveToCache', error);
}
};
/**
* IndustryProvider 组件
* 提供全局行业数据管理 - 使用API获取 + 缓存机制
*/
export const IndustryProvider = ({ children }) => {
const [industryData, setIndustryData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const hasLoadedRef = useRef(false);
const isLoadingRef = useRef(false);
/**
* 加载行业数据
*/
const loadIndustryData = async () => {
// 防止重复加载(处理 StrictMode 双重调用)
if (hasLoadedRef.current || isLoadingRef.current) {
logger.debug('IndustryContext', '跳过重复加载', {
hasLoaded: hasLoadedRef.current,
isLoading: isLoadingRef.current
});
return industryData;
}
try {
isLoadingRef.current = true;
setLoading(true);
setError(null);
logger.debug('IndustryContext', '开始加载行业数据');
// 1. 先尝试从缓存加载
const cachedData = loadFromCache();
if (cachedData && cachedData.length > 0) {
setIndustryData(cachedData);
hasLoadedRef.current = true;
return cachedData;
}
// 2. 缓存不存在或过期,调用 API
logger.debug('IndustryContext', '缓存无效调用API获取数据');
const response = await industryService.getClassifications();
if (response.success && response.data && response.data.length > 0) {
setIndustryData(response.data);
saveToCache(response.data);
hasLoadedRef.current = true;
logger.debug('IndustryContext', 'API数据加载成功', {
count: response.data.length
});
return response.data;
} else {
throw new Error('API返回数据为空');
}
} catch (err) {
// 3. API 失败,回退到静态数据
logger.warn('IndustryContext', 'API加载失败使用静态数据', {
error: err.message
});
setError(err.message);
setIndustryData(staticIndustryData);
hasLoadedRef.current = true;
return staticIndustryData;
} finally {
setLoading(false);
isLoadingRef.current = false;
}
};
/**
* 刷新行业数据(清除缓存并重新加载)
*/
const refreshIndustryData = async () => {
logger.debug('IndustryContext', '刷新行业数据,清除缓存');
localStorage.removeItem(CACHE_KEY);
hasLoadedRef.current = false;
isLoadingRef.current = false;
return loadIndustryData();
};
// 组件挂载时自动加载数据
useEffect(() => {
loadIndustryData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const value = {
industryData,
loading,
error,
loadIndustryData,
refreshIndustryData
};
return (
<IndustryContext.Provider value={value}>
{children}
</IndustryContext.Provider>
);
};

View File

@@ -0,0 +1,819 @@
// src/contexts/NotificationContext.js
/**
* 通知上下文 - 管理实时消息推送和通知显示
*
* 环境说明:
* - SOCKET_TYPE === 'REAL': 使用真实 Socket.IO 连接(生产环境),连接到 wss://valuefrontier.cn
* - SOCKET_TYPE === 'MOCK': 使用模拟 Socket 服务(开发环境),用于本地测试
*
* 环境切换:
* - 设置 REACT_APP_ENABLE_MOCK=true 或 REACT_APP_USE_MOCK_SOCKET=true 使用 MOCK 模式
* - 否则使用 REAL 模式连接生产环境
*/
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
import { BellIcon } from '@chakra-ui/icons';
import { logger } from '../utils/logger';
import socket, { SOCKET_TYPE } from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
import { browserNotificationService } from '../services/browserNotificationService';
import { notificationMetricsService } from '../services/notificationMetricsService';
import { notificationHistoryService } from '../services/notificationHistoryService';
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
// 连接状态枚举
const CONNECTION_STATUS = {
CONNECTED: 'connected',
DISCONNECTED: 'disconnected',
RECONNECTING: 'reconnecting',
FAILED: 'failed',
RECONNECTED: 'reconnected', // 重连成功显示2秒后自动变回 CONNECTED
};
// 创建通知上下文
const NotificationContext = createContext();
// 自定义Hook
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context;
};
// 通知提供者组件
export const NotificationProvider = ({ children }) => {
const toast = useToast();
const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(true);
const [browserPermission, setBrowserPermission] = useState(browserNotificationService.getPermissionStatus());
const [hasRequestedPermission, setHasRequestedPermission] = useState(() => {
// 从 localStorage 读取是否已请求过权限
return localStorage.getItem('browser_notification_requested') === 'true';
});
const [connectionStatus, setConnectionStatus] = useState(CONNECTION_STATUS.CONNECTED);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(Infinity);
const audioRef = useRef(null);
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
const processedEventIds = useRef(new Set()); // 用于Socket层去重记录已处理的事件ID
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID避免内存泄漏
// ⚡ 使用权限引导管理 Hook
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
// 初始化音频
useEffect(() => {
try {
audioRef.current = new Audio(notificationSound);
audioRef.current.volume = 0.5;
} catch (error) {
logger.error('NotificationContext', 'Audio initialization failed', error);
}
}, []);
/**
* 播放通知音效
*/
const playNotificationSound = useCallback(() => {
if (!soundEnabled || !audioRef.current) {
return;
}
try {
// 重置音频到开始位置
audioRef.current.currentTime = 0;
// 播放音频
audioRef.current.play().catch(error => {
logger.warn('NotificationContext', 'Failed to play notification sound', error);
});
} catch (error) {
logger.error('NotificationContext', 'playNotificationSound', error);
}
}, [soundEnabled]);
/**
* 移除通知
* @param {string} id - 通知ID
* @param {boolean} wasClicked - 是否是因为点击而关闭
*/
const removeNotification = useCallback((id, wasClicked = false) => {
logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
// 监控埋点:追踪关闭(非点击的情况)
setNotifications(prev => {
const notification = prev.find(n => n.id === id);
if (notification && !wasClicked) {
notificationMetricsService.trackDismissed(notification);
}
return prev.filter(notif => notif.id !== id);
});
}, []);
/**
* 清空所有通知
*/
const clearAllNotifications = useCallback(() => {
logger.info('NotificationContext', 'Clearing all notifications');
setNotifications([]);
}, []);
/**
* 切换音效开关
*/
const toggleSound = useCallback(() => {
setSoundEnabled(prev => {
const newValue = !prev;
logger.info('NotificationContext', 'Sound toggled', { enabled: newValue });
return newValue;
});
}, []);
/**
* 请求浏览器通知权限
*/
const requestBrowserPermission = useCallback(async () => {
logger.info('NotificationContext', 'Requesting browser notification permission');
const permission = await browserNotificationService.requestPermission();
setBrowserPermission(permission);
// 记录已请求过权限
setHasRequestedPermission(true);
localStorage.setItem('browser_notification_requested', 'true');
// 根据权限结果显示 Toast 提示
if (permission === 'granted') {
toast({
title: '桌面通知已开启',
description: '您现在可以在后台接收重要通知',
status: 'success',
duration: 3000,
isClosable: true,
});
} else if (permission === 'denied') {
toast({
title: '桌面通知已关闭',
description: '您将继续在网页内收到通知',
status: 'info',
duration: 5000,
isClosable: true,
});
}
return permission;
}, [toast]);
/**
* ⚡ 显示权限引导(通用方法)
* @param {string} guideType - 引导类型
* @param {object} options - 引导选项
*/
const showPermissionGuide = useCallback((guideType, options = {}) => {
// 检查是否应该显示引导
if (!shouldShowGuide(guideType)) {
logger.debug('NotificationContext', 'Guide already shown, skipping', { guideType });
return;
}
// 检查权限状态:只在未授权时显示引导
if (browserPermission === 'granted') {
logger.debug('NotificationContext', 'Permission already granted, skipping guide', { guideType });
return;
}
// 默认选项
const {
title = '开启桌面通知',
description = '及时接收重要事件和股票提醒',
icon = true,
duration = 10000,
} = options;
// 显示引导 Toast
const toastId = `permission-guide-${guideType}`;
if (!toast.isActive(toastId)) {
toast({
id: toastId,
duration,
render: ({ onClose }) => (
<Box
p={4}
bg="blue.500"
color="white"
borderRadius="md"
boxShadow="lg"
maxW="400px"
>
<VStack spacing={3} align="stretch">
{icon && (
<HStack spacing={2}>
<Icon as={BellIcon} boxSize={5} />
<Text fontWeight="bold" fontSize="md">
{title}
</Text>
</HStack>
)}
<Text fontSize="sm" opacity={0.9}>
{description}
</Text>
<HStack spacing={2} justify="flex-end">
<Button
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={() => {
onClose();
markGuideAsShown(guideType);
}}
>
稍后再说
</Button>
<Button
size="sm"
colorScheme="whiteAlpha"
bg="whiteAlpha.300"
_hover={{ bg: 'whiteAlpha.400' }}
onClick={async () => {
onClose();
markGuideAsShown(guideType);
await requestBrowserPermission();
}}
>
立即开启
</Button>
</HStack>
</VStack>
</Box>
),
});
logger.info('NotificationContext', 'Permission guide shown', { guideType });
}
}, [toast, shouldShowGuide, markGuideAsShown, browserPermission, requestBrowserPermission]);
/**
* ⚡ 显示欢迎引导(登录后)
*/
const showWelcomeGuide = useCallback(() => {
showPermissionGuide(GUIDE_TYPES.WELCOME, {
title: '🎉 欢迎使用价值前沿',
description: '开启桌面通知,第一时间接收重要投资事件和股票提醒',
duration: 12000,
});
}, [showPermissionGuide]);
/**
* ⚡ 显示社区功能引导
*/
const showCommunityGuide = useCallback(() => {
showPermissionGuide(GUIDE_TYPES.COMMUNITY, {
title: '关注感兴趣的事件',
description: '开启通知后,您关注的事件有新动态时会第一时间提醒您',
duration: 10000,
});
}, [showPermissionGuide]);
/**
* ⚡ 显示首次关注引导
*/
const showFirstFollowGuide = useCallback(() => {
showPermissionGuide(GUIDE_TYPES.FIRST_FOLLOW, {
title: '关注成功',
description: '开启桌面通知,事件有更新时我们会及时提醒您',
duration: 8000,
});
}, [showPermissionGuide]);
/**
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
return;
}
const { priority, title, content, link, type } = notificationData;
// 生成唯一 tag
const tag = `${type}_${Date.now()}`;
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
// 发送浏览器通知
const notification = browserNotificationService.sendNotification({
title: title || '新通知',
body: content || '',
tag,
requireInteraction,
data: { link },
autoClose: requireInteraction ? 0 : 8000,
});
// 设置点击处理(聚焦窗口并跳转)
if (notification && link) {
notification.onclick = () => {
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
}, [browserPermission]);
/**
* 事件数据适配器 - 将后端事件格式转换为前端通知格式
* @param {object} event - 后端事件对象
* @returns {object} - 前端通知对象
*/
const adaptEventToNotification = useCallback((event) => {
// 检测数据格式:如果已经是前端格式(包含 priority直接返回
if (event.priority || event.type === NOTIFICATION_TYPES.ANNOUNCEMENT || event.type === NOTIFICATION_TYPES.STOCK_ALERT) {
logger.debug('NotificationContext', 'Event is already in notification format', { id: event.id });
return event;
}
// 转换后端事件格式到前端通知格式
logger.debug('NotificationContext', 'Converting backend event to notification format', {
eventId: event.id,
eventType: event.event_type,
importance: event.importance
});
// 重要性映射S/A → urgent/important, B/C → normal
let priority = PRIORITY_LEVELS.NORMAL;
if (event.importance === 'S') {
priority = PRIORITY_LEVELS.URGENT;
} else if (event.importance === 'A') {
priority = PRIORITY_LEVELS.IMPORTANT;
}
// 获取自动关闭时长
const autoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
// 构建通知对象
const notification = {
id: event.id || `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: NOTIFICATION_TYPES.EVENT_ALERT, // 统一使用"事件动向"类型
priority: priority,
title: event.title || '新事件',
content: event.description || event.content || '',
publishTime: event.created_at ? new Date(event.created_at).getTime() : Date.now(),
pushTime: Date.now(),
timestamp: Date.now(),
isAIGenerated: event.is_ai_generated || false,
clickable: true,
link: `/event-detail/${event.id}`,
autoClose: autoClose,
extra: {
eventId: event.id,
eventType: event.event_type,
importance: event.importance,
status: event.status,
hotScore: event.hot_score,
viewCount: event.view_count,
relatedAvgChg: event.related_avg_chg,
relatedMaxChg: event.related_max_chg,
keywords: event.keywords || [],
},
};
logger.info('NotificationContext', 'Event converted to notification', {
eventId: event.id,
notificationId: notification.id,
priority: notification.priority,
});
return notification;
}, []);
/**
* 添加网页通知(内部方法)
*/
const addWebNotification = useCallback((newNotification) => {
// 监控埋点:追踪通知接收
notificationMetricsService.trackReceived(newNotification);
// 保存到历史记录
notificationHistoryService.saveNotification(newNotification);
// 新消息插入到数组开头,最多保留 maxHistory 条
setNotifications(prev => {
const updated = [newNotification, ...prev];
const maxNotifications = NOTIFICATION_CONFIG.maxHistory;
// 如果超过最大数量,移除最旧的(数组末尾)
if (updated.length > maxNotifications) {
const removed = updated.slice(maxNotifications);
removed.forEach(old => {
logger.info('NotificationContext', 'Auto-removing old notification', { id: old.id });
});
return updated.slice(0, maxNotifications);
}
return updated;
});
// 播放音效
playNotificationSound();
// 自动关闭
if (newNotification.autoClose && newNotification.autoClose > 0) {
setTimeout(() => {
removeNotification(newNotification.id);
}, newNotification.autoClose);
}
}, [playNotificationSound, removeNotification]);
/**
* 添加通知到队列
* @param {object} notification - 通知对象
*/
const addNotification = useCallback(async (notification) => {
// ========== 显示层去重检查 ==========
const notificationId = notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 检查当前显示队列中是否已存在该通知
const isDuplicate = notifications.some(n => n.id === notificationId);
if (isDuplicate) {
logger.debug('NotificationContext', 'Duplicate notification ignored at display level', { id: notificationId });
return notificationId; // 返回ID但不显示
}
// ========== 显示层去重检查结束 ==========
// 根据优先级获取自动关闭时长
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
const newNotification = {
id: notificationId, // 使用预先生成的ID
type: notification.type || 'info',
severity: notification.severity || 'info',
title: notification.title || '通知',
message: notification.message || '',
timestamp: notification.timestamp || Date.now(),
priority: priority,
autoClose: notification.autoClose !== undefined ? notification.autoClose : defaultAutoClose,
...notification,
};
logger.info('NotificationContext', 'Adding notification', newNotification);
// ========== 增强权限请求策略 ==========
// 只要收到通知,就检查并提示用户授权
// 如果权限是default未授权自动请求
if (browserPermission === 'default' && !hasRequestedPermission) {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission();
}
// 如果权限是denied已拒绝提供设置指引
else if (browserPermission === 'denied') {
const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) {
toast({
id: toastId,
duration: 12000,
isClosable: true,
position: 'top',
render: ({ onClose }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
maxW="400px"
>
<VStack spacing={3} align="stretch">
<HStack spacing={2}>
<Icon as={BellIcon} boxSize={5} />
<Text fontWeight="bold" fontSize="md">
浏览器通知已被拒绝
</Text>
</HStack>
<Text fontSize="sm" opacity={0.9}>
{newNotification.title}
</Text>
<Text fontSize="xs" opacity={0.8}>
💡 如需接收桌面通知请在浏览器设置中允许通知权限
</Text>
<VStack spacing={1} align="start" fontSize="xs" opacity={0.7}>
<Text>Chrome: 地址栏左侧 🔒 网站设置 通知</Text>
<Text>Safari: 偏好设置 网站 通知</Text>
<Text>Edge: 地址栏右侧 网站权限 通知</Text>
</VStack>
<Button
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={onClose}
alignSelf="flex-end"
>
知道了
</Button>
</VStack>
</Box>
),
});
}
}
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 原分发策略(按优先级区分)- 已废弃 ==========
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
// if (priority === PRIORITY_LEVELS.URGENT) {
// logger.info('NotificationContext', 'Urgent notification: sending browser + web');
// // 总是发送浏览器通知
// sendBrowserNotification(newNotification);
// // 如果在前台,也显示网页通知
// if (!isPageHidden) {
// addWebNotification(newNotification);
// }
// }
// 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页)
// else if (priority === PRIORITY_LEVELS.IMPORTANT) {
// if (isPageHidden) {
// logger.info('NotificationContext', 'Important notification (background): sending browser');
// sendBrowserNotification(newNotification);
// } else {
// logger.info('NotificationContext', 'Important notification (foreground): sending web');
// addWebNotification(newNotification);
// }
// }
// 策略 3: 普通通知 - 仅网页通知
// else {
// logger.info('NotificationContext', 'Normal notification: sending web only');
// addWebNotification(newNotification);
// }
// ========== 新分发策略(仅区分前后台) ==========
if (isPageHidden) {
// 页面在后台:发送浏览器通知
logger.info('NotificationContext', 'Page hidden: sending browser notification');
sendBrowserNotification(newNotification);
} else {
// 页面在前台:发送网页通知
logger.info('NotificationContext', 'Page visible: sending web notification');
addWebNotification(newNotification);
}
return newNotification.id;
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
// 连接到 Socket 服务
useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...');
// 连接 socket
socket.connect();
// 获取并保存最大重连次数
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// 监听连接状态
socket.on('connect', () => {
const wasDisconnected = connectionStatus !== CONNECTION_STATUS.CONNECTED;
setIsConnected(true);
setReconnectAttempt(0);
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
if (wasDisconnected) {
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
logger.info('NotificationContext', 'Reconnected, will auto-dismiss in 2s');
// 清除之前的定时器
if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current);
}
// 2秒后自动变回 CONNECTED
reconnectedTimerRef.current = setTimeout(() => {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
}, 2000);
} else {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
}
// 如果使用 mock可以启动定期推送
if (SOCKET_TYPE === 'MOCK') {
// 启动模拟推送:使用配置的间隔和数量
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
socket.startMockPush(interval, maxBatch);
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
}
});
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket disconnected', { reason });
});
// 监听连接错误
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
// 获取重连次数Real 和 Mock 都支持)
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', 'Reconnection attempt', { attempts, socketType: SOCKET_TYPE });
});
// 监听重连失败
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', 'Socket reconnect_failed');
setConnectionStatus(CONNECTION_STATUS.FAILED);
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null, // 不自动关闭
isClosable: true,
});
});
// 监听新事件推送(统一事件名)
socket.on('new_event', (data) => {
logger.info('NotificationContext', 'Received new event', data);
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type}_${data.publishTime}`;
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
return; // 重复事件,直接忽略
}
// 记录已处理的事件ID
processedEventIds.current.add(eventId);
// 限制Set大小避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS
});
}
// ========== Socket层去重检查结束 ==========
// 使用适配器转换事件格式
const notification = adaptEventToNotification(data);
addNotification(notification);
});
// 保留系统通知监听(兼容性)
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
addNotification(data);
});
// 清理函数
return () => {
logger.info('NotificationContext', 'Cleaning up socket connection');
// 如果是 mock service停止推送
if (SOCKET_TYPE === 'MOCK') {
socket.stopMockPush();
}
socket.off('connect');
socket.off('disconnect');
socket.off('connect_error');
socket.off('reconnect_failed');
socket.off('new_event');
socket.off('system_notification');
socket.disconnect();
};
}, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
// ==================== 智能自动重试 ====================
/**
* 标签页聚焦时自动重试
*/
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isConnected, connectionStatus]);
/**
* 网络恢复时自动重试
*/
useEffect(() => {
const handleOnline = () => {
if (!isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
logger.info('NotificationContext', 'Network restored, attempting auto-reconnect');
toast({
title: '网络已恢复',
description: '正在重新连接...',
status: 'info',
duration: 2000,
isClosable: true,
});
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
}
};
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
};
}, [isConnected, connectionStatus, toast]);
/**
* 追踪通知点击
* @param {string} id - 通知ID
*/
const trackNotificationClick = useCallback((id) => {
const notification = notifications.find(n => n.id === id);
if (notification) {
logger.info('NotificationContext', 'Notification clicked', { id });
// 监控埋点:追踪点击
notificationMetricsService.trackClicked(notification);
// 标记历史记录为已点击
notificationHistoryService.markAsClicked(id);
}
}, [notifications]);
/**
* 手动重试连接
*/
const retryConnection = useCallback(() => {
logger.info('NotificationContext', 'Manual reconnection triggered');
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
}, []);
const value = {
notifications,
isConnected,
soundEnabled,
browserPermission,
connectionStatus,
reconnectAttempt,
maxReconnectAttempts,
addNotification,
removeNotification,
clearAllNotifications,
toggleSound,
requestBrowserPermission,
trackNotificationClick,
retryConnection,
// ⚡ 新增:权限引导方法
showWelcomeGuide,
showCommunityGuide,
showFirstFollowGuide,
};
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
};
// 导出连接状态枚举供外部使用
export { CONNECTION_STATUS };
export default NotificationContext;

4340
src/data/industryData.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
// src/hooks/useEventNotifications.js
/**
* React Hook用于在组件中订阅事件推送通知
*
* 使用示例:
* ```jsx
* import { useEventNotifications } from 'hooks/useEventNotifications';
*
* function MyComponent() {
* const { newEvent, isConnected } = useEventNotifications({
* eventType: 'all',
* importance: 'all',
* onNewEvent: (event) => {
* console.log('收到新事件:', event);
* // 显示通知...
* }
* });
*
* return <div>...</div>;
* }
* ```
*/
import { useEffect, useState, useRef } from 'react';
import { socketService } from '../services/socketService';
import { logger } from '../utils/logger';
export const useEventNotifications = (options = {}) => {
const {
eventType = 'all',
importance = 'all',
enabled = true,
onNewEvent,
} = options;
const [isConnected, setIsConnected] = useState(false);
const [newEvent, setNewEvent] = useState(null);
const [error, setError] = useState(null);
const unsubscribeRef = useRef(null);
// 使用 ref 存储 onNewEvent 回调,避免因回调函数引用改变导致重新连接
const onNewEventRef = useRef(onNewEvent);
// 每次 onNewEvent 改变时更新 ref
useEffect(() => {
onNewEventRef.current = onNewEvent;
}, [onNewEvent]);
useEffect(() => {
console.log('[useEventNotifications DEBUG] ========== useEffect 执行 ==========');
console.log('[useEventNotifications DEBUG] enabled:', enabled);
console.log('[useEventNotifications DEBUG] eventType:', eventType);
console.log('[useEventNotifications DEBUG] importance:', importance);
// 如果禁用,则不订阅
if (!enabled) {
console.log('[useEventNotifications DEBUG] ⚠️ 订阅已禁用,跳过');
return;
}
// 连接状态监听
const handleConnect = () => {
console.log('[useEventNotifications DEBUG] ✓ WebSocket 已连接');
logger.info('useEventNotifications', 'WebSocket connected');
setIsConnected(true);
setError(null);
};
const handleDisconnect = () => {
console.log('[useEventNotifications DEBUG] ⚠️ WebSocket 已断开');
logger.warn('useEventNotifications', 'WebSocket disconnected');
setIsConnected(false);
};
const handleConnectError = (err) => {
console.error('[useEventNotifications ERROR] WebSocket 连接错误:', err);
logger.error('useEventNotifications', 'WebSocket connect error', err);
setError(err);
setIsConnected(false);
};
// 监听连接事件必须在connect之前设置否则可能错过事件
socketService.on('connect', handleConnect);
socketService.on('disconnect', handleDisconnect);
socketService.on('connect_error', handleConnectError);
// 连接 WebSocket
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
logger.info('useEventNotifications', 'Initializing WebSocket connection');
// 先检查是否已经连接
const alreadyConnected = socketService.isConnected();
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
if (alreadyConnected) {
// 如果已经连接,直接更新状态
console.log('[useEventNotifications DEBUG] Socket已连接直接更新状态');
setIsConnected(true);
} else {
// 否则建立新连接
socketService.connect();
}
// 新事件处理函数 - 使用 ref 中的回调
const handleNewEvent = (eventData) => {
console.log('\n[useEventNotifications DEBUG] ========== Hook 收到新事件 ==========');
console.log('[useEventNotifications DEBUG] 事件数据:', eventData);
console.log('[useEventNotifications DEBUG] 事件 ID:', eventData?.id);
console.log('[useEventNotifications DEBUG] 事件标题:', eventData?.title);
console.log('[useEventNotifications DEBUG] 设置 newEvent 状态');
setNewEvent(eventData);
console.log('[useEventNotifications DEBUG] ✓ newEvent 状态已更新');
// 调用外部回调(从 ref 中获取最新的回调)
if (onNewEventRef.current) {
console.log('[useEventNotifications DEBUG] 准备调用外部 onNewEvent 回调');
onNewEventRef.current(eventData);
console.log('[useEventNotifications DEBUG] ✓ 外部 onNewEvent 回调已调用');
} else {
console.log('[useEventNotifications DEBUG] ⚠️ 没有外部 onNewEvent 回调');
}
console.log('[useEventNotifications DEBUG] ========== Hook 事件处理完成 ==========\n');
};
// 订阅事件推送
console.log('\n[useEventNotifications DEBUG] ========== 开始订阅事件 ==========');
console.log('[useEventNotifications DEBUG] eventType:', eventType);
console.log('[useEventNotifications DEBUG] importance:', importance);
console.log('[useEventNotifications DEBUG] enabled:', enabled);
socketService.subscribeToEvents({
eventType,
importance,
onNewEvent: handleNewEvent,
onSubscribed: (data) => {
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
},
});
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
// 保存取消订阅函数
unsubscribeRef.current = () => {
socketService.unsubscribeFromEvents({ eventType });
};
// 组件卸载时清理
return () => {
console.log('\n[useEventNotifications DEBUG] ========== 清理 WebSocket 订阅 ==========');
// 取消订阅
if (unsubscribeRef.current) {
console.log('[useEventNotifications DEBUG] 取消订阅...');
unsubscribeRef.current();
}
// 移除监听器
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
socketService.off('connect', handleConnect);
socketService.off('disconnect', handleDisconnect);
socketService.off('connect_error', handleConnectError);
// 断开连接
console.log('[useEventNotifications DEBUG] 断开 WebSocket 连接...');
socketService.disconnect();
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
};
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
return {
newEvent, // 最新收到的事件
isConnected, // WebSocket 连接状态
error, // 错误信息
clearNewEvent: () => setNewEvent(null), // 清除新事件状态
};
};
/**
* 简化版 Hook只订阅所有事件
*/
export const useAllEventNotifications = (onNewEvent) => {
return useEventNotifications({
eventType: 'all',
importance: 'all',
onNewEvent,
});
};
/**
* Hook订阅重要事件S 和 A 级)
*/
export const useImportantEventNotifications = (onNewEvent) => {
const [importantEvents, setImportantEvents] = useState([]);
const handleEvent = (event) => {
// 只处理 S 和 A 级事件
if (event.importance === 'S' || event.importance === 'A') {
setImportantEvents(prev => [event, ...prev].slice(0, 10)); // 最多保留 10 个
if (onNewEvent) {
onNewEvent(event);
}
}
};
const result = useEventNotifications({
eventType: 'all',
importance: 'all',
onNewEvent: handleEvent,
});
return {
...result,
importantEvents,
clearImportantEvents: () => setImportantEvents([]),
};
};
export default useEventNotifications;

View File

@@ -0,0 +1,170 @@
// src/hooks/usePermissionGuide.js
/**
* 通知权限引导管理 Hook
*
* 功能:
* - 管理多个引导场景的显示状态
* - 使用 localStorage 持久化记录
* - 支持定期提醒策略
*/
import { useState, useCallback, useEffect } from 'react';
import { logger } from '../utils/logger';
// 引导场景类型
export const GUIDE_TYPES = {
WELCOME: 'welcome', // 首次登录欢迎引导
COMMUNITY: 'community', // 社区功能引导
FIRST_FOLLOW: 'first_follow', // 首次关注事件引导
PERIODIC: 'periodic', // 定期提醒
};
// localStorage 键名
const STORAGE_KEYS = {
SHOWN_GUIDES: 'notification_guides_shown',
LAST_PERIODIC: 'notification_last_periodic_prompt',
TOTAL_PROMPTS: 'notification_total_prompts',
};
// 定期提醒间隔(毫秒)
const PERIODIC_INTERVAL = 3 * 24 * 60 * 60 * 1000; // 3 天
const MAX_PERIODIC_PROMPTS = 3; // 最多提醒 3 次
/**
* 权限引导管理 Hook
*/
export function usePermissionGuide() {
const [shownGuides, setShownGuides] = useState(() => {
try {
const stored = localStorage.getItem(STORAGE_KEYS.SHOWN_GUIDES);
return stored ? JSON.parse(stored) : [];
} catch (error) {
logger.error('usePermissionGuide', 'Failed to load shown guides', error);
return [];
}
});
/**
* 检查是否应该显示某个引导
* @param {string} guideType - 引导类型
* @returns {boolean}
*/
const shouldShowGuide = useCallback((guideType) => {
// 已经显示过的引导不再显示
if (shownGuides.includes(guideType)) {
return false;
}
// 特殊逻辑:定期提醒
if (guideType === GUIDE_TYPES.PERIODIC) {
try {
const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC);
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
// 超过最大提醒次数
if (totalPrompts >= MAX_PERIODIC_PROMPTS) {
logger.debug('usePermissionGuide', 'Periodic prompts limit reached', { totalPrompts });
return false;
}
// 未到提醒间隔
if (lastPrompt) {
const elapsed = Date.now() - parseInt(lastPrompt, 10);
if (elapsed < PERIODIC_INTERVAL) {
logger.debug('usePermissionGuide', 'Periodic interval not reached', {
elapsed: Math.round(elapsed / 1000 / 60 / 60), // 小时
required: Math.round(PERIODIC_INTERVAL / 1000 / 60 / 60)
});
return false;
}
}
return true;
} catch (error) {
logger.error('usePermissionGuide', 'Failed to check periodic guide', error);
return false;
}
}
return true;
}, [shownGuides]);
/**
* 标记引导已显示
* @param {string} guideType - 引导类型
*/
const markGuideAsShown = useCallback((guideType) => {
try {
// 更新状态
setShownGuides(prev => {
if (prev.includes(guideType)) {
return prev;
}
const updated = [...prev, guideType];
// 持久化
localStorage.setItem(STORAGE_KEYS.SHOWN_GUIDES, JSON.stringify(updated));
logger.info('usePermissionGuide', 'Guide marked as shown', { guideType });
return updated;
});
// 特殊处理:定期提醒
if (guideType === GUIDE_TYPES.PERIODIC) {
localStorage.setItem(STORAGE_KEYS.LAST_PERIODIC, String(Date.now()));
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
localStorage.setItem(STORAGE_KEYS.TOTAL_PROMPTS, String(totalPrompts + 1));
logger.info('usePermissionGuide', 'Periodic prompt recorded', {
totalPrompts: totalPrompts + 1
});
}
} catch (error) {
logger.error('usePermissionGuide', 'Failed to mark guide as shown', error);
}
}, []);
/**
* 重置所有引导(用于测试或用户主动重置)
*/
const resetAllGuides = useCallback(() => {
try {
localStorage.removeItem(STORAGE_KEYS.SHOWN_GUIDES);
localStorage.removeItem(STORAGE_KEYS.LAST_PERIODIC);
localStorage.removeItem(STORAGE_KEYS.TOTAL_PROMPTS);
setShownGuides([]);
logger.info('usePermissionGuide', 'All guides reset');
} catch (error) {
logger.error('usePermissionGuide', 'Failed to reset guides', error);
}
}, []);
/**
* 获取定期提醒的统计信息(用于调试)
*/
const getPeriodicStats = useCallback(() => {
try {
const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC);
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
return {
lastPromptTime: lastPrompt ? new Date(parseInt(lastPrompt, 10)) : null,
totalPrompts,
remainingPrompts: MAX_PERIODIC_PROMPTS - totalPrompts,
nextPromptTime: lastPrompt
? new Date(parseInt(lastPrompt, 10) + PERIODIC_INTERVAL)
: new Date(),
};
} catch (error) {
logger.error('usePermissionGuide', 'Failed to get periodic stats', error);
return null;
}
}, []);
return {
shouldShowGuide,
markGuideAsShown,
resetAllGuides,
getPeriodicStats,
shownGuides,
};
}

View File

@@ -1,6 +1,7 @@
// src/hooks/useSubscription.js
import { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { logger } from '../utils/logger';
// 订阅级别映射
const SUBSCRIPTION_LEVELS = {
@@ -46,7 +47,10 @@ export const useSubscription = () => {
// 首先检查用户对象中是否已经包含订阅信息
if (user.subscription_type) {
console.log('📋 从用户对象获取订阅信息:', user.subscription_type);
logger.debug('useSubscription', '从用户对象获取订阅信息', {
subscriptionType: user.subscription_type,
daysLeft: user.subscription_days_left
});
setSubscriptionInfo({
type: user.subscription_type,
status: 'active',
@@ -73,7 +77,10 @@ export const useSubscription = () => {
}
} else {
// 如果API调用失败回退到用户对象中的信息
console.log('📋 API失败使用用户对象中的订阅信息');
logger.warn('useSubscription', 'API调用失败,使用用户对象订阅信息', {
status: response.status,
fallbackType: user.subscription_type || 'free'
});
setSubscriptionInfo({
type: user.subscription_type || 'free',
status: 'active',
@@ -82,7 +89,9 @@ export const useSubscription = () => {
});
}
} catch (error) {
console.error('获取订阅信息失败:', error);
logger.error('useSubscription', 'fetchSubscriptionInfo', error, {
userId: user?.id
});
// 发生错误时,回退到用户对象中的信息
setSubscriptionInfo({
type: user.subscription_type || 'free',
@@ -97,7 +106,8 @@ export const useSubscription = () => {
useEffect(() => {
fetchSubscriptionInfo();
}, [isAuthenticated, user]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
// 获取订阅级别数值
const getSubscriptionLevel = (type = null) => {
@@ -109,7 +119,10 @@ export const useSubscription = () => {
const hasFeatureAccess = (featureName) => {
// 临时调试如果用户对象中有max权限直接解锁所有功能
if (user?.subscription_type === 'max') {
console.log(`🔓 Max用户直接解锁功能: ${featureName}`);
logger.debug('useSubscription', 'Max用户解锁功能', {
featureName,
userId: user?.id
});
return true;
}

View File

@@ -1,222 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import { Portal, Box, useColorMode, Stack, useDisclosure, useColorModeValue } from '@chakra-ui/react';
import Configurator from 'components/Configurator/Configurator';
import FixedPlugin from 'components/FixedPlugin/FixedPlugin';
import Footer from 'components/Footer/Footer.js';
// Custom components
import MainPanel from 'components/Layout/MainPanel';
import PanelContainer from 'components/Layout/PanelContainer';
import PanelContent from 'components/Layout/PanelContent';
// Layout components
import AdminNavbar from 'components/Navbars/AdminNavbar.js';
import Sidebar from 'components/Sidebar/Sidebar.js';
import { SidebarContext } from 'contexts/SidebarContext';
import React, { useState, Suspense } from 'react';
import 'react-quill/dist/quill.snow.css'; // ES6
import { Route, Routes, Navigate } from "react-router-dom";
import routes from 'routes.js';
import PageLoader from 'components/Loading/PageLoader';
import {
ArgonLogoDark,
ArgonLogoLight,
ChakraLogoDark,
ChakraLogoLight,
ArgonLogoMinifiedDark,
ArgonLogoMinifiedLight
} from 'components/Icons/Icons';
// Custom Chakra theme
export default function Dashboard(props) {
const { ...rest } = props;
// states and functions
const [ fixed, setFixed ] = useState(false);
const [ toggleSidebar, setToggleSidebar ] = useState(false);
const [ sidebarWidth, setSidebarWidth ] = useState(275);
// functions for changing the states from components
const getRoute = () => {
return window.location.pathname !== '/admin/full-screen-maps';
};
const getActiveRoute = (routes) => {
let activeRoute = 'Default Brand Text';
for (let i = 0; i < routes.length; i++) {
if (routes[i].collapse) {
let collapseActiveRoute = getActiveRoute(routes[i].items);
if (collapseActiveRoute !== activeRoute) {
return collapseActiveRoute;
}
} else if (routes[i].category) {
let categoryActiveRoute = getActiveRoute(routes[i].items);
if (categoryActiveRoute !== activeRoute) {
return categoryActiveRoute;
}
} else {
if (window.location.href.indexOf(routes[i].layout + routes[i].path) !== -1) {
return routes[i].name;
}
}
}
return activeRoute;
};
const getActiveNavbar = (routes) => {
let activeNavbar = false;
for (let i = 0; i < routes.length; i++) {
if (routes[i].collapse) {
let collapseActiveNavbar = getActiveNavbar(routes[i].items);
if (collapseActiveNavbar !== activeNavbar) {
return collapseActiveNavbar;
}
} else if (routes[i].category) {
let categoryActiveNavbar = getActiveNavbar(routes[i].items);
if (categoryActiveNavbar !== activeNavbar) {
return categoryActiveNavbar;
}
} else {
if (window.location.href.indexOf(routes[i].layout + routes[i].path) !== -1) {
return routes[i].secondaryNavbar;
}
}
}
return activeNavbar;
};
const getRoutes = (routes) => {
return routes.map((route, key) => {
if (route.layout === '/admin') {
// ⚡ 懒加载组件需要包裹在 Suspense 中
const Component = route.component;
return (
<Route
path={route.path}
element={
<Suspense fallback={<PageLoader message="加载中..." />}>
<Component />
</Suspense>
}
key={key}
/>
);
}
if (route.collapse) {
return getRoutes(route.items);
}
if (route.category) {
return getRoutes(route.items);
} else {
return null;
}
});
};
let bgBoxHeight = '40vh';
let bgBoxColor = useColorModeValue('blue.500', 'navy.900');
const { isOpen, onOpen, onClose } = useDisclosure();
const { colorMode } = useColorMode();
document.documentElement.dir = 'ltr';
document.documentElement.layout = 'admin';
// Chakra Color Mode
return (
<Box>
<SidebarContext.Provider
value={{
sidebarWidth,
setSidebarWidth,
toggleSidebar,
setToggleSidebar
}}>
<Box minH={bgBoxHeight} h='100% !important' w='100%' position='absolute' bg={bgBoxColor} top='0' />
<Sidebar
routes={routes}
logo={
sidebarWidth === 275 ? (
<Stack direction='row' spacing='12px' align='center' justify='center'>
{colorMode === 'dark' ? (
<ArgonLogoLight w='74px' h='27px' />
) : (
<ArgonLogoDark w='74px' h='27px' />
)}
<Box w='1px' h='20px' bg={colorMode === 'dark' ? 'white' : 'gray.700'} />
{colorMode === 'dark' ? (
<ChakraLogoLight w='82px' h='21px' />
) : (
<ChakraLogoDark w='82px' h='21px' />
)}
</Stack>
) : colorMode === 'light' ? (
<ArgonLogoMinifiedDark w='36px' h='36px' />
) : (
<ArgonLogoMinifiedLight w='36px' h='36px' />
)
}
display='none'
{...rest}
/>
<MainPanel
w={{
base: '100%',
xl: `calc(100% - ${sidebarWidth}px)`
}}>
<Portal>
<Box>
<AdminNavbar
onOpen={onOpen}
logoText={'Argon Dashboard Chakra PRO'}
brandText={getActiveRoute(routes)}
secondary={getActiveNavbar(routes)}
fixed={fixed}
{...rest}
/>
</Box>
</Portal>
{getRoute() ? (
<PanelContent>
<PanelContainer>
<Routes>
{getRoutes(routes)}
<Route
path="/"
element={<Navigate to="/admin/dashboard/default" replace />}
/>
</Routes>
</PanelContainer>
</PanelContent>
) : null}
<Box>
<Footer />
</Box>
<Portal>
<Box>
<FixedPlugin fixed={fixed} onOpen={onOpen} />
</Box>
</Portal>
<Configurator
secondary={getActiveNavbar(routes)}
isOpen={isOpen}
onClose={onClose}
isChecked={fixed}
onSwitch={(value) => {
setFixed(value);
}}
/>
</MainPanel>
</SidebarContext.Provider>
</Box>
);
}

View File

@@ -32,7 +32,6 @@ export default function Home() {
<Routes>
{/* 首页默认路由 */}
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<HomePage />} />
<Route
path="/center"
element={

View File

@@ -1,257 +0,0 @@
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Chakra imports
import {
Portal,
Box,
useColorMode,
Stack,
useDisclosure,
useColorModeValue,
} from "@chakra-ui/react";
import Configurator from "components/Configurator/Configurator";
import FixedPlugin from "components/FixedPlugin/FixedPlugin";
import Footer from "components/Footer/Footer.js";
// Custom components
import MainPanel from "components/Layout/MainPanel";
import PanelContainer from "components/Layout/PanelContainer";
import PanelContent from "components/Layout/PanelContent";
// Layout components
import AdminNavbar from "components/Navbars/AdminNavbar.js";
import Sidebar from "components/Sidebar/Sidebar.js";
import { SidebarContext } from "contexts/SidebarContext";
import React, { useState, Suspense } from "react";
import "react-quill/dist/quill.snow.css"; // ES6
import { Route, Routes, Navigate } from "react-router-dom";
import routes from "routes.js";
import PageLoader from "components/Loading/PageLoader";
import {
ArgonLogoDark,
ArgonLogoLight,
ChakraLogoDark,
ChakraLogoLight,
ArgonLogoMinifiedDark,
ArgonLogoMinifiedLight,
} from "components/Icons/Icons";
import { RtlProvider } from "components/RTLProvider/RTLProvider";
export default function Dashboard(props) {
const { ...rest } = props;
// states and functions
const [sidebarVariant, setSidebarVariant] = useState("transparent");
const [fixed, setFixed] = useState(false);
const [toggleSidebar, setToggleSidebar] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(275);
// ref for main panel div
const mainPanel = React.createRef();
const { colorMode } = useColorMode();
// functions for changing the states from components
const getRoute = () => {
return window.location.pathname !== "/rtl/full-screen-maps";
};
const getActiveRoute = (routes) => {
let activeRoute = "Default Brand Text";
for (let i = 0; i < routes.length; i++) {
if (routes[i].collapse) {
let collapseActiveRoute = getActiveRoute(routes[i].items);
if (collapseActiveRoute !== activeRoute) {
return collapseActiveRoute;
}
} else if (routes[i].category) {
let categoryActiveRoute = getActiveRoute(routes[i].items);
if (categoryActiveRoute !== activeRoute) {
return categoryActiveRoute;
}
} else {
if (
window.location.href.indexOf(routes[i].layout + routes[i].path) !== -1
) {
return routes[i].name;
}
}
}
return activeRoute;
};
// This changes navbar state(fixed or not)
const getActiveNavbar = (routes) => {
let activeNavbar = false;
for (let i = 0; i < routes.length; i++) {
if (routes[i].category) {
let categoryActiveNavbar = getActiveNavbar(routes[i].items);
if (categoryActiveNavbar !== activeNavbar) {
return categoryActiveNavbar;
}
} else {
if (
window.location.href.indexOf(routes[i].layout + routes[i].path) !== -1
) {
if (routes[i].secondaryNavbar) {
return routes[i].secondaryNavbar;
}
}
}
}
return activeNavbar;
};
const getRoutes = (routes) => {
return routes.map((route, key) => {
if (route.layout === "/rtl") {
const Component = route.component;
return (
<Route
path={route.path}
element={
<Suspense fallback={<PageLoader message="加载中..." />}>
<Component />
</Suspense>
}
key={key}
/>
);
}
if (route.collapse) {
return getRoutes(route.items);
}
if (route.category) {
return getRoutes(route.items);
} else {
return null;
}
});
};
const { isOpen, onOpen, onClose } = useDisclosure();
document.body.style.backgroundColor = useColorModeValue(
"gray.50",
"gray.800"
);
document.documentElement.dir = "rtl";
document.documentElement.layout = "rtl";
let bgBoxHeight = "40vh";
let bgBoxColor = useColorModeValue("blue.500", "navy.900");
// Chakra Color Mode
return (
<>
<RtlProvider>
<SidebarContext.Provider
value={{
sidebarWidth,
setSidebarWidth,
toggleSidebar,
setToggleSidebar,
}}>
<Box
minH={bgBoxHeight}
h='100% !important'
w='100%'
position='absolute'
bg={bgBoxColor}
top='0'
/>
<Sidebar
routes={routes}
logo={
sidebarWidth === 275 ? (
<Stack
direction='row'
spacing='12px'
align='center'
justify='center'>
{colorMode === "dark" ? (
<ArgonLogoLight w='74px' h='27px' />
) : (
<ArgonLogoDark w='74px' h='27px' />
)}
<Box
w='1px'
h='20px'
bg={colorMode === "dark" ? "white" : "gray.700"}
/>
{colorMode === "dark" ? (
<ChakraLogoLight w='82px' h='21px' />
) : (
<ChakraLogoDark w='82px' h='21px' />
)}
</Stack>
) : colorMode === "light" ? (
<ArgonLogoMinifiedDark w='36px' h='36px' />
) : (
<ArgonLogoMinifiedLight w='36px' h='36px' />
)
}
display='none'
{...rest}
/>
<MainPanel
ref={mainPanel}
w={{
base: "100%",
xl: `calc(100% - ${sidebarWidth}px)`,
}}
variant='rtl'>
<Portal>
<Box>
<AdminNavbar
onOpen={onOpen}
logoText={"Argon Dashboard Chakra PRO"}
brandText={getActiveRoute(routes)}
secondary={getActiveNavbar(routes)}
fixed={fixed}
{...rest}
/>
</Box>
</Portal>
{getRoute() ? (
<PanelContent>
<PanelContainer>
<Routes>
{getRoutes(routes)}
<Route
path="/"
element={<Navigate to="/admin/dashboard/default" replace />}
/>
</Routes>
</PanelContainer>
</PanelContent>
) : null}
<Box>
<Footer />
</Box>
<Portal>
<Box>
<FixedPlugin fixed={fixed} onOpen={onOpen} />
</Box>
</Portal>
<Configurator
secondary={getActiveNavbar(routes)}
isOpen={isOpen}
onClose={onClose}
isChecked={fixed}
onSwitch={(value) => {
setFixed(value);
}}
/>
</MainPanel>
</SidebarContext.Provider>
</RtlProvider>
</>
);
}

743
src/mocks/data/account.js Normal file
View File

@@ -0,0 +1,743 @@
// src/mocks/data/account.js
// 个人中心相关的 Mock 数据
// ==================== 自选股数据 ====================
export const mockWatchlist = [
{
id: 1,
user_id: 1,
stock_code: '600519.SH',
stock_name: '贵州茅台',
industry: '白酒',
current_price: 1650.50,
change_percent: 2.5,
added_at: '2025-01-10T10:30:00Z'
},
{
id: 2,
user_id: 1,
stock_code: '000001.SZ',
stock_name: '平安银行',
industry: '银行',
current_price: 12.34,
change_percent: 4.76,
added_at: '2025-01-15T14:20:00Z'
},
{
id: 3,
user_id: 1,
stock_code: '000858.SZ',
stock_name: '五粮液',
industry: '白酒',
current_price: 156.78,
change_percent: 1.52,
added_at: '2025-01-08T09:15:00Z'
},
{
id: 4,
user_id: 1,
stock_code: '300750.SZ',
stock_name: '宁德时代',
industry: '新能源',
current_price: 168.90,
change_percent: -1.23,
added_at: '2025-01-12T16:45:00Z'
},
{
id: 5,
user_id: 1,
stock_code: '002594.SZ',
stock_name: 'BYD比亚迪',
industry: '新能源汽车',
current_price: 256.88,
change_percent: 3.45,
added_at: '2025-01-05T11:20:00Z'
}
];
// ==================== 实时行情数据 ====================
export const mockRealtimeQuotes = [
{
stock_code: '600519.SH',
current_price: 1650.50,
change_percent: 2.5,
change: 40.25,
volume: 2345678,
turnover: 3945678901.23,
high: 1665.00,
low: 1645.00,
open: 1648.80,
prev_close: 1610.25,
update_time: '15:00:00'
},
{
stock_code: '000001.SZ',
current_price: 12.34,
change_percent: 4.76,
change: 0.56,
volume: 123456789,
turnover: 1523456789.12,
high: 12.50,
low: 11.80,
open: 11.90,
prev_close: 11.78,
update_time: '15:00:00'
},
{
stock_code: '000858.SZ',
current_price: 156.78,
change_percent: 1.52,
change: 2.34,
volume: 45678901,
turnover: 7123456789.45,
high: 158.00,
low: 154.50,
open: 155.00,
prev_close: 154.44,
update_time: '15:00:00'
},
{
stock_code: '300750.SZ',
current_price: 168.90,
change_percent: -1.23,
change: -2.10,
volume: 98765432,
turnover: 16678945612.34,
high: 172.30,
low: 167.50,
open: 171.00,
prev_close: 171.00,
update_time: '15:00:00'
},
{
stock_code: '002594.SZ',
current_price: 256.88,
change_percent: 3.45,
change: 8.56,
volume: 56789012,
turnover: 14567890123.45,
high: 260.00,
low: 252.00,
open: 253.50,
prev_close: 248.32,
update_time: '15:00:00'
}
];
// ==================== 关注事件数据 ====================
export const mockFollowingEvents = [
{
id: 101,
title: '央行宣布降准0.5个百分点释放长期资金约1.2万亿元',
tags: ['货币政策', '央行', '降准', '银行'],
view_count: 12340,
comment_count: 156,
upvote_count: 489,
heat_score: 95,
exceed_expectation_score: 85,
creator: {
id: 1001,
username: '财经分析师',
avatar_url: 'https://i.pravatar.cc/150?img=11'
},
created_at: '2025-01-15T09:00:00Z',
followed_at: '2025-01-15T10:30:00Z'
},
{
id: 102,
title: 'ChatGPT-5 即将发布AI 算力需求将迎来爆发式增长',
tags: ['人工智能', 'ChatGPT', '算力', '科技'],
view_count: 8950,
comment_count: 234,
upvote_count: 567,
heat_score: 88,
exceed_expectation_score: 78,
creator: {
id: 1002,
username: '科技观察者',
avatar_url: 'https://i.pravatar.cc/150?img=12'
},
created_at: '2025-01-14T14:20:00Z',
followed_at: '2025-01-14T15:00:00Z'
},
{
id: 103,
title: '新能源汽车补贴政策延续至2026年行业持续受益',
tags: ['新能源', '汽车', '补贴政策', '产业政策'],
view_count: 6780,
comment_count: 98,
upvote_count: 345,
heat_score: 72,
exceed_expectation_score: 68,
creator: {
id: 1003,
username: '产业研究员',
avatar_url: 'https://i.pravatar.cc/150?img=13'
},
created_at: '2025-01-13T11:15:00Z',
followed_at: '2025-01-13T12:00:00Z'
},
{
id: 104,
title: '芯片法案正式实施,国产半导体迎来黄金发展期',
tags: ['半导体', '芯片', '国产替代', '政策'],
view_count: 9540,
comment_count: 178,
upvote_count: 432,
heat_score: 80,
exceed_expectation_score: 72,
creator: {
id: 1004,
username: '半导体观察',
avatar_url: 'https://i.pravatar.cc/150?img=14'
},
created_at: '2025-01-12T16:30:00Z',
followed_at: '2025-01-12T17:00:00Z'
},
{
id: 105,
title: '医保目录调整,创新药企业有望获得更多市场份额',
tags: ['医药', '医保', '创新药', '政策'],
view_count: 5430,
comment_count: 87,
upvote_count: 234,
heat_score: 65,
exceed_expectation_score: null,
creator: {
id: 1005,
username: '医药行业专家',
avatar_url: 'https://i.pravatar.cc/150?img=15'
},
created_at: '2025-01-11T10:00:00Z',
followed_at: '2025-01-11T11:30:00Z'
}
];
// ==================== 评论数据 ====================
export const mockEventComments = [
{
id: 201,
user_id: 1,
event_id: 101,
event_title: '央行宣布降准0.5个百分点释放长期资金约1.2万亿元',
content: '这次降准对银行股是重大利好!预计四大行和股份制银行都会受益,特别是净息差承压的中小银行。建议重点关注招商银行、兴业银行等优质标的。',
created_at: '2025-01-15T11:20:00Z',
likes: 45,
replies: 12
},
{
id: 202,
user_id: 1,
event_id: 102,
event_title: 'ChatGPT-5 即将发布AI 算力需求将迎来爆发式增长',
content: 'AI 板块又要起飞了重点关注算力基础设施概念股如服务器、芯片、数据中心等。另外AI 应用端也值得关注,特别是已经有成熟产品的公司。',
created_at: '2025-01-14T16:45:00Z',
likes: 38,
replies: 8
},
{
id: 203,
user_id: 1,
event_id: 103,
event_title: '新能源汽车补贴政策延续至2026年行业持续受益',
content: '政策延续对整个产业链都是好消息。上游的锂电池、下游的整车厂都会受益。比亚迪和宁德时代可以继续持有,长期看好新能源汽车的渗透率提升。',
created_at: '2025-01-13T14:30:00Z',
likes: 56,
replies: 15
},
{
id: 204,
user_id: 1,
event_id: 104,
event_title: '芯片法案正式实施,国产半导体迎来黄金发展期',
content: '国产替代是大趋势!设备材料、设计封测、制造都有机会。关注那些有核心技术、已经打入国内大厂供应链的公司。半导体是长期主线,波动中坚定持有。',
created_at: '2025-01-12T18:00:00Z',
likes: 67,
replies: 20
},
{
id: 205,
user_id: 1,
event_id: 105,
event_title: '医保目录调整,创新药企业有望获得更多市场份额',
content: '医保谈判结果出来了,创新药企业普遍受益。重点关注有多个重磅品种的药企,以及 CXO 产业链。医药板块经过调整后,估值已经比较合理,可以逐步配置。',
created_at: '2025-01-11T13:15:00Z',
likes: 42,
replies: 10
}
];
// ==================== 投资计划与复盘数据 ====================
export const mockInvestmentPlans = [
{
id: 301,
user_id: 1,
type: 'plan',
title: '2025年Q1 新能源板块布局计划',
content: '计划在Q1分批建仓新能源板块重点关注宁德时代、比亚迪、隆基绿能三只标的。目标仓位15%预计收益率20%。\n\n具体策略\n1. 宁德时代占比6%等待回调至160元附近分批买入\n2. 比亚迪占比6%,当前价位可以开始建仓\n3. 隆基绿能占比3%,观察光伏行业景气度再决定\n\n风险控制单只个股止损-8%,板块整体止损-10%',
target_date: '2025-03-31',
status: 'in_progress',
created_at: '2025-01-10T10:00:00Z',
updated_at: '2025-01-15T14:30:00Z',
tags: ['新能源', '布局计划', 'Q1计划']
},
{
id: 302,
user_id: 1,
type: 'review',
title: '2024年12月投资复盘 - 白酒板块大涨',
content: '12月白酒板块表现优异贵州茅台上涨12%五粮液上涨8%。\n\n操作回顾\n1. 11月底在1550元加仓茅台获利6.5%\n2. 五粮液持仓未动获利4.2%\n\n经验总结\n- 消费板块在年底有明显的估值修复行情\n- 龙头白马股在市场震荡时更具韧性\n- 应该更大胆一些仓位可以再提高2-3个点\n\n下月计划\n- 继续持有茅台、五粮液,不轻易卖出\n- 关注春节前的消费旺季催化',
target_date: '2024-12-31',
status: 'completed',
created_at: '2025-01-02T09:00:00Z',
updated_at: '2025-01-02T09:00:00Z',
tags: ['月度复盘', '白酒', '2024年12月']
},
{
id: 303,
user_id: 1,
type: 'plan',
title: 'AI 算力板块波段交易计划',
content: '随着ChatGPT-5即将发布AI算力板块有望迎来新一轮炒作。\n\n标的选择\n- 寒武纪AI芯片龙头弹性最大\n- 中科曙光:服务器厂商,业绩支撑\n- 浪潮信息:算力基础设施\n\n交易策略\n- 仓位控制在10%以内(高风险高弹性)\n- 采用金字塔式买入第一笔3%\n- 快进快出涨幅20%分批止盈\n- 破位及时止损,控制在-5%以内',
target_date: '2025-02-28',
status: 'pending',
created_at: '2025-01-14T16:00:00Z',
updated_at: '2025-01-14T16:00:00Z',
tags: ['AI', '算力', '波段交易']
},
{
id: 304,
user_id: 1,
type: 'review',
title: '2024年全年投资总结 - 收益率25.6%',
content: '2024年全年收益率25.6%跑赢沪深300指数12个百分点。\n\n全年亮点\n1. 新能源板块贡献最大年度收益35%\n2. 白酒板块稳健增长年度收益18%\n3. 半导体板块波动较大年度收益8%\n\n教训与反思\n1. 年初追高了一些热门概念股,后续回调损失较大\n2. 止损执行不够坚决,有两次错过最佳止损时机\n3. 仓位管理有待提高,牛市时仓位偏低\n\n2025年目标\n- 收益率目标30%\n- 优化仓位管理,提高资金使用效率\n- 严格执行止损纪律\n- 加强行业研究,提前布局',
target_date: '2024-12-31',
status: 'completed',
created_at: '2025-01-01T10:00:00Z',
updated_at: '2025-01-01T10:00:00Z',
tags: ['年度复盘', '2024年', '总结']
}
];
// ==================== 投资日历事件数据 ====================
// ==================== 未来事件数据(用于投资日历) ====================
export const mockFutureEvents = [
{
id: 501,
data_id: 501,
title: '美联储FOMC会议',
calendar_time: '2025-10-20T14:00:00Z',
type: 'event',
star: 5,
former: {
data: [
{
author: '美联储官网',
sentences: '本次会议将重点讨论通胀控制和利率调整策略,美联储将评估当前经济形势,包括就业市场、物价水平和金融稳定性等关键指标,以决定是否调整联邦基金利率目标区间',
query_part: '本次会议将重点讨论通胀控制和利率调整策略',
report_title: 'FOMC会议议程公告',
declare_date: '2025-10-15T00:00:00',
match_score: '好'
},
{
author: '彭博社',
sentences: '市场普遍预期美联储将维持当前利率水平根据对50位经济学家的调查超过80%的受访者认为美联储将在本次会议上保持利率不变,等待更多经济数据以评估政策效果',
query_part: '市场普遍预期美联储将维持当前利率水平',
report_title: '美联储利率决议前瞻:经济学家调查报告',
declare_date: '2025-10-18T00:00:00',
match_score: '好'
},
{
author: '路透社',
sentences: '鲍威尔的讲话将释放未来货币政策方向的重要信号,市场将密切关注其对经济前景的评估,特别是关于通胀回落速度、就业市场韧性以及未来降息时点的表述',
query_part: '鲍威尔的讲话将释放未来货币政策方向的重要信号',
report_title: '鲍威尔讲话要点预测',
declare_date: '2025-10-19T00:00:00',
match_score: '好'
}
]
},
forecast: '预计维持利率不变,关注鲍威尔讲话基调',
fact: null,
related_stocks: [
[
'600036',
'招商银行',
{
data: [
{
author: '中信证券',
sentences: '作为国内领先的商业银行,招商银行对利率变化敏感度高,美联储货币政策调整将通过汇率、资本流动等渠道影响国内货币政策,进而影响银行净息差和资产质量',
query_part: '美联储政策通过汇率和资本流动影响国内银行业',
report_title: '美联储政策对中国银行业影响分析',
declare_date: '2025-10-18T00:00:00',
match_score: '好'
},
{
author: '中信证券',
sentences: '作为国内领先的商业银行,招商银行对利率变化敏感度高,美联储货币政策调整将通过汇率、资本流动等渠道影响国内货币政策,进而影响银行净息差和资产质量',
query_part: '美联储政策通过汇率和资本流动影响国内银行业',
report_title: '美联储政策对中国银行业影响分析',
declare_date: '2025-10-18T00:00:00',
match_score: '好'
}
]
},
0.85
],
[
'601398',
'工商银行',
{
data: [
{
author: '招商证券',
sentences: '工商银行作为国有大行,其经营业绩与宏观经济和货币政策高度相关,美联储利率决策将影响全球流动性和人民币汇率,对大型商业银行的跨境业务和外汇敞口产生直接影响',
query_part: '美联储决策影响全球流动性和大行跨境业务',
report_title: '货币政策对银行业影响专题研究',
declare_date: '2025-10-17T00:00:00',
match_score: '好'
}
]
},
0.80
]
],
concepts: ['货币政策', '利率', '美联储'],
is_following: false
},
{
id: 502,
data_id: 502,
title: '央行货币政策委员会例会',
calendar_time: '2025-10-20T09:00:00Z',
type: 'event',
star: 4,
former: '本次例会将总结前期货币政策执行情况,研究部署下一阶段工作。重点关注经济增长、通胀水平和金融稳定等方面的形势变化。\n\n(AI合成)',
forecast: '可能释放适度宽松信号',
fact: null,
related_stocks: [],
concepts: ['货币政策', '央行', '宏观经济'],
is_following: true
},
{
id: 503,
data_id: 503,
title: '宁德时代业绩快报',
calendar_time: '2025-10-20T16:00:00Z',
type: 'data',
star: 5,
former: {
data: [
{
author: 'SNE Research',
sentences: '公司Q3动力电池装机量持续保持全球第一市场份额达到37.8%较去年同期提升2.3个百分点,在全球动力电池市场继续保持领先地位,主要得益于国内新能源汽车市场的强劲增长以及海外客户订单的持续放量',
query_part: '公司Q3动力电池装机量持续保持全球第一',
report_title: '全球动力电池市场装机量统计报告',
declare_date: '2025-10-10T00:00:00',
match_score: '好'
},
{
author: '宁德时代',
sentences: '储能业务订单饱满预计全年营收同比增长超过60%,公司储能产品已应用于全球多个大型储能项目,在用户侧储能、电网侧储能等领域均实现突破,随着全球能源转型加速,储能市场需求持续旺盛',
query_part: '储能业务订单饱满预计全年营收同比增长超过60%',
report_title: '宁德时代2024年业绩预告',
declare_date: '2025-09-30T00:00:00',
match_score: '好'
}
]
},
forecast: '预计营收和净利润双增长',
fact: null,
related_stocks: [
[
'300750',
'宁德时代',
{
data: [
{
author: '宁德时代公告',
sentences: '公司Q3动力电池装机量持续保持全球第一市场份额达到37.8%较去年同期提升2.3个百分点,在全球动力电池市场继续保持领先地位,主要得益于国内新能源汽车市场的强劲增长以及海外客户订单的持续放量',
query_part: '动力电池装机量全球第一市场份额37.8%',
report_title: '宁德时代2024年Q3业绩快报',
declare_date: '2025-10-15T00:00:00',
match_score: '优'
},
{
author: '国泰君安证券',
sentences: '储能业务订单饱满预计全年营收同比增长超过60%,公司储能产品已应用于全球多个大型储能项目,在用户侧储能、电网侧储能等领域均实现突破',
query_part: '储能业务营收同比增长超60%',
report_title: '宁德时代储能业务深度报告',
declare_date: '2025-10-12T00:00:00',
match_score: '优'
}
]
},
0.95
],
[
'002466',
'天齐锂业',
{
data: [
{
author: '天风证券',
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
query_part: '核心锂供应商直接受益于下游需求增长',
report_title: '天齐锂业:受益动力电池产业链景气',
declare_date: '2025-10-14T00:00:00',
match_score: '好'
},
{
author: '天风证券',
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
query_part: '核心锂供应商直接受益于下游需求增长',
report_title: '天齐锂业:受益动力电池产业链景气',
declare_date: '2025-10-14T00:00:00',
match_score: '好'
},
{
author: '天风证券',
sentences: '天齐锂业作为宁德时代的核心供应商,将直接受益于下游动力电池需求的增长,公司锂资源储量丰富,随着宁德时代产能扩张,锂盐需求持续旺盛,公司业绩增长确定性强',
query_part: '核心锂供应商直接受益于下游需求增长',
report_title: '天齐锂业:受益动力电池产业链景气',
declare_date: '2025-10-14T00:00:00',
match_score: '好'
}
]
},
0.82
]
],
concepts: ['新能源', '动力电池', '储能'],
is_following: false
}
];
export const mockCalendarEvents = [
{
id: 401,
user_id: 1,
title: '贵州茅台年报披露',
date: '2025-12-20',
event_date: '2025-12-20',
type: 'earnings',
category: 'financial_report',
description: '关注营收和净利润增速,以及渠道库存情况',
stock_code: '600519.SH',
stock_name: '贵州茅台',
importance: 5,
source: 'future',
stocks: ['600519'],
created_at: '2025-01-10T10:00:00Z'
},
{
id: 402,
user_id: 1,
title: '宁德时代业绩快报',
date: '2025-11-28',
event_date: '2025-11-28',
type: 'earnings',
category: 'financial_report',
description: '重点关注出货量和单位盈利情况',
stock_code: '300750.SZ',
stock_name: '宁德时代',
importance: 5,
source: 'future',
stocks: ['300750'],
created_at: '2025-01-12T14:00:00Z'
},
{
id: 403,
user_id: 1,
title: '央行货币政策委员会例会',
date: '2025-10-25',
event_date: '2025-10-25',
type: 'policy',
category: 'macro_policy',
description: '关注货币政策基调和利率调整信号',
importance: 4,
source: 'future',
stocks: [],
created_at: '2025-01-08T09:00:00Z'
},
{
id: 404,
user_id: 1,
title: '春节假期后首个交易日',
date: '2025-11-15',
event_date: '2025-11-15',
type: 'reminder',
category: 'trading',
description: '节后第一天,关注资金面和市场情绪',
importance: 3,
source: 'future',
stocks: [],
created_at: '2025-01-05T16:00:00Z'
},
{
id: 405,
user_id: 1,
title: '定投日 - 沪深300ETF',
date: '2025-10-20',
event_date: '2025-10-20',
type: 'reminder',
category: 'investment',
description: '每月20日定投3000元',
importance: 2,
source: 'user',
stocks: [],
is_recurring: true,
recurrence_rule: 'monthly',
created_at: '2024-12-15T10:00:00Z'
},
{
id: 406,
user_id: 1,
title: '美联储FOMC会议',
date: '2025-11-07',
event_date: '2025-11-07',
type: 'policy',
category: 'macro_policy',
description: '关注美联储利率决议和鲍威尔讲话',
importance: 5,
source: 'future',
stocks: [],
created_at: '2025-01-07T11:00:00Z'
},
{
id: 407,
user_id: 1,
title: '持仓股票复盘日',
date: '2025-10-26',
event_date: '2025-10-26',
type: 'reminder',
category: 'review',
description: '每周六进行持仓复盘和下周计划',
importance: 3,
source: 'user',
stocks: [],
is_recurring: true,
recurrence_rule: 'weekly',
created_at: '2025-01-01T10:00:00Z'
}
];
// ==================== 订阅信息数据 ====================
export const mockSubscriptionCurrent = {
type: 'pro',
status: 'active',
is_active: true,
days_left: 90,
end_date: '2025-04-15T23:59:59Z',
plan_name: 'Pro版',
features: [
'无限事件查看',
'实时行情推送',
'专业分析报告',
'优先客服支持',
'关联股票分析',
'历史事件对比'
],
price: 0.01,
currency: 'CNY',
billing_cycle: 'monthly',
auto_renew: true,
next_billing_date: '2025-02-15T00:00:00Z'
};
// ==================== 辅助函数 ====================
// 根据用户ID获取自选股
export function getWatchlistByUserId(userId) {
return mockWatchlist.filter(item => item.user_id === userId);
}
// 根据用户ID获取关注事件
export function getFollowingEventsByUserId(userId) {
return mockFollowingEvents;
}
// 根据用户ID获取评论
export function getCommentsByUserId(userId) {
return mockEventComments.filter(comment => comment.user_id === userId);
}
// 根据用户ID获取投资计划
export function getInvestmentPlansByUserId(userId) {
return mockInvestmentPlans.filter(plan => plan.user_id === userId);
}
// 根据用户ID获取日历事件
export function getCalendarEventsByUserId(userId) {
return mockCalendarEvents.filter(event => event.user_id === userId);
}
// 获取指定日期范围的日历事件
export function getCalendarEventsByDateRange(userId, startDate, endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
return mockCalendarEvents.filter(event => {
if (event.user_id !== userId) return false;
const eventDate = new Date(event.date);
return eventDate >= start && eventDate <= end;
});
}
// ==================== 未来事件(投资日历)辅助函数 ====================
/**
* 获取指定日期的未来事件列表
* @param {string} dateStr - 日期字符串 'YYYY-MM-DD'
* @param {string} type - 事件类型 'event' | 'data' | 'all'
* @returns {Array} 事件列表
*/
export function getMockFutureEvents(dateStr, type = 'all') {
const targetDate = new Date(dateStr);
return mockFutureEvents.filter(event => {
const eventDate = new Date(event.calendar_time);
const isSameDate =
eventDate.getFullYear() === targetDate.getFullYear() &&
eventDate.getMonth() === targetDate.getMonth() &&
eventDate.getDate() === targetDate.getDate();
if (!isSameDate) return false;
if (type === 'all') return true;
return event.type === type;
});
}
/**
* 获取指定月份的事件统计
* @param {number} year - 年份
* @param {number} month - 月份 (1-12)
* @returns {Array} 事件统计数组
*/
export function getMockEventCountsForMonth(year, month) {
const counts = {};
mockFutureEvents.forEach(event => {
const eventDate = new Date(event.calendar_time);
if (eventDate.getFullYear() === year && eventDate.getMonth() + 1 === month) {
const dateStr = eventDate.toISOString().split('T')[0];
counts[dateStr] = (counts[dateStr] || 0) + 1;
}
});
return Object.entries(counts).map(([date, count]) => ({
date,
count,
className: count >= 3 ? 'high-activity' : count >= 2 ? 'medium-activity' : 'low-activity'
}));
}

View File

@@ -545,3 +545,236 @@ export function getEventRelatedStocks(eventId) {
const count = 3 + (parseInt(eventId) % 4);
return generateRelatedStocks(eventId, count);
}
// ==================== Mock 事件列表数据 ====================
// 事件类型池
const eventTypes = ['政策发布', '行业动向', '公司公告', '市场研判', '技术突破', '财报发布', '投融资', '高管变动'];
// 行业池
const industries = ['半导体', '新能源', '人工智能', '医药', '消费', '金融', '房地产', '通信', '互联网', '军工', '化工', '机械'];
// 事件标题模板
const eventTitleTemplates = [
'{industry}行业迎来重大政策利好',
'{company}发布{quarter}财报,业绩超预期',
'{industry}板块集体大涨,{company}涨停',
'央行宣布{policy},影响{industry}行业',
'{company}与{partner}达成战略合作',
'{industry}技术取得重大突破',
'{company}拟投资{amount}亿元布局{industry}',
'国家发改委:支持{industry}产业发展',
'{industry}龙头{company}涨价{percent}%',
'{company}回购股份,彰显信心',
];
// 生成随机公司名
function generateCompanyName(industry) {
const prefixes = ['华为', '中兴', '阿里', '腾讯', '比亚迪', '宁德时代', '隆基', '恒瑞', '茅台', '五粮液', '海康', '中芯'];
const suffixes = ['科技', '集团', '股份', '控股', '实业', ''];
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${prefix}${suffix}`;
}
// 生成事件标题
function generateEventTitle(industry, seed) {
const template = eventTitleTemplates[seed % eventTitleTemplates.length];
return template
.replace('{industry}', industry)
.replace('{company}', generateCompanyName(industry))
.replace('{partner}', generateCompanyName(industry))
.replace('{quarter}', ['一季度', '半年度', '三季度', '年度'][seed % 4])
.replace('{policy}', ['降准0.5%', '降息25BP', 'MLF下调', '提高赤字率'][seed % 4])
.replace('{amount}', [50, 100, 200, 500][seed % 4])
.replace('{percent}', [5, 10, 15, 20][seed % 4]);
}
// 生成事件描述
function generateEventDescription(industry, importance, seed) {
const impacts = {
S: '重大利好预计将对行业格局产生深远影响相关概念股有望持续受益。机构预计该事件将带动行业整体估值提升15-20%,龙头企业市值增长空间广阔。',
A: '重要利好,市场情绪积极,短期内资金流入明显。分析师普遍认为该事件将推动行业景气度上行,相关公司业绩有望超预期增长。',
B: '中性偏好,对部分细分领域有一定促进作用。虽然不是行业性机会,但优质标的仍有结构性行情,建议关注业绩确定性强的公司。',
C: '影响有限,市场反应平淡,但长期来看仍有积极意义。事件对行业发展方向有指引作用,关注后续政策跟进和落地情况。',
};
const details = [
`根据最新消息,${industry}领域将获得新一轮政策支持,产业链相关企业订单饱满。`,
`${industry}板块近期表现活跃,多只个股创出年内新高,资金持续流入。`,
`行业专家指出,${industry}产业正处于高速发展期,市场空间广阔,龙头企业优势明显。`,
`券商研报显示,${industry}行业估值处于历史低位,当前具备较高配置价值。`,
];
return impacts[importance] + details[seed % details.length];
}
// 生成关键词
function generateKeywords(industry, seed) {
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
const industryKeywords = {
'半导体': ['芯片', '晶圆', '封测', 'AI芯片', '国产替代'],
'新能源': ['电池', '光伏', '储能', '新能源车', '锂电'],
'人工智能': ['大模型', 'AI应用', '算力', '数据', '机器学习'],
'医药': ['创新药', 'CRO', '医疗器械', '生物制药', '仿制药'],
'消费': ['白酒', '食品', '家电', '零售', '免税'],
};
const keywords = [
...commonKeywords.slice(seed % 3, seed % 3 + 3),
...(industryKeywords[industry] || []).slice(0, 2)
];
return keywords.slice(0, 5);
}
/**
* 生成 Mock 事件列表
* @param {Object} params - 查询参数
* @returns {Object} - {events: [], pagination: {}}
*/
export function generateMockEvents(params = {}) {
const {
page = 1,
per_page = 10,
sort = 'new',
importance = 'all',
date_range = '',
q = '',
industry_code = '',
} = params;
// 生成100个事件用于测试
const totalEvents = 100;
const allEvents = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
const baseDate = new Date('2025-01-15');
for (let i = 0; i < totalEvents; i++) {
const industry = industries[i % industries.length];
const imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length];
// 生成随机日期最近30天内
const createdAt = new Date(baseDate);
createdAt.setDate(createdAt.getDate() - (i % 30));
// 生成随机热度和收益率
const hotScore = Math.max(50, 100 - i);
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
allEvents.push({
id: i + 1,
title: generateEventTitle(industry, i),
description: generateEventDescription(industry, imp, i),
content: generateEventDescription(industry, imp, i),
event_type: eventType,
importance: imp,
status: 'published',
created_at: createdAt.toISOString(),
updated_at: createdAt.toISOString(),
hot_score: hotScore,
view_count: Math.floor(Math.random() * 10000),
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
keywords: generateKeywords(industry, i),
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
industry: industry,
});
}
// 筛选
let filteredEvents = allEvents;
// 重要性筛选
if (importance && importance !== 'all') {
filteredEvents = filteredEvents.filter(e => e.importance === importance);
}
// 关键词搜索
if (q) {
const query = q.toLowerCase();
filteredEvents = filteredEvents.filter(e =>
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
e.keywords.some(k => k.toLowerCase().includes(query))
);
}
// 行业筛选
if (industry_code) {
filteredEvents = filteredEvents.filter(e =>
e.industry.includes(industry_code) || e.keywords.includes(industry_code)
);
}
// 日期范围筛选
if (date_range) {
const [startStr, endStr] = date_range.split(' 至 ');
if (startStr && endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
filteredEvents = filteredEvents.filter(e => {
const eventDate = new Date(e.created_at);
return eventDate >= start && eventDate <= end;
});
}
}
// 排序
if (sort === 'hot') {
filteredEvents.sort((a, b) => b.hot_score - a.hot_score);
} else if (sort === 'returns') {
filteredEvents.sort((a, b) => b.related_avg_chg - a.related_avg_chg);
} else {
// 默认按时间排序 (new)
filteredEvents.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
// 分页
const start = (page - 1) * per_page;
const end = start + per_page;
const paginatedEvents = filteredEvents.slice(start, end);
return {
events: paginatedEvents,
pagination: {
page: page,
per_page: per_page,
total: filteredEvents.length,
total_pages: Math.ceil(filteredEvents.length / per_page),
},
};
}
/**
* 生成热点事件
* @param {number} limit - 返回数量
* @returns {Array} - 热点事件列表
*/
export function generateHotEvents(limit = 5) {
const { events } = generateMockEvents({ sort: 'hot', per_page: limit });
return events;
}
/**
* 生成热门关键词
* @param {number} limit - 返回数量
* @returns {Array} - 热门关键词列表
*/
export function generatePopularKeywords(limit = 20) {
const allKeywords = [
'人工智能', '芯片', '新能源', '锂电池', '光伏', '储能',
'消费', '白酒', '医药', 'CRO', '半导体', '国产替代',
'军工', '航空', '5G', '通信', '互联网', '云计算',
'大数据', '区块链', '元宇宙', '新基建', '数字经济',
];
return allKeywords.slice(0, limit).map((keyword, index) => ({
keyword,
count: Math.max(10, 100 - index * 3),
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
}));
}

View File

@@ -0,0 +1,554 @@
// src/mocks/data/industries.js
// 行业分类完整树形数据 Mock
/**
* 完整的行业分类树形结构
* 包含 5 个分类体系,层级深度 2-4 层不等
*/
export const industryTreeData = [
{
value: "新财富行业分类",
label: "新财富行业分类",
children: [
{
value: "XCF001",
label: "传播与文化",
children: [
{
value: "XCF001001",
label: "互联网传媒",
children: [
{ value: "XCF001001001", label: "数字媒体" },
{ value: "XCF001001002", label: "社交平台" },
{ value: "XCF001001003", label: "短视频平台" }
]
},
{
value: "XCF001002",
label: "影视娱乐",
children: [
{ value: "XCF001002001", label: "电影制作" },
{ value: "XCF001002002", label: "网络视频" }
]
},
{
value: "XCF001003",
label: "出版发行"
}
]
},
{
value: "XCF002",
label: "交通运输仓储",
children: [
{
value: "XCF002001",
label: "航空运输",
children: [
{ value: "XCF002001001", label: "航空客运" },
{ value: "XCF002001002", label: "航空货运" }
]
},
{
value: "XCF002002",
label: "铁路运输"
},
{
value: "XCF002003",
label: "公路运输",
children: [
{ value: "XCF002003001", label: "公路客运" },
{ value: "XCF002003002", label: "公路货运" },
{ value: "XCF002003003", label: "快递物流" }
]
}
]
},
{
value: "XCF003",
label: "农林牧渔",
children: [
{ value: "XCF003001", label: "种植业" },
{ value: "XCF003002", label: "林业" },
{ value: "XCF003003", label: "畜牧业" },
{ value: "XCF003004", label: "渔业" }
]
},
{
value: "XCF004",
label: "医药生物",
children: [
{
value: "XCF004001",
label: "化学制药",
children: [
{ value: "XCF004001001", label: "化学原料药" },
{ value: "XCF004001002", label: "化学制剂" }
]
},
{
value: "XCF004002",
label: "生物制品",
children: [
{ value: "XCF004002001", label: "疫苗" },
{ value: "XCF004002002", label: "血液制品" },
{ value: "XCF004002003", label: "诊断试剂" }
]
},
{ value: "XCF004003", label: "中药" },
{ value: "XCF004004", label: "医疗器械" }
]
},
{
value: "XCF005",
label: "基础化工",
children: [
{ value: "XCF005001", label: "化学原料" },
{ value: "XCF005002", label: "化学制品" },
{ value: "XCF005003", label: "塑料" },
{ value: "XCF005004", label: "橡胶" }
]
},
{
value: "XCF006",
label: "家电",
children: [
{ value: "XCF006001", label: "白色家电" },
{ value: "XCF006002", label: "黑色家电" },
{ value: "XCF006003", label: "小家电" }
]
},
{
value: "XCF007",
label: "电子",
children: [
{
value: "XCF007001",
label: "半导体",
children: [
{ value: "XCF007001001", label: "芯片设计" },
{ value: "XCF007001002", label: "芯片制造" },
{ value: "XCF007001003", label: "封装测试" }
]
},
{ value: "XCF007002", label: "元件" },
{ value: "XCF007003", label: "光学光电子" },
{ value: "XCF007004", label: "消费电子" }
]
},
{
value: "XCF008",
label: "计算机",
children: [
{
value: "XCF008001",
label: "计算机设备",
children: [
{ value: "XCF008001001", label: "PC" },
{ value: "XCF008001002", label: "服务器" }
]
},
{
value: "XCF008002",
label: "软件开发",
children: [
{ value: "XCF008002001", label: "应用软件" },
{ value: "XCF008002002", label: "系统软件" }
]
},
{ value: "XCF008003", label: "IT服务" }
]
}
]
},
{
value: "申银万国行业分类",
label: "申银万国行业分类",
children: [
{
value: "SW001",
label: "电子",
children: [
{
value: "SW001001",
label: "半导体",
children: [
{ value: "SW001001001", label: "半导体材料" },
{ value: "SW001001002", label: "半导体设备" },
{ value: "SW001001003", label: "集成电路" }
]
},
{
value: "SW001002",
label: "电子制造",
children: [
{ value: "SW001002001", label: "PCB" },
{ value: "SW001002002", label: "被动元件" }
]
},
{ value: "SW001003", label: "光学光电子" }
]
},
{
value: "SW002",
label: "计算机",
children: [
{ value: "SW002001", label: "计算机设备" },
{ value: "SW002002", label: "计算机应用" },
{ value: "SW002003", label: "通信设备" }
]
},
{
value: "SW003",
label: "传媒",
children: [
{ value: "SW003001", label: "互联网传媒" },
{ value: "SW003002", label: "营销传播" },
{ value: "SW003003", label: "文化传媒" }
]
},
{
value: "SW004",
label: "医药生物",
children: [
{ value: "SW004001", label: "化学制药" },
{ value: "SW004002", label: "中药" },
{ value: "SW004003", label: "生物制品" },
{ value: "SW004004", label: "医疗器械" },
{ value: "SW004005", label: "医药商业" }
]
},
{
value: "SW005",
label: "汽车",
children: [
{
value: "SW005001",
label: "乘用车",
children: [
{ value: "SW005001001", label: "燃油车" },
{ value: "SW005001002", label: "新能源车" }
]
},
{ value: "SW005002", label: "商用车" },
{ value: "SW005003", label: "汽车零部件" }
]
},
{
value: "SW006",
label: "机械设备",
children: [
{ value: "SW006001", label: "通用设备" },
{ value: "SW006002", label: "专用设备" },
{ value: "SW006003", label: "仪器仪表" }
]
},
{
value: "SW007",
label: "食品饮料",
children: [
{ value: "SW007001", label: "白酒" },
{ value: "SW007002", label: "啤酒" },
{ value: "SW007003", label: "软饮料" },
{ value: "SW007004", label: "食品加工" }
]
},
{
value: "SW008",
label: "银行",
children: [
{ value: "SW008001", label: "国有银行" },
{ value: "SW008002", label: "股份制银行" },
{ value: "SW008003", label: "城商行" }
]
},
{
value: "SW009",
label: "非银金融",
children: [
{ value: "SW009001", label: "证券" },
{ value: "SW009002", label: "保险" },
{ value: "SW009003", label: "多元金融" }
]
},
{
value: "SW010",
label: "房地产",
children: [
{ value: "SW010001", label: "房地产开发" },
{ value: "SW010002", label: "房地产服务" }
]
}
]
},
{
value: "证监会行业分类2001",
label: "证监会行业分类2001",
children: [
{
value: "CSRC_A",
label: "A 农、林、牧、渔业",
children: [
{ value: "CSRC_A01", label: "A01 农业" },
{ value: "CSRC_A02", label: "A02 林业" },
{ value: "CSRC_A03", label: "A03 畜牧业" },
{ value: "CSRC_A04", label: "A04 渔业" }
]
},
{
value: "CSRC_B",
label: "B 采矿业",
children: [
{ value: "CSRC_B06", label: "B06 煤炭开采和洗选业" },
{ value: "CSRC_B07", label: "B07 石油和天然气开采业" },
{ value: "CSRC_B08", label: "B08 黑色金属矿采选业" },
{ value: "CSRC_B09", label: "B09 有色金属矿采选业" }
]
},
{
value: "CSRC_C",
label: "C 制造业",
children: [
{
value: "CSRC_C13",
label: "C13 农副食品加工业",
children: [
{ value: "CSRC_C1310", label: "C1310 肉制品加工" },
{ value: "CSRC_C1320", label: "C1320 水产品加工" }
]
},
{
value: "CSRC_C27",
label: "C27 医药制造业",
children: [
{ value: "CSRC_C2710", label: "C2710 化学药品原料药制造" },
{ value: "CSRC_C2720", label: "C2720 化学药品制剂制造" },
{ value: "CSRC_C2730", label: "C2730 中药饮片加工" }
]
},
{ value: "CSRC_C35", label: "C35 专用设备制造业" },
{ value: "CSRC_C39", label: "C39 计算机、通信和其他电子设备制造业" }
]
},
{
value: "CSRC_I",
label: "I 信息传输、软件和信息技术服务业",
children: [
{ value: "CSRC_I63", label: "I63 电信、广播电视和卫星传输服务" },
{ value: "CSRC_I64", label: "I64 互联网和相关服务" },
{ value: "CSRC_I65", label: "I65 软件和信息技术服务业" }
]
},
{
value: "CSRC_J",
label: "J 金融业",
children: [
{ value: "CSRC_J66", label: "J66 货币金融服务" },
{ value: "CSRC_J67", label: "J67 资本市场服务" },
{ value: "CSRC_J68", label: "J68 保险业" }
]
},
{
value: "CSRC_K",
label: "K 房地产业",
children: [
{ value: "CSRC_K70", label: "K70 房地产业" }
]
}
]
},
{
value: "中银国际行业分类",
label: "中银国际行业分类",
children: [
{
value: "BOC001",
label: "能源",
children: [
{ value: "BOC001001", label: "石油天然气" },
{ value: "BOC001002", label: "煤炭" },
{ value: "BOC001003", label: "新能源" }
]
},
{
value: "BOC002",
label: "原材料",
children: [
{ value: "BOC002001", label: "化工" },
{ value: "BOC002002", label: "钢铁" },
{ value: "BOC002003", label: "有色金属" },
{ value: "BOC002004", label: "建材" }
]
},
{
value: "BOC003",
label: "工业",
children: [
{ value: "BOC003001", label: "机械" },
{ value: "BOC003002", label: "电气设备" },
{ value: "BOC003003", label: "国防军工" }
]
},
{
value: "BOC004",
label: "消费",
children: [
{
value: "BOC004001",
label: "可选消费",
children: [
{ value: "BOC004001001", label: "汽车" },
{ value: "BOC004001002", label: "家电" },
{ value: "BOC004001003", label: "纺织服装" }
]
},
{
value: "BOC004002",
label: "必需消费",
children: [
{ value: "BOC004002001", label: "食品饮料" },
{ value: "BOC004002002", label: "农林牧渔" }
]
}
]
},
{
value: "BOC005",
label: "医疗保健",
children: [
{ value: "BOC005001", label: "医药" },
{ value: "BOC005002", label: "医疗器械" },
{ value: "BOC005003", label: "医疗服务" }
]
},
{
value: "BOC006",
label: "金融",
children: [
{ value: "BOC006001", label: "银行" },
{ value: "BOC006002", label: "非银金融" }
]
},
{
value: "BOC007",
label: "科技",
children: [
{
value: "BOC007001",
label: "信息技术",
children: [
{ value: "BOC007001001", label: "半导体" },
{ value: "BOC007001002", label: "电子" },
{ value: "BOC007001003", label: "计算机" },
{ value: "BOC007001004", label: "通信" }
]
},
{ value: "BOC007002", label: "传媒" }
]
}
]
},
{
value: "巨潮行业分类",
label: "巨潮行业分类",
children: [
{
value: "JC01",
label: "制造业",
children: [
{
value: "JC0101",
label: "电气机械及器材制造业",
children: [
{ value: "JC010101", label: "电机制造" },
{ value: "JC010102", label: "输配电及控制设备制造" },
{ value: "JC010103", label: "电池制造" }
]
},
{
value: "JC0102",
label: "医药制造业",
children: [
{ value: "JC010201", label: "化学药品原药制造" },
{ value: "JC010202", label: "化学药品制剂制造" },
{ value: "JC010203", label: "中成药制造" },
{ value: "JC010204", label: "生物、生化制品制造" }
]
},
{ value: "JC0103", label: "食品制造业" },
{ value: "JC0104", label: "纺织业" }
]
},
{
value: "JC02",
label: "信息传输、软件和信息技术服务业",
children: [
{ value: "JC0201", label: "互联网和相关服务" },
{ value: "JC0202", label: "软件和信息技术服务业" }
]
},
{
value: "JC03",
label: "批发和零售业",
children: [
{ value: "JC0301", label: "批发业" },
{ value: "JC0302", label: "零售业" }
]
},
{
value: "JC04",
label: "房地产业",
children: [
{ value: "JC0401", label: "房地产开发经营" },
{ value: "JC0402", label: "物业管理" }
]
},
{
value: "JC05",
label: "金融业",
children: [
{ value: "JC0501", label: "货币金融服务" },
{ value: "JC0502", label: "资本市场服务" },
{ value: "JC0503", label: "保险业" }
]
},
{
value: "JC06",
label: "交通运输、仓储和邮政业",
children: [
{ value: "JC0601", label: "道路运输业" },
{ value: "JC0602", label: "航空运输业" },
{ value: "JC0603", label: "水上运输业" }
]
},
{
value: "JC07",
label: "采矿业",
children: [
{ value: "JC0701", label: "煤炭开采和洗选业" },
{ value: "JC0702", label: "石油和天然气开采业" },
{ value: "JC0703", label: "有色金属矿采选业" }
]
},
{
value: "JC08",
label: "农、林、牧、渔业",
children: [
{ value: "JC0801", label: "农业" },
{ value: "JC0802", label: "林业" },
{ value: "JC0803", label: "畜牧业" },
{ value: "JC0804", label: "渔业" }
]
},
{
value: "JC09",
label: "建筑业",
children: [
{ value: "JC0901", label: "房屋建筑业" },
{ value: "JC0902", label: "土木工程建筑业" },
{ value: "JC0903", label: "建筑装饰和其他建筑业" }
]
}
]
}
];

View File

@@ -91,8 +91,18 @@ export function generateWechatSessionId() {
// 设置当前登录用户
export function setCurrentUser(user) {
if (user) {
localStorage.setItem('mock_current_user', JSON.stringify(user));
console.log('[Mock State] 设置当前登录用户:', user);
// 数据兼容处理:确保用户数据包含订阅信息字段
const normalizedUser = {
...user,
// 如果缺少订阅信息,添加默认值
subscription_type: user.subscription_type || 'free',
subscription_status: user.subscription_status || 'active',
subscription_end_date: user.subscription_end_date || null,
is_subscription_active: user.is_subscription_active !== false,
subscription_days_left: user.subscription_days_left || 0
};
localStorage.setItem('mock_current_user', JSON.stringify(normalizedUser));
console.log('[Mock State] 设置当前登录用户:', normalizedUser);
}
}

View File

@@ -1,248 +1,700 @@
// src/mocks/handlers/account.js
import { http, HttpResponse, delay } from 'msw';
import { getCurrentUser } from '../data/users';
import {
mockWatchlist,
mockRealtimeQuotes,
mockFollowingEvents,
mockEventComments,
mockInvestmentPlans,
mockCalendarEvents,
mockSubscriptionCurrent,
getCalendarEventsByDateRange
} from '../data/account';
// 模拟网络延迟(毫秒)
const NETWORK_DELAY = 300;
export const accountHandlers = [
// ==================== 用户资料管理 ====================
// ==================== 用户资料管理 ====================
// 1. 获取资料完整度
http.get('/api/account/profile-completeness', async () => {
await delay(NETWORK_DELAY);
// 1. 获取资料完整度
http.get('/api/account/profile-completeness', async () => {
await delay(NETWORK_DELAY);
// 获取当前登录用户
const currentUser = getCurrentUser();
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '用户未登录'
}, { status: 401 });
}
// 如果没有登录,返回 401
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '用户未登录'
}, { status: 401 });
console.log('[Mock] 获取资料完整度:', currentUser);
const isWechatUser = currentUser.has_wechat || !!currentUser.wechat_openid;
const completeness = {
hasPassword: !!currentUser.password_hash || !isWechatUser,
hasPhone: !!currentUser.phone,
hasEmail: !!currentUser.email && currentUser.email.includes('@') && !currentUser.email.endsWith('@valuefrontier.temp'),
isWechatUser: isWechatUser
};
const totalItems = 3;
const completedItems = [completeness.hasPassword, completeness.hasPhone, completeness.hasEmail].filter(Boolean).length;
const completenessPercentage = Math.round((completedItems / totalItems) * 100);
let needsAttention = false;
const missingItems = [];
if (isWechatUser && completenessPercentage < 100) {
needsAttention = true;
if (!completeness.hasPassword) missingItems.push('登录密码');
if (!completeness.hasPhone) missingItems.push('手机号');
if (!completeness.hasEmail) missingItems.push('邮箱');
}
const result = {
success: true,
data: {
completeness,
completenessPercentage,
needsAttention,
missingItems,
isComplete: completedItems === totalItems,
showReminder: needsAttention
}
};
console.log('[Mock] 资料完整度结果:', result.data);
return HttpResponse.json(result);
}),
// 2. 更新用户资料
http.put('/api/account/profile', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '用户未登录'
}, { status: 401 });
}
const body = await request.json();
console.log('[Mock] 更新用户资料:', body);
Object.assign(currentUser, body);
return HttpResponse.json({
success: true,
message: '资料更新成功',
data: currentUser
});
}),
// 3. 获取用户资料
http.get('/api/account/profile', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '用户未登录'
}, { status: 401 });
}
console.log('[Mock] 获取用户资料:', currentUser);
return HttpResponse.json({
success: true,
data: currentUser
});
}),
// ==================== 自选股管理 ====================
// 4. 获取自选股列表
http.get('/api/account/watchlist', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取自选股列表');
return HttpResponse.json({
success: true,
data: mockWatchlist
});
}),
// 5. 获取自选股实时行情
http.get('/api/account/watchlist/realtime', async () => {
await delay(200);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取自选股实时行情');
return HttpResponse.json({
success: true,
data: mockRealtimeQuotes
});
}),
// 6. 添加自选股
http.post('/api/account/watchlist/add', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const body = await request.json();
const { stock_code, stock_name } = body;
console.log('[Mock] 添加自选股:', { stock_code, stock_name });
const newItem = {
id: mockWatchlist.length + 1,
user_id: currentUser.id,
stock_code,
stock_name,
added_at: new Date().toISOString(),
industry: '未知',
current_price: null,
change_percent: null
};
mockWatchlist.push(newItem);
return HttpResponse.json({
success: true,
message: '添加成功',
data: newItem
});
}),
// 7. 删除自选股
http.delete('/api/account/watchlist/:id', async ({ params }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const { id } = params;
console.log('[Mock] 删除自选股:', id);
const index = mockWatchlist.findIndex(item => item.id === parseInt(id));
if (index !== -1) {
mockWatchlist.splice(index, 1);
}
return HttpResponse.json({
success: true,
message: '删除成功'
});
}),
// ==================== 事件关注管理 ====================
// 8. 获取关注的事件
http.get('/api/account/events/following', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取关注的事件');
return HttpResponse.json({
success: true,
data: mockFollowingEvents
});
}),
// 9. 获取事件评论
http.get('/api/account/events/comments', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取事件评论');
return HttpResponse.json({
success: true,
data: mockEventComments
});
}),
// ==================== 投资计划与复盘 ====================
// 10. 获取投资计划列表
http.get('/api/account/investment-plans', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取投资计划列表');
return HttpResponse.json({
success: true,
data: mockInvestmentPlans
});
}),
// 11. 创建投资计划
http.post('/api/account/investment-plans', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const body = await request.json();
console.log('[Mock] 创建投资计划:', body);
const newPlan = {
id: mockInvestmentPlans.length + 301,
user_id: currentUser.id,
...body,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
mockInvestmentPlans.push(newPlan);
return HttpResponse.json({
success: true,
message: '创建成功',
data: newPlan
});
}),
// 12. 更新投资计划
http.put('/api/account/investment-plans/:id', async ({ request, params }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const { id } = params;
const body = await request.json();
console.log('[Mock] 更新投资计划:', { id, body });
const index = mockInvestmentPlans.findIndex(plan => plan.id === parseInt(id));
if (index !== -1) {
mockInvestmentPlans[index] = {
...mockInvestmentPlans[index],
...body,
updated_at: new Date().toISOString()
};
return HttpResponse.json({
success: true,
message: '更新成功',
data: mockInvestmentPlans[index]
});
}
return HttpResponse.json({
success: false,
error: '计划不存在'
}, { status: 404 });
}),
// 13. 删除投资计划
http.delete('/api/account/investment-plans/:id', async ({ params }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const { id } = params;
console.log('[Mock] 删除投资计划:', id);
const index = mockInvestmentPlans.findIndex(plan => plan.id === parseInt(id));
if (index !== -1) {
mockInvestmentPlans.splice(index, 1);
}
return HttpResponse.json({
success: true,
message: '删除成功'
});
}),
// ==================== 投资日历 ====================
// 14. 获取日历事件(可选日期范围)- 合并投资计划和日历事件
http.get('/api/account/calendar/events', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const url = new URL(request.url);
const startDate = url.searchParams.get('start_date');
const endDate = url.searchParams.get('end_date');
console.log('[Mock] 获取日历事件:', { startDate, endDate });
// 1. 获取日历事件
let calendarEvents = mockCalendarEvents;
if (startDate && endDate) {
calendarEvents = getCalendarEventsByDateRange(currentUser.id, startDate, endDate);
}
// 2. 获取投资计划和复盘,转换为日历事件格式
const investmentPlansAsEvents = mockInvestmentPlans
.filter(plan => plan.user_id === currentUser.id)
.map(plan => ({
id: plan.id,
user_id: plan.user_id,
title: plan.title,
date: plan.target_date || plan.date,
event_date: plan.target_date || plan.date,
type: plan.type, // 'plan' or 'review'
category: plan.type === 'plan' ? 'investment_plan' : 'investment_review',
description: plan.content || '',
importance: 3, // 默认重要度
source: 'user', // 标记为用户创建
stocks: plan.stocks || [],
tags: plan.tags || [],
status: plan.status,
created_at: plan.created_at,
updated_at: plan.updated_at
}));
// 3. 合并两个数据源
const allEvents = [...calendarEvents, ...investmentPlansAsEvents];
// 4. 如果提供了日期范围,对合并后的数据进行过滤
let filteredEvents = allEvents;
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
filteredEvents = allEvents.filter(event => {
const eventDate = new Date(event.date || event.event_date);
return eventDate >= start && eventDate <= end;
});
}
console.log('[Mock] 合并后的日历事件数量:', {
calendarEvents: calendarEvents.length,
investmentPlansAsEvents: investmentPlansAsEvents.length,
total: filteredEvents.length
});
return HttpResponse.json({
success: true,
data: filteredEvents
});
}),
// 15. 创建日历事件
http.post('/api/account/calendar/events', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const body = await request.json();
console.log('[Mock] 创建日历事件:', body);
const newEvent = {
id: mockCalendarEvents.length + 401,
user_id: currentUser.id,
...body,
source: 'user', // 用户创建的事件标记为 'user'
created_at: new Date().toISOString()
};
mockCalendarEvents.push(newEvent);
return HttpResponse.json({
success: true,
message: '创建成功',
data: newEvent
});
}),
// 16. 更新日历事件
http.put('/api/account/calendar/events/:id', async ({ request, params }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const { id } = params;
const body = await request.json();
console.log('[Mock] 更新日历事件:', { id, body });
const index = mockCalendarEvents.findIndex(event => event.id === parseInt(id));
if (index !== -1) {
mockCalendarEvents[index] = {
...mockCalendarEvents[index],
...body
};
return HttpResponse.json({
success: true,
message: '更新成功',
data: mockCalendarEvents[index]
});
}
return HttpResponse.json({
success: false,
error: '事件不存在'
}, { status: 404 });
}),
// 17. 删除日历事件
http.delete('/api/account/calendar/events/:id', async ({ params }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const { id } = params;
console.log('[Mock] 删除日历事件:', id);
const index = mockCalendarEvents.findIndex(event => event.id === parseInt(id));
if (index !== -1) {
mockCalendarEvents.splice(index, 1);
}
return HttpResponse.json({
success: true,
message: '删除成功'
});
}),
// ==================== 订阅信息 ====================
// 18. 获取订阅信息
http.get('/api/subscription/info', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: true,
data: {
type: 'free',
status: 'active',
is_active: true,
days_left: 0,
end_date: null
}
});
}
console.log('[Mock] 获取资料完整度:', currentUser);
console.log('[Mock] 获取订阅信息:', currentUser);
// 检查用户是否是微信用户
const isWechatUser = currentUser.has_wechat || !!currentUser.wechat_openid;
const subscriptionInfo = {
type: currentUser.subscription_type || 'free',
status: currentUser.subscription_status || 'active',
is_active: currentUser.is_subscription_active !== false,
days_left: currentUser.subscription_days_left || 0,
end_date: currentUser.subscription_end_date || null
};
// 检查各项信息
const completeness = {
hasPassword: !!currentUser.password_hash || !isWechatUser, // 非微信用户默认有密码
hasPhone: !!currentUser.phone,
hasEmail: !!currentUser.email && currentUser.email.includes('@') && !currentUser.email.endsWith('@valuefrontier.temp'),
isWechatUser: isWechatUser
};
console.log('[Mock] 订阅信息结果:', subscriptionInfo);
// 计算完整度
const totalItems = 3;
const completedItems = [completeness.hasPassword, completeness.hasPhone, completeness.hasEmail].filter(Boolean).length;
const completenessPercentage = Math.round((completedItems / totalItems) * 100);
return HttpResponse.json({
success: true,
data: subscriptionInfo
});
}),
// 智能判断是否需要提醒
let needsAttention = false;
const missingItems = [];
// 19. 获取当前订阅详情
http.get('/api/subscription/current', async () => {
await delay(NETWORK_DELAY);
// Mock 模式下,对微信用户进行简化判断
if (isWechatUser && completenessPercentage < 100) {
needsAttention = true;
if (!completeness.hasPassword) {
missingItems.push('登录密码');
}
if (!completeness.hasPhone) {
missingItems.push('手机号');
}
if (!completeness.hasEmail) {
missingItems.push('邮箱');
}
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取当前订阅详情');
// 基于当前用户的订阅类型返回详情
const userSubscriptionType = (currentUser.subscription_type || 'free').toLowerCase();
const subscriptionDetails = {
...mockSubscriptionCurrent,
type: userSubscriptionType,
status: currentUser.subscription_status || 'active',
is_active: currentUser.is_subscription_active !== false,
days_left: currentUser.subscription_days_left || 0,
end_date: currentUser.subscription_end_date || null
};
return HttpResponse.json({
success: true,
data: subscriptionDetails
});
}),
// 20. 获取订阅权限
http.get('/api/subscription/permissions', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: true,
data: {
permissions: {
'related_stocks': false,
'related_concepts': false,
'transmission_chain': false,
'historical_events': 'limited',
'concept_html_detail': false,
'concept_stats_panel': false,
'concept_related_stocks': false,
'concept_timeline': false,
'hot_stocks': false
}
}
});
}
const result = {
success: true,
data: {
completeness,
completenessPercentage,
needsAttention,
missingItems,
isComplete: completedItems === totalItems,
showReminder: needsAttention
}
};
const subscriptionType = (currentUser.subscription_type || 'free').toLowerCase();
console.log('[Mock] 资料完整度结果:', result.data);
let permissions = {};
return HttpResponse.json(result);
}),
if (subscriptionType === 'free') {
permissions = {
'related_stocks': false,
'related_concepts': false,
'transmission_chain': false,
'historical_events': 'limited',
'concept_html_detail': false,
'concept_stats_panel': false,
'concept_related_stocks': false,
'concept_timeline': false,
'hot_stocks': false
};
} else if (subscriptionType === 'pro') {
permissions = {
'related_stocks': true,
'related_concepts': true,
'transmission_chain': false,
'historical_events': 'full',
'concept_html_detail': true,
'concept_stats_panel': true,
'concept_related_stocks': true,
'concept_timeline': false,
'hot_stocks': true
};
} else if (subscriptionType === 'max') {
permissions = {
'related_stocks': true,
'related_concepts': true,
'transmission_chain': true,
'historical_events': 'full',
'concept_html_detail': true,
'concept_stats_panel': true,
'concept_related_stocks': true,
'concept_timeline': true,
'hot_stocks': true
};
}
// 2. 更新用户资料
http.put('/api/account/profile', async ({ request }) => {
await delay(NETWORK_DELAY);
console.log('[Mock] 订阅权限:', { subscriptionType, permissions });
// 获取当前登录用户
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '用户未登录'
}, { status: 401 });
}
const body = await request.json();
console.log('[Mock] 更新用户资料:', body);
// 在 Mock 模式下,我们直接更新当前用户对象(实际应该调用 setCurrentUser
Object.assign(currentUser, body);
return HttpResponse.json({
success: true,
message: '资料更新成功',
data: currentUser
});
}),
// 3. 获取用户资料
http.get('/api/account/profile', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '用户未登录'
}, { status: 401 });
}
console.log('[Mock] 获取用户资料:', currentUser);
return HttpResponse.json({
success: true,
data: currentUser
});
}),
// ==================== 订阅管理 ====================
// 4. 获取订阅信息
http.get('/api/subscription/info', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
// 未登录时返回免费用户信息
if (!currentUser) {
return HttpResponse.json({
success: true,
data: {
type: 'free',
status: 'active',
is_active: true,
days_left: 0,
end_date: null
}
});
}
console.log('[Mock] 获取订阅信息:', currentUser);
// 从当前用户对象中获取订阅信息
const subscriptionInfo = {
type: currentUser.subscription_type || 'free',
status: currentUser.subscription_status || 'active',
is_active: currentUser.is_subscription_active !== false,
days_left: currentUser.subscription_days_left || 0,
end_date: currentUser.subscription_end_date || null
};
console.log('[Mock] 订阅信息结果:', subscriptionInfo);
return HttpResponse.json({
success: true,
data: subscriptionInfo
});
}),
// 5. 获取订阅权限
http.get('/api/subscription/permissions', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
// 未登录时返回免费权限
if (!currentUser) {
return HttpResponse.json({
success: true,
data: {
permissions: {
'related_stocks': false,
'related_concepts': false,
'transmission_chain': false,
'historical_events': 'limited',
'concept_html_detail': false,
'concept_stats_panel': false,
'concept_related_stocks': false,
'concept_timeline': false,
'hot_stocks': false
}
}
});
}
const subscriptionType = (currentUser.subscription_type || 'free').toLowerCase();
// 根据订阅类型返回对应权限
let permissions = {};
if (subscriptionType === 'free') {
permissions = {
'related_stocks': false,
'related_concepts': false,
'transmission_chain': false,
'historical_events': 'limited',
'concept_html_detail': false,
'concept_stats_panel': false,
'concept_related_stocks': false,
'concept_timeline': false,
'hot_stocks': false
};
} else if (subscriptionType === 'pro') {
permissions = {
'related_stocks': true,
'related_concepts': true,
'transmission_chain': false,
'historical_events': 'full',
'concept_html_detail': true,
'concept_stats_panel': true,
'concept_related_stocks': true,
'concept_timeline': false,
'hot_stocks': true
};
} else if (subscriptionType === 'max') {
permissions = {
'related_stocks': true,
'related_concepts': true,
'transmission_chain': true,
'historical_events': 'full',
'concept_html_detail': true,
'concept_stats_panel': true,
'concept_related_stocks': true,
'concept_timeline': true,
'hot_stocks': true
};
}
console.log('[Mock] 订阅权限:', { subscriptionType, permissions });
return HttpResponse.json({
success: true,
data: {
subscription_type: subscriptionType,
permissions
}
});
})
return HttpResponse.json({
success: true,
data: {
subscription_type: subscriptionType,
permissions
}
});
}),
];

View File

@@ -94,7 +94,13 @@ export const authHandlers = [
email: null,
avatar_url: `https://i.pravatar.cc/150?img=${id}`,
has_wechat: false,
created_at: new Date().toISOString()
created_at: new Date().toISOString(),
// 默认订阅信息 - 免费用户
subscription_type: 'free',
subscription_status: 'active',
subscription_end_date: null,
is_subscription_active: true,
subscription_days_left: 0
};
mockUsers[credential] = user;
console.log('[Mock] 创建新用户:', user);
@@ -134,14 +140,14 @@ export const authHandlers = [
console.log('[Mock] 生成微信二维码:', { sessionId, authUrl });
// 10秒后自动模拟扫码(方便测试)
// 3秒后自动模拟扫码(方便测试,已缩短延迟
setTimeout(() => {
const session = mockWechatSessions.get(sessionId);
if (session && session.status === 'waiting') {
session.status = 'scanned';
console.log(`[Mock] 模拟用户扫码: ${sessionId}`);
// 再过5秒自动确认登录
// 再过2秒自动确认登录
setTimeout(() => {
const session2 = mockWechatSessions.get(sessionId);
if (session2 && session2.status === 'scanned') {
@@ -154,13 +160,19 @@ export const authHandlers = [
phone: null,
email: null,
has_wechat: true,
created_at: new Date().toISOString()
created_at: new Date().toISOString(),
// 添加默认订阅信息
subscription_type: 'free',
subscription_status: 'active',
subscription_end_date: null,
is_subscription_active: true,
subscription_days_left: 0
};
console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user);
}
}, 5000);
}, 2000);
}
}, 10000);
}, 3000);
return HttpResponse.json({
code: 0,
@@ -328,3 +340,90 @@ export const authHandlers = [
});
})
];
// ==================== Mock 调试工具(仅开发环境) ====================
/**
* 暴露全局API方便手动触发微信扫码模拟
* 使用方式:
* 1. 浏览器控制台输入window.mockWechatScan()
* 2. 或者在组件中调用window.mockWechatScan(sessionId)
*/
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
window.mockWechatScan = (sessionId) => {
// 如果没有传入sessionId尝试获取最新的session
let targetSessionId = sessionId;
if (!targetSessionId) {
// 获取最新创建的session
const sessions = Array.from(mockWechatSessions.entries());
if (sessions.length === 0) {
console.warn('[Mock API] 没有活跃的微信session请先获取二维码');
return false;
}
// 按创建时间排序,获取最新的
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
targetSessionId = latestSession[0];
}
const session = mockWechatSessions.get(targetSessionId);
if (!session) {
console.error('[Mock API] Session不存在:', targetSessionId);
return false;
}
if (session.status !== 'waiting') {
console.warn('[Mock API] Session状态不是waiting当前状态:', session.status);
return false;
}
// 立即触发扫码
session.status = 'scanned';
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
// 1秒后自动确认登录
setTimeout(() => {
const session2 = mockWechatSessions.get(targetSessionId);
if (session2 && session2.status === 'scanned') {
session2.status = 'confirmed';
session2.user = {
id: 999,
nickname: '微信测试用户',
wechat_openid: 'mock_openid_' + targetSessionId,
avatar_url: 'https://i.pravatar.cc/150?img=99',
phone: null,
email: null,
has_wechat: true,
created_at: new Date().toISOString(),
subscription_type: 'free',
subscription_status: 'active',
subscription_end_date: null,
is_subscription_active: true,
subscription_days_left: 0
};
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
}
}, 1000);
return true;
};
// 暴露获取当前sessions的方法调试用
window.getMockWechatSessions = () => {
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
sessionId: id,
status: session.status,
createdAt: new Date(session.createdAt).toLocaleString(),
hasUser: !!session.user
}));
console.table(sessions);
return sessions;
};
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
console.log('%c使用方法:', 'color: #666');
console.log(' window.mockWechatScan() - 触发最新session的扫码');
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
}

View File

@@ -2,12 +2,116 @@
// 事件相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { getEventRelatedStocks } from '../data/events';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
export const eventHandlers = [
// ==================== 事件列表相关 ====================
// 获取事件列表
http.get('/api/events/', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const params = {
page: parseInt(url.searchParams.get('page') || '1'),
per_page: parseInt(url.searchParams.get('per_page') || '10'),
sort: url.searchParams.get('sort') || 'new',
importance: url.searchParams.get('importance') || 'all',
date_range: url.searchParams.get('date_range') || '',
q: url.searchParams.get('q') || '',
industry_code: url.searchParams.get('industry_code') || '',
industry_classification: url.searchParams.get('industry_classification') || '',
};
console.log('[Mock] 获取事件列表:', params);
try {
const result = generateMockEvents(params);
return HttpResponse.json({
success: true,
data: result,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取事件列表失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取事件列表失败',
data: { events: [], pagination: {} }
},
{ status: 500 }
);
}
}),
// 获取热点事件
http.get('/api/events/hot', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '5');
console.log('[Mock] 获取热点事件, limit:', limit);
try {
const hotEvents = generateHotEvents(limit);
return HttpResponse.json({
success: true,
data: hotEvents,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取热点事件失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取热点事件失败',
data: []
},
{ status: 500 }
);
}
}),
// 获取热门关键词
http.get('/api/events/keywords/popular', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
console.log('[Mock] 获取热门关键词, limit:', limit);
try {
const keywords = generatePopularKeywords(limit);
return HttpResponse.json({
success: true,
data: keywords,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取热门关键词失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取热门关键词失败',
data: []
},
{ status: 500 }
);
}
}),
// ==================== 事件详情相关 ====================
// 获取事件相关股票
http.get('/api/events/:eventId/stocks', async ({ params }) => {
await delay(300);
@@ -36,4 +140,786 @@ export const eventHandlers = [
);
}
}),
// 获取事件传导链分析数据
http.get('/api/events/:eventId/transmission', async ({ params }) => {
await delay(500);
const { eventId } = params;
console.log('[Mock] 获取事件传导链分析, eventId:', eventId);
// Mock数据事件传导链
const mockTransmissionData = {
success: true,
data: {
nodes: [
{
id: '1',
name: '主要事件',
category: '事件',
value: 50,
extra: {
node_type: 'event',
description: '这是主要事件节点',
importance_score: 50,
is_main_event: true
}
},
{
id: '2',
name: '半导体行业',
category: '行业',
value: 40,
extra: {
node_type: 'industry',
description: '受影响的半导体行业',
importance_score: 40,
is_main_event: false
}
},
{
id: '3',
name: '芯片制造',
category: '行业',
value: 35,
extra: {
node_type: 'industry',
description: '芯片制造产业链',
importance_score: 35,
is_main_event: false
}
},
{
id: '4',
name: 'A公司',
category: '公司',
value: 30,
extra: {
node_type: 'company',
description: '龙头企业A',
importance_score: 30,
stock_code: '600000',
is_main_event: false
}
},
{
id: '5',
name: 'B公司',
category: '公司',
value: 25,
extra: {
node_type: 'company',
description: '龙头企业B',
importance_score: 25,
stock_code: '600001',
is_main_event: false
}
},
{
id: '6',
name: '相关政策',
category: '政策',
value: 30,
extra: {
node_type: 'policy',
description: '国家产业政策支持',
importance_score: 30,
is_main_event: false
}
}
],
edges: [
{
source: '1',
target: '2',
value: 0.8,
extra: {
transmission_strength: 0.8,
transmission_type: '直接影响',
description: '主事件对半导体行业的直接影响'
}
},
{
source: '2',
target: '3',
value: 0.7,
extra: {
transmission_strength: 0.7,
transmission_type: '产业链传导',
description: '半导体到芯片制造的传导'
}
},
{
source: '3',
target: '4',
value: 0.6,
extra: {
transmission_strength: 0.6,
transmission_type: '企业影响',
description: '对龙头企业A的影响'
}
},
{
source: '3',
target: '5',
value: 0.5,
extra: {
transmission_strength: 0.5,
transmission_type: '企业影响',
description: '对龙头企业B的影响'
}
},
{
source: '6',
target: '1',
value: 0.7,
extra: {
transmission_strength: 0.7,
transmission_type: '政策驱动',
description: '政策对主事件的推动作用'
}
},
{
source: '6',
target: '2',
value: 0.6,
extra: {
transmission_strength: 0.6,
transmission_type: '政策支持',
description: '政策对行业的支持'
}
}
],
categories: ['事件', '行业', '公司', '政策', '技术', '市场', '其他']
},
message: '获取成功'
};
return HttpResponse.json(mockTransmissionData);
}),
// 获取桑基图数据
http.get('/api/events/:eventId/sankey-data', async ({ params }) => {
await delay(300);
const { eventId } = params;
console.log('[Mock] 获取桑基图数据, eventId:', eventId);
const mockSankeyData = {
success: true,
data: {
nodes: [
{
name: '相关政策',
type: 'policy',
level: 0,
color: '#10ac84'
},
{
name: '主要事件',
type: 'event',
level: 0,
color: '#ff4757'
},
{
name: '半导体行业',
type: 'industry',
level: 1,
color: '#00d2d3'
},
{
name: '芯片制造',
type: 'industry',
level: 2,
color: '#00d2d3'
},
{
name: 'A公司',
type: 'company',
level: 3,
color: '#54a0ff'
},
{
name: 'B公司',
type: 'company',
level: 3,
color: '#54a0ff'
}
],
links: [
{ source: 0, target: 1, value: 7 }, // 相关政策 -> 主要事件
{ source: 0, target: 2, value: 6 }, // 相关政策 -> 半导体行业
{ source: 1, target: 2, value: 8 }, // 主要事件 -> 半导体行业
{ source: 2, target: 3, value: 7 }, // 半导体行业 -> 芯片制造
{ source: 3, target: 4, value: 6 }, // 芯片制造 -> A公司
{ source: 3, target: 5, value: 5 } // 芯片制造 -> B公司
]
},
message: '获取成功'
};
return HttpResponse.json(mockSankeyData);
}),
// 获取传导链节点详情
http.get('/api/events/:eventId/chain-node/:nodeId', async ({ params }) => {
await delay(300);
const { eventId, nodeId } = params;
console.log('[Mock] 获取节点详情, eventId:', eventId, 'nodeId:', nodeId);
// 根据节点ID返回不同的详细信息
const nodeDetailsMap = {
'1': {
success: true,
data: {
node: {
id: '1',
name: '主要事件',
type: 'event',
description: '这是影响整个产业链的重大事件,涉及政策调整和技术突破,对下游产业产生深远影响。',
importance_score: 50,
total_connections: 2,
incoming_connections: 1,
outgoing_connections: 1
},
parents: [
{
id: '6',
name: '相关政策',
transmission_mechanism: {
data: [
{
author: "国务院",
sentences: "为加快实施创新驱动发展战略推动产业转型升级国家将对重点领域给予财政补贴支持单个项目最高补贴金额可达5000万元同时享受研发费用加计扣除175%的税收优惠政策",
query_part: "国家财政补贴最高5000万元研发费用加计扣除175%",
match_score: "好",
declare_date: "2024-01-15T00:00:00",
report_title: "关于促进产业高质量发展的若干政策措施"
},
{
author: "工信部",
sentences: "根据《重点产业扶持目录》,对符合条件的企业和项目,将优先纳入政府采购名单,并提供专项资金支持,确保政策红利直接惠及实体经济",
query_part: "政府采购优先支持,专项资金直达企业",
match_score: "好",
declare_date: "2024-01-20T00:00:00",
report_title: "工业和信息化部关于落实产业扶持政策的通知"
}
]
},
direction: 'positive',
strength: 70,
is_circular: false
}
],
children: [
{
id: '2',
name: '半导体行业(正向影响)',
transmission_mechanism: {
data: [
{
author: "李明",
organization: "中国电子信息产业发展研究院",
sentences: "在技术突破和应用场景快速扩张的双重驱动下国内半导体市场呈现爆发式增长态势。据统计2024年上半年半导体市场规模达到1.2万亿元同比增长32%其中新能源汽车和AI算力芯片需求贡献了超过60%的增量",
query_part: "技术突破和需求激增推动半导体市场增长32%",
match_score: "好",
declare_date: "2024-07-10T00:00:00",
report_title: "2024年上半年中国半导体产业发展报告"
}
]
},
direction: 'positive',
strength: 80,
is_circular: false
},
{
id: '7',
name: '传统制造业(负向影响)',
transmission_mechanism: {
data: [
{
author: "张华",
organization: "经济观察报",
sentences: "随着半导体等高科技产业获得大量政策和资金支持传统制造业面临融资难、用工成本上升等多重压力。部分劳动密集型企业利润率下降15%,行业整体投资意愿降低",
query_part: "资源向高科技倾斜导致传统制造业承压",
match_score: "好",
declare_date: "2024-06-15T00:00:00",
report_title: "传统制造业转型升级调研报告"
}
]
},
direction: 'negative',
strength: 60,
is_circular: false
},
{
id: '8',
name: '能源行业(中性影响)',
transmission_mechanism: {
data: [
{
author: "王刚",
organization: "能源研究所",
sentences: "半导体产业扩张带来电力需求增长约8%但同时推动节能技术应用整体能源消费结构趋于优化。新建芯片工厂虽增加用电负荷但智能电网技术应用使能源利用效率提升12%",
query_part: "半导体产业对能源行业影响相对中性",
match_score: "中",
declare_date: "2024-07-01T00:00:00",
report_title: "高科技产业能源消费分析"
}
]
},
direction: 'neutral',
strength: 40,
is_circular: false
},
{
id: '9',
name: '教育培训行业(未明确方向)',
transmission_mechanism: {
data: [
{
author: "赵敏",
organization: "教育部职业教育司",
sentences: "半导体产业快速发展催生大量专业人才需求各类培训机构、职业院校纷纷开设相关课程。预计未来三年将新增半导体专业学员超过50万人带动职业教育市场规模扩大",
query_part: "半导体产业推动职业教育发展",
match_score: "好",
declare_date: "2024-06-20T00:00:00",
report_title: "半导体人才培养白皮书"
}
]
},
strength: 50,
is_circular: false
}
]
}
},
'2': {
success: true,
data: {
node: {
id: '2',
name: '半导体行业',
type: 'industry',
description: '半导体行业是现代科技产业的基础,受到主事件和政策的双重推动,迎来新一轮发展机遇。',
importance_score: 40,
total_connections: 3,
incoming_connections: 2,
outgoing_connections: 1
},
parents: [
{
id: '1',
name: '主要事件',
transmission_mechanism: {
data: [
{
author: "刘洋",
organization: "中国半导体行业协会",
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长国内半导体市场需求持续旺盛2024年Q1市场规模同比增长28%,创历史新高",
query_part: "新兴应用推动半导体需求增长28%",
match_score: "好",
declare_date: "2024-04-05T00:00:00",
report_title: "2024年Q1中国半导体行业景气度报告"
},
{
author: "刘洋",
organization: "中国半导体行业协会",
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长国内半导体市场需求持续旺盛2024年Q1市场规模同比增长28%,创历史新高",
query_part: "新兴应用推动半导体需求增长28%",
match_score: "好",
declare_date: "2024-04-05T00:00:00",
report_title: "2024年Q1中国半导体行业景气度报告"
}
]
},
direction: 'positive',
strength: 80,
is_circular: false
},
{
id: '6',
name: '相关政策',
transmission_mechanism: {
data: [
{
author: "国家发改委",
sentences: "《国家集成电路产业发展推进纲要》明确提出到2025年半导体产业自给率要达到70%以上国家将设立专项基金规模超过3000亿元重点支持半导体设备、材料、设计等关键环节",
query_part: "半导体自给率目标70%专项基金3000亿",
match_score: "好",
declare_date: "2024-02-01T00:00:00",
report_title: "国家集成电路产业发展推进纲要2024-2030"
}
]
},
direction: 'positive',
strength: 60,
is_circular: false
}
],
children: [
{
id: '3',
name: '芯片制造',
transmission_mechanism: {
data: [
{
author: "张明",
organization: "中信证券",
sentences: "在半导体行业景气度持续提升的背景下下游芯片制造企业订单饱满产能利用率达到历史新高预计2024年产能扩张将达到30%以上技术工艺也将从28nm向14nm升级",
query_part: "半导体行业繁荣带动芯片制造产能扩张30%",
match_score: "好",
declare_date: "2024-03-15T00:00:00",
report_title: "半导体行业深度报告:产业链景气度传导分析"
},
{
author: "李华",
organization: "海通证券",
sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升",
query_part: "技术迭代加快,先进制程占比提升",
match_score: "好",
declare_date: "2024-02-28T00:00:00",
report_title: "芯片制造行业跟踪报告"
}
]
},
direction: 'positive',
strength: 70,
is_circular: false
}
]
}
},
'3': {
success: true,
data: {
node: {
id: '3',
name: '芯片制造',
type: 'industry',
description: '芯片制造作为半导体产业链的核心环节,在上游需求推动下,产能利用率提升,技术迭代加快。',
importance_score: 35,
total_connections: 3,
incoming_connections: 1,
outgoing_connections: 2
},
parents: [
{
id: '2',
name: '半导体行业',
transmission_mechanism: {
data: [
{
author: "张明",
sentences: "在半导体行业景气度持续提升的背景下下游芯片制造企业订单饱满产能利用率达到历史新高预计2024年产能扩张将达到30%以上技术工艺也将从28nm向14nm升级",
query_part: "半导体行业繁荣带动芯片制造产能扩张30%",
match_score: "好",
declare_date: "2024-03-15T00:00:00",
report_title: "半导体行业深度报告:产业链景气度传导分析"
},
{
author: "李华",
sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升",
query_part: "技术迭代加快,先进制程占比提升",
match_score: "好",
declare_date: "2024-02-28T00:00:00",
report_title: "芯片制造行业跟踪报告"
}
]
},
direction: 'positive',
strength: 70,
is_circular: false
}
],
children: [
{
id: '4',
name: 'A公司',
transmission_mechanism: {
data: [
{
author: "王芳",
organization: "国泰君安",
sentences: "A公司作为国内芯片制造龙头企业在手订单已排至2024年Q4预计全年营收增长45%净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先",
query_part: "A公司在手订单充足预计营收增长45%",
match_score: "好",
declare_date: "2024-04-10T00:00:00",
report_title: "A公司深度研究受益芯片制造景气周期"
}
]
},
direction: 'positive',
strength: 60,
is_circular: false
},
{
id: '5',
name: 'B公司',
transmission_mechanism: {
data: [
{
author: "赵强",
organization: "华泰证券",
sentences: "随着芯片制造产能的大规模扩张上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业订单量同比增长55%产品供不应求预计2024年营收将突破百亿大关。公司在封装测试领域的市场份额已提升至国内第二位",
query_part: "B公司订单增长55%,营收将破百亿",
match_score: "好",
declare_date: "2024-05-08T00:00:00",
report_title: "B公司跟踪报告芯片产业链配套龙头崛起"
},
{
author: "陈彤",
organization: "国信证券",
sentences: "B公司深度受益于芯片制造产业链的景气度传导。公司凭借先进的封装技术和完善的产能布局成功绑定多家头部芯片制造企业形成稳定的供应关系。随着下游客户产能持续扩张公司业绩增长确定性强",
query_part: "B公司受益产业链景气度业绩增长确定性强",
match_score: "好",
declare_date: "2024-06-01T00:00:00",
report_title: "半导体封装测试行业专题:产业链景气度传导分析"
}
]
},
direction: 'positive',
strength: 50,
is_circular: false
}
]
}
},
'4': {
success: true,
data: {
node: {
id: '4',
name: 'A公司',
type: 'company',
description: 'A公司是行业龙头企业拥有先进的芯片制造技术和完整的产业链布局在本轮产业升级中占据有利位置。',
importance_score: 30,
stock_code: '600000',
total_connections: 1,
incoming_connections: 1,
outgoing_connections: 0
},
parents: [
{
id: '3',
name: '芯片制造',
transmission_mechanism: {
data: [
{
author: "王芳",
sentences: "A公司作为国内芯片制造龙头企业在手订单已排至2024年Q4预计全年营收增长45%净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先",
query_part: "A公司在手订单充足预计营收增长45%",
match_score: "好",
declare_date: "2024-04-10T00:00:00",
report_title: "A公司深度研究受益芯片制造景气周期"
}
]
},
direction: 'positive',
strength: 60,
is_circular: false
}
],
children: []
}
},
'5': {
success: true,
data: {
node: {
id: '5',
name: 'B公司',
type: 'company',
description: 'B公司专注于芯片封装测试领域随着上游制造产能释放公司订单饱满业绩稳步增长。',
importance_score: 25,
stock_code: '600001',
total_connections: 1,
incoming_connections: 1,
outgoing_connections: 0
},
parents: [
{
id: '3',
name: '芯片制造',
transmission_mechanism: {
data: [
{
author: "赵强",
organization: "华泰证券",
sentences: "随着芯片制造产能的大规模扩张上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业订单量同比增长55%产品供不应求预计2024年营收将突破百亿大关",
query_part: "B公司订单增长55%,营收将破百亿",
match_score: "好",
declare_date: "2024-05-08T00:00:00",
report_title: "B公司跟踪报告芯片产业链配套龙头崛起"
}
]
},
direction: 'positive',
strength: 50,
is_circular: false
}
],
children: []
}
},
'6': {
success: true,
data: {
node: {
id: '6',
name: '相关政策',
type: 'policy',
description: '国家出台了一系列产业扶持政策,包括财政补贴、税收减免和研发支持,旨在推动产业自主创新和进口替代。',
importance_score: 30,
total_connections: 2,
incoming_connections: 0,
outgoing_connections: 2
},
parents: [],
children: [
{
id: '1',
name: '主要事件',
transmission_mechanism: {
data: [
{
author: "国务院",
sentences: "为加快实施创新驱动发展战略推动产业转型升级国家将对重点领域给予财政补贴支持单个项目最高补贴金额可达5000万元同时享受研发费用加计扣除175%的税收优惠政策",
query_part: "国家财政补贴最高5000万元研发费用加计扣除175%",
match_score: "好",
declare_date: "2024-01-15T00:00:00",
report_title: "关于促进产业高质量发展的若干政策措施"
},
{
author: "工信部",
sentences: "将重点支持关键核心技术攻关和产业化应用建立产业发展专项基金规模达到1000亿元引导社会资本共同参与产业发展",
query_part: "设立1000亿元产业发展专项基金",
match_score: "好",
declare_date: "2024-02-01T00:00:00",
report_title: "产业发展专项基金管理办法"
}
]
},
direction: 'positive',
strength: 70,
is_circular: false
},
{
id: '2',
name: '半导体行业',
transmission_mechanism: {
data: [
{
author: "国家发改委",
sentences: "《国家集成电路产业发展推进纲要》明确提出到2025年半导体产业自给率要达到70%以上国家将设立专项基金规模超过3000亿元重点支持半导体设备、材料、设计等关键环节。同时通过进口替代战略加快培育本土产业链",
query_part: "半导体自给率目标70%专项基金3000亿",
match_score: "好",
declare_date: "2024-02-01T00:00:00",
report_title: "国家集成电路产业发展推进纲要2024-2030"
},
{
author: "工信部",
sentences: "将重点支持关键核心技术攻关和产业化应用建立产业发展专项基金规模达到1000亿元引导社会资本共同参与产业发展。通过税收优惠、研发补贴等政策工具为半导体行业创造良好的发展环境",
query_part: "设立1000亿元产业发展专项基金",
match_score: "好",
declare_date: "2024-02-01T00:00:00",
report_title: "产业发展专项基金管理办法"
}
]
},
direction: 'positive',
strength: 60,
is_circular: false
}
]
}
}
};
// 返回对应节点的详情,如果不存在则返回默认数据
const nodeDetail = nodeDetailsMap[nodeId] || {
success: true,
data: {
node: {
id: nodeId,
name: '未知节点',
type: 'other',
description: '该节点暂无详细信息',
importance_score: 0,
total_connections: 0,
incoming_connections: 0,
outgoing_connections: 0
},
parents: [],
children: []
}
};
return HttpResponse.json(nodeDetail);
}),
// ==================== 投资日历相关 ====================
// 获取月度事件统计
http.get('/api/v1/calendar/event-counts', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const year = parseInt(url.searchParams.get('year'));
const month = parseInt(url.searchParams.get('month'));
console.log('[Mock] 获取月度事件统计:', { year, month });
const eventCounts = getMockEventCountsForMonth(year, month);
return HttpResponse.json({
success: true,
data: eventCounts
});
}),
// 获取指定日期的事件列表
http.get('/api/v1/calendar/events', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const dateStr = url.searchParams.get('date');
const type = url.searchParams.get('type') || 'all';
console.log('[Mock] 获取日历事件列表:', { date: dateStr, type });
if (!dateStr) {
return HttpResponse.json({
success: false,
error: 'Date parameter required'
}, { status: 400 });
}
const events = getMockFutureEvents(dateStr, type);
return HttpResponse.json({
success: true,
data: events
});
}),
// 切换未来事件关注状态
http.post('/api/v1/calendar/events/:eventId/follow', async ({ params }) => {
await delay(300);
const { eventId } = params;
console.log('[Mock] 切换事件关注状态, eventId:', eventId);
// 简单返回成功,实际状态管理可以后续完善
return HttpResponse.json({
success: true,
data: {
is_following: true,
message: '关注成功'
}
});
}),
];

View File

@@ -5,6 +5,8 @@ import { authHandlers } from './auth';
import { accountHandlers } from './account';
import { simulationHandlers } from './simulation';
import { eventHandlers } from './event';
import { paymentHandlers } from './payment';
import { industryHandlers } from './industry';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -14,5 +16,7 @@ export const handlers = [
...accountHandlers,
...simulationHandlers,
...eventHandlers,
...paymentHandlers,
...industryHandlers,
// ...userHandlers,
];

View File

@@ -0,0 +1,44 @@
// src/mocks/handlers/industry.js
// 行业分类相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { industryData } from '../../data/industryData';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
export const industryHandlers = [
// 获取行业分类完整树形结构
http.get('/api/classifications', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const classification = url.searchParams.get('classification');
console.log('[Mock] 获取行业分类树形数据(真实数据)', { classification });
try {
let data = industryData;
// 如果指定了分类体系,只返回该体系的数据
if (classification) {
data = industryData.filter(item => item.value === classification);
}
return HttpResponse.json({
success: true,
data: data
});
} catch (error) {
console.error('[Mock] 获取行业分类失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取行业分类失败',
data: []
},
{ status: 500 }
);
}
})
];

View File

@@ -0,0 +1,233 @@
// src/mocks/handlers/payment.js
import { http, HttpResponse, delay } from 'msw';
import { getCurrentUser } from '../data/users';
// 模拟网络延迟(毫秒)
const NETWORK_DELAY = 500;
// 模拟订单数据存储
const mockOrders = new Map();
let orderIdCounter = 1000;
export const paymentHandlers = [
// ==================== 支付订单管理 ====================
// 1. 创建支付订单
http.post('/api/payment/create-order', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '未登录'
}, { status: 401 });
}
const body = await request.json();
const { plan_name, billing_cycle } = body;
console.log('[Mock] 创建支付订单:', { plan_name, billing_cycle, user: currentUser.id });
if (!plan_name || !billing_cycle) {
return HttpResponse.json({
success: false,
error: '参数不完整'
}, { status: 400 });
}
// 模拟价格
const prices = {
pro: { monthly: 0.01, yearly: 0.08 },
max: { monthly: 0.1, yearly: 0.8 }
};
const amount = prices[plan_name]?.[billing_cycle] || 0.01;
// 创建订单
const orderId = `ORDER_${orderIdCounter++}_${Date.now()}`;
const order = {
id: orderId,
user_id: currentUser.id,
plan_name,
billing_cycle,
amount,
status: 'pending',
qr_code_url: `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=weixin://wxpay/bizpayurl?pr=mock_${orderId}`,
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟后过期
};
mockOrders.set(orderId, order);
console.log('[Mock] 订单创建成功:', order);
// 模拟5秒后自动支付成功方便测试
setTimeout(() => {
const existingOrder = mockOrders.get(orderId);
if (existingOrder && existingOrder.status === 'pending') {
existingOrder.status = 'paid';
existingOrder.paid_at = new Date().toISOString();
console.log(`[Mock] 订单自动支付成功: ${orderId}`);
}
}, 5000);
return HttpResponse.json({
success: true,
data: order
});
}),
// 2. 查询订单状态
http.get('/api/payment/order-status/:orderId', async ({ params }) => {
await delay(300);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '未登录'
}, { status: 401 });
}
const { orderId } = params;
const order = mockOrders.get(orderId);
console.log('[Mock] 查询订单状态:', { orderId, found: !!order });
if (!order) {
return HttpResponse.json({
success: false,
error: '订单不存在'
}, { status: 404 });
}
if (order.user_id !== currentUser.id) {
return HttpResponse.json({
success: false,
error: '无权访问此订单'
}, { status: 403 });
}
return HttpResponse.json({
success: true,
data: order
});
}),
// 3. 获取用户订单列表
http.get('/api/payment/orders', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '未登录'
}, { status: 401 });
}
const userOrders = Array.from(mockOrders.values())
.filter(order => order.user_id === currentUser.id)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log('[Mock] 获取用户订单列表:', { count: userOrders.length });
return HttpResponse.json({
success: true,
data: userOrders
});
}),
// 4. 取消订单
http.post('/api/payment/cancel-order', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json({
success: false,
error: '未登录'
}, { status: 401 });
}
const body = await request.json();
const { order_id } = body;
const order = mockOrders.get(order_id);
if (!order) {
return HttpResponse.json({
success: false,
error: '订单不存在'
}, { status: 404 });
}
if (order.user_id !== currentUser.id) {
return HttpResponse.json({
success: false,
error: '无权操作此订单'
}, { status: 403 });
}
if (order.status !== 'pending') {
return HttpResponse.json({
success: false,
error: '只能取消待支付的订单'
}, { status: 400 });
}
order.status = 'cancelled';
order.cancelled_at = new Date().toISOString();
console.log('[Mock] 订单已取消:', order_id);
return HttpResponse.json({
success: true,
message: '订单已取消'
});
})
];
// ==================== Mock 调试工具(仅开发环境) ====================
/**
* 暴露全局API方便手动触发支付成功
* 使用方式window.mockPaymentSuccess(orderId)
*/
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
window.mockPaymentSuccess = (orderId) => {
const order = mockOrders.get(orderId);
if (!order) {
console.error('[Mock Payment] 订单不存在:', orderId);
return false;
}
if (order.status !== 'pending') {
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
return false;
}
order.status = 'paid';
order.paid_at = new Date().toISOString();
console.log('[Mock Payment] ✅ 支付成功:', orderId);
return true;
};
window.getMockOrders = () => {
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
orderId: id,
status: order.status,
amount: order.amount,
plan: `${order.plan_name} - ${order.billing_cycle}`,
createdAt: new Date(order.created_at).toLocaleString()
}));
console.table(orders);
return orders;
};
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
console.log('%c使用方法:', 'color: #666');
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
console.log(' window.getMockOrders() - 查看所有模拟订单');
}

View File

@@ -15,609 +15,13 @@
*/
// ⚡ 使用 React.lazy() 实现路由懒加载
// 按需加载组件,大幅减少初始 JS 包大小
import React from "react";
import {
CartIcon,
DocumentIcon,
HomeIcon,
PersonIcon,
StatsIcon,
} from "components/Icons/Icons";
// ⚡ 懒加载所有页面组件
const Calendar = React.lazy(() => import("views/Applications/Calendar"));
const DataTables = React.lazy(() => import("views/Applications/DataTables"));
const Kanban = React.lazy(() => import("views/Applications/Kanban.js"));
const Wizard = React.lazy(() => import("views/Applications/Wizard.js"));
const SignInBasic = React.lazy(() => import("views/Authentication/SignIn/SignInBasic.js"));
const SignInCover = React.lazy(() => import("views/Authentication/SignIn/SignInCover.js"));
const SignInIllustration = React.lazy(() => import("views/Authentication/SignIn/SignInIllustration.js"));
const LockBasic = React.lazy(() => import("views/Authentication/Lock/LockBasic.js"));
const LockCover = React.lazy(() => import("views/Authentication/Lock/LockCover.js"));
const LockIllustration = React.lazy(() => import("views/Authentication/Lock/LockIllustration.js"));
const ResetBasic = React.lazy(() => import("views/Authentication/Reset/ResetBasic.js"));
const ResetCover = React.lazy(() => import("views/Authentication/Reset/ResetCover.js"));
const ResetIllustration = React.lazy(() => import("views/Authentication/Reset/ResetIllustration.js"));
const VerificationBasic = React.lazy(() => import("views/Authentication/Verification/VerificationBasic.js"));
const VerificationCover = React.lazy(() => import("views/Authentication/Verification/VerificationCover.js"));
const VerificationIllustration = React.lazy(() => import("views/Authentication/Verification/VerificationIllustration.js"));
const SignUpBasic = React.lazy(() => import("views/Authentication/SignUp/SignUpBasic.js"));
const SignUpCover = React.lazy(() => import("views/Authentication/SignUp/SignUpCover.js"));
const SignUpIllustration = React.lazy(() => import("views/Authentication/SignUp/SignUpIllustration.js"));
const Automotive = React.lazy(() => import("views/Dashboard/Automotive"));
const CRM = React.lazy(() => import("views/Dashboard/CRM.js"));
const Default = React.lazy(() => import("views/Dashboard/Default.js"));
const Landing = React.lazy(() => import("views/Dashboard/Landing.js"));
const OrderDetails = React.lazy(() => import("views/Ecommerce/Orders/OrderDetails"));
const OrderList = React.lazy(() => import("views/Ecommerce/Orders/OrderList"));
const EditProduct = React.lazy(() => import("views/Ecommerce/Products/EditProduct"));
const NewProduct = React.lazy(() => import("views/Ecommerce/Products/NewProduct"));
const ProductPage = React.lazy(() => import("views/Ecommerce/Products/ProductPage"));
const Billing = React.lazy(() => import("views/Pages/Account/Billing.js"));
const Subscription = React.lazy(() => import("views/Pages/Account/Subscription.js"));
const Invoice = React.lazy(() => import("views/Pages/Account/Invoice.js"));
const Settings = React.lazy(() => import("views/Pages/Account/Settings.js"));
const Alerts = React.lazy(() => import("views/Pages/Alerts"));
const Charts = React.lazy(() => import("views/Pages/Charts.js"));
const Pricing = React.lazy(() => import("views/Pages/Pricing.js"));
const Overview = React.lazy(() => import("views/Pages/Profile/Overview.js"));
const Projects = React.lazy(() => import("views/Pages/Profile/Projects.js"));
const Teams = React.lazy(() => import("views/Pages/Profile/Teams.js"));
const General = React.lazy(() => import("views/Pages/Projects/General.js"));
const Timeline = React.lazy(() => import("views/Pages/Projects/Timeline.js"));
const RTLPage = React.lazy(() => import("views/Pages/RTLPage.js"));
const NewUser = React.lazy(() => import("views/Pages/Users/NewUser.js"));
const Reports = React.lazy(() => import("views/Pages/Users/Reports.js"));
const Widgets = React.lazy(() => import("views/Pages/Widgets.js"));
const SmartHome = React.lazy(() => import("views/Dashboard/SmartHome"));
const ConceptCenter = React.lazy(() => import("views/Concept"));
const ProfilePage = React.lazy(() => import("views/Profile/ProfilePage"));
const SettingsPage = React.lazy(() => import("views/Settings/SettingsPage"));
const LimitAnalyse = React.lazy(() => import("views/LimitAnalyse"));
const Community = React.lazy(() => import("views/Community"));
const ForecastReport = React.lazy(() => import("views/Company/ForecastReport"));
const FinancialPanorama = React.lazy(() => import("views/Company/FinancialPanorama"));
const CompanyIndex = React.lazy(() => import("views/Company"));
const MarketDataView = React.lazy(() => import("views/Company/MarketDataView"));
const StockOverview = React.lazy(() => import("views/StockOverview"));
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
const PrivacyPolicy = React.lazy(() => import("views/Pages/PrivacyPolicy"));
const UserAgreement = React.lazy(() => import("views/Pages/UserAgreement"));
const WechatCallback = React.lazy(() => import("views/Pages/WechatCallback"));
const dashRoutes = [
{
name: "Dashboard",
path: "/dashboard",
icon: <HomeIcon color="inherit" />,
authIcon: <HomeIcon color="inherit" />,
collapse: true,
items: [
{
name: "Landing Page",
path: "/dashboard/landing",
component: Landing,
layout: "/landing",
},
{
name: "Default",
path: "/dashboard/default",
component: Default,
layout: "/admin",
},
{
name: "Automotive",
path: "/dashboard/automotive",
component: Automotive,
layout: "/admin",
},
{
name: "Smart Home",
path: "/dashboard/smart-home",
component: SmartHome,
layout: "/admin",
},
{
name: "CRM",
path: "/dashboard/crm",
component: CRM,
layout: "/admin",
},
],
},
{
name: "股票分析",
path: "/stock-analysis",
icon: <StatsIcon color="inherit" />,
authIcon: <StatsIcon color="inherit" />,
collapse: true,
items: [
{
name: "股票概览",
path: "/stock-analysis/overview",
component: StockOverview,
layout: "/admin",
},
{
name: "个股信息",
path: "/stock-analysis/company",
component: CompanyIndex,
layout: "/admin",
},
{
name: "股票行情",
path: "/stock-analysis/market-data",
component: MarketDataView,
layout: "/admin",
},
{
name: "涨停分析",
path: "/stock-analysis/limit-analyse",
component: LimitAnalyse,
layout: "/admin",
},
{
name: "盈利预测报表",
path: "/stock-analysis/forecast-report",
component: ForecastReport,
layout: "/admin",
},
{
name: "盈利预测报表",
path: "/stock-analysis/Financial-report",
component: FinancialPanorama,
layout: "/admin",
},
],
},
{
name: "概念中心",
path: "/concepts",
icon: <StatsIcon color="inherit" />, // 或者使用其他图标
authIcon: <StatsIcon color="inherit" />,
collapse: false,
component: ConceptCenter,
layout: "/admin",
},
{
name: "事件社区",
path: "/community",
icon: <StatsIcon color="inherit" />,
authIcon: <StatsIcon color="inherit" />,
collapse: false,
component: Community,
layout: "/admin",
},
{
name: "模拟盘交易",
path: "/trading-simulation",
icon: <CartIcon color="inherit" />,
authIcon: <CartIcon color="inherit" />,
collapse: false,
component: TradingSimulation,
layout: "/home",
},
{
name: "个人资料",
path: "/profile",
icon: <PersonIcon color="inherit" />,
component: ProfilePage,
layout: "/admin",
invisible: true, // 不在侧边栏显示
},
{
name: "账户设置",
path: "/settings",
icon: <StatsIcon color="inherit" />,
component: SettingsPage,
layout: "/admin",
invisible: true, // 不在侧边栏显示
},
{
name: "隐私政策",
path: "/privacy-policy",
icon: <DocumentIcon color="inherit" />,
component: PrivacyPolicy,
layout: "/home",
invisible: true, // 不在侧边栏显示
},
{
name: "用户协议",
path: "/user-agreement",
icon: <DocumentIcon color="inherit" />,
component: UserAgreement,
layout: "/home",
invisible: true, // 不在侧边栏显示
},
{
name: "微信授权回调",
path: "/wechat-callback",
icon: <DocumentIcon color="inherit" />,
component: WechatCallback,
layout: "/home",
invisible: true, // 不在侧边栏显示
},
{
name: "PAGES",
category: "pages",
items: [
{
name: "Pages",
path: "/pages",
collapse: true,
icon: <DocumentIcon color="inherit" />,
items: [
{
name: "Profile",
path: "/profile",
collapse: true,
authIcon: <HomeIcon color="inherit" />,
items: [
{
name: "Profile Overview",
secondaryNavbar: true,
path: "/pages/profile/overview",
component: Overview,
layout: "/admin",
},
{
name: "Teams",
secondaryNavbar: true,
path: "/pages/profile/teams",
component: Teams,
layout: "/admin",
},
{
name: "All Projects",
secondaryNavbar: true,
path: "/pages/profile/profile-projects",
component: Projects,
layout: "/admin",
},
],
},
{
name: "Users",
path: "/users",
collapse: true,
authIcon: <PersonIcon color="inherit" />,
items: [
{
name: "Reports",
path: "/pages/users/reports",
component: Reports,
layout: "/admin",
},
{
name: "New User",
path: "/pages/users/new-user",
component: NewUser,
layout: "/admin",
},
],
},
{
name: "Account",
path: "/account",
collapse: true,
authIcon: <PersonIcon color="inherit" />,
items: [
{
name: "Settings",
path: "/pages/account/settings",
component: Settings,
layout: "/admin",
},
{
name: "Billing",
component: Billing,
path: "/pages/account/billing",
layout: "/admin",
},
{
name: "Subscription",
component: Subscription,
path: "/pages/account/subscription",
layout: "/home",
},
{
name: "Invoice",
component: Invoice,
path: "/pages/account/invoice",
layout: "/admin",
},
],
},
{
name: "Projects",
path: "/projects",
collapse: true,
authIcon: <StatsIcon color="inherit" />,
items: [
{
name: "General",
path: "/pages/projects/general",
component: General,
layout: "/admin",
},
{
name: "Timeline",
path: "/pages/projects/timeline",
component: Timeline,
layout: "/admin",
},
],
},
{
name: "Pricing Page",
component: Pricing,
path: "/pages/pricing-page",
layout: "/auth",
},
{
name: "RTL",
component: RTLPage,
path: "/pages/rtl-support-page",
layout: "/rtl",
},
{
name: "Widgets",
component: Widgets,
path: "/pages/widgets",
layout: "/admin",
},
{
name: "Charts",
component: Charts,
path: "/pages/charts",
layout: "/admin",
},
{
name: "Alerts",
path: "/pages/alerts",
component: Alerts,
layout: "/admin",
},
],
},
{
name: "Applications",
path: "/applications",
icon: <StatsIcon color="inherit" />,
collapse: true,
items: [
{
name: "Kanban",
component: Kanban,
authIcon: <DocumentIcon color="inherit" />,
path: "/applications/kanban",
layout: "/admin",
},
{
name: "Wizard",
component: Wizard,
authIcon: <CartIcon color="inherit" />,
path: "/applications/wizard",
layout: "/admin",
},
{
name: "Data Tables",
path: "/applications/data-tables",
authIcon: <PersonIcon color="inherit" />,
component: DataTables,
layout: "/admin",
},
{
name: "Calendar",
component: Calendar,
authIcon: <StatsIcon color="inherit" />,
path: "/applications/calendar",
layout: "/admin",
},
],
},
{
name: "Ecommerce",
path: "/ecommerce",
icon: <CartIcon color="inherit" />,
collapse: true,
items: [
{
name: "Products",
path: "/products",
collapse: true,
authIcon: <DocumentIcon color="inherit" />,
items: [
{
name: "New Product",
component: NewProduct,
secondaryNavbar: true,
path: "/ecommerce/products/new-product",
layout: "/admin",
},
{
name: "Edit Product",
component: EditProduct,
path: "/ecommerce/products/edit-product",
layout: "/admin",
},
{
name: "Product Page",
component: ProductPage,
path: "/ecommerce/products/product-page",
layout: "/admin",
},
],
},
{
name: "Orders",
path: "/orders",
collapse: true,
authIcon: <StatsIcon color="inherit" />,
items: [
{
name: "Order List",
component: OrderList,
path: "/ecommerce/orders/order-list",
layout: "/admin",
},
{
name: "Order Details",
component: OrderDetails,
path: "/ecommerce/orders/order-details",
layout: "/admin",
},
],
},
],
},
{
name: "Authentication",
path: "/authentication",
icon: <PersonIcon color="inherit" />,
collapse: true,
items: [
{
name: "Sign In",
path: "/authentication/sign-in",
collapse: true,
authIcon: <DocumentIcon color="inherit" />,
items: [
{
name: "Basic",
component: SignInBasic,
path: "/authentication/sign-in/basic",
layout: "/auth",
},
{
name: "Cover",
component: SignInCover,
path: "/authentication/sign-in/cover",
layout: "/auth",
},
{
name: "Illustration",
component: SignInIllustration,
secondaryNavbar: true,
path: "/authentication/sign-in/illustration",
layout: "/auth",
},
],
},
{
name: "Sign Up",
path: "/authentication/sign-up",
collapse: true,
authIcon: <DocumentIcon color="inherit" />,
items: [
{
name: "Basic",
component: SignUpBasic,
path: "/authentication/sign-up/basic",
layout: "/auth",
},
{
name: "Cover",
component: SignUpCover,
path: "/authentication/sign-up/cover",
layout: "/auth",
},
{
name: "Illustration",
secondaryNavbar: true,
component: SignUpIllustration,
path: "/authentication/sign-up/illustration",
layout: "/auth",
},
],
},
{
name: "Reset password",
path: "/authentication/reset",
collapse: true,
authIcon: <DocumentIcon color="inherit" />,
items: [
{
name: "Basic",
component: ResetBasic,
path: "/authentication/reset/basic",
layout: "/auth",
},
{
name: "Cover",
component: ResetCover,
path: "/authentication/reset/cover",
layout: "/auth",
},
{
name: "Illustration",
secondaryNavbar: true,
component: ResetIllustration,
path: "/authentication/reset/illustration",
layout: "/auth",
},
],
},
{
name: "Lock",
path: "/authentication/lock",
collapse: true,
authIcon: <DocumentIcon color="inherit" />,
items: [
{
name: "Basic",
component: LockBasic,
path: "/authentication/lock/basic",
layout: "/auth",
},
{
name: "Cover",
component: LockCover,
path: "/authentication/lock/cover",
layout: "/auth",
},
{
name: "Illustration",
secondaryNavbar: true,
component: LockIllustration,
path: "/authentication/lock/illustration",
layout: "/auth",
},
],
},
{
name: "2-Step Verification",
path: "/authentication/verification",
collapse: true,
authIcon: <DocumentIcon color="inherit" />,
items: [
{
name: "Basic",
component: VerificationBasic,
path: "/authentication/verification/basic",
layout: "/auth",
},
{
name: "Cover",
component: VerificationCover,
path: "/authentication/verification/cover",
layout: "/auth",
},
{
name: "Illustration",
secondaryNavbar: true,
component: VerificationIllustration,
path: "/authentication/verification/illustration",
layout: "/auth",
},
],
},
],
},
],
},
];
// ⚠️ Admin 布局已移除
// 实际路由配置现在在 /src/App.js 中
// 此文件保留仅为兼容可能的旧引用
// 导出空数组以保持向后兼容
const dashRoutes = [];
export default dashRoutes;

View File

@@ -1,10 +1,13 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/authService.js
/**
* 认证服务层 - 处理所有认证相关的 API 调用
*/
import { logger } from '../utils/logger';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
const API_BASE_URL = getApiBase();
/**
* 统一的 API 请求处理
@@ -13,6 +16,12 @@ const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
* @returns {Promise} - 响应数据
*/
const apiRequest = async (url, options = {}) => {
const method = options.method || 'GET';
const requestData = options.body ? JSON.parse(options.body) : null;
// 记录请求日志
logger.api.request(method, url, requestData);
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
@@ -20,7 +29,7 @@ const apiRequest = async (url, options = {}) => {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // 包含 cookies
credentials: 'include',
});
// 检查响应是否为 JSON
@@ -33,8 +42,10 @@ const apiRequest = async (url, options = {}) => {
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
// 记录错误响应
logger.api.error(method, url, new Error(errorMessage), requestData);
} catch (parseError) {
console.warn('Failed to parse error response as JSON');
logger.warn('authService', 'Failed to parse error response as JSON', { url });
}
}
throw new Error(errorMessage);
@@ -43,16 +54,21 @@ const apiRequest = async (url, options = {}) => {
// 安全地解析 JSON 响应
if (isJson) {
try {
return await response.json();
const data = await response.json();
// 记录成功响应
logger.api.response(method, url, response.status, data);
return data;
} catch (parseError) {
console.error('Failed to parse response as JSON:', parseError);
logger.error('authService', 'apiRequest', parseError, { url });
throw new Error('服务器响应格式错误');
}
} else {
throw new Error('服务器响应不是 JSON 格式');
}
} catch (error) {
console.error(`Auth API request failed for ${url}:`, error);
// ❌ 移除 console.error使用 logger
logger.api.error(method, url, error, requestData);
// 如果是网络错误,提供更友好的提示
if (error.message === 'Failed to fetch' || error.name === 'TypeError') {
throw new Error('网络连接失败,请检查网络设置');

View File

@@ -0,0 +1,208 @@
// src/services/browserNotificationService.js
/**
* 浏览器原生通知服务
* 提供系统级通知功能Web Notifications API
*/
import { logger } from '../utils/logger';
class BrowserNotificationService {
constructor() {
this.permission = this.isSupported() ? Notification.permission : 'denied';
this.activeNotifications = new Map(); // 存储活跃的通知
}
/**
* 检查浏览器是否支持通知 API
*/
isSupported() {
return 'Notification' in window;
}
/**
* 获取当前权限状态
* @returns {string} 'granted' | 'denied' | 'default'
*/
getPermissionStatus() {
if (!this.isSupported()) {
return 'denied';
}
return Notification.permission;
}
/**
* 请求通知权限
* @returns {Promise<string>} 权限状态
*/
async requestPermission() {
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
return 'denied';
}
if (this.permission === 'granted') {
logger.info('browserNotificationService', 'Permission already granted');
return 'granted';
}
try {
const permission = await Notification.requestPermission();
this.permission = permission;
logger.info('browserNotificationService', `Permission ${permission}`);
return permission;
} catch (error) {
logger.error('browserNotificationService', 'requestPermission', error);
return 'denied';
}
}
/**
* 发送浏览器通知
* @param {Object} options 通知选项
* @param {string} options.title 标题
* @param {string} options.body 内容
* @param {string} options.icon 图标路径
* @param {string} options.tag 标签(防止重复)
* @param {boolean} options.requireInteraction 是否需要用户交互才关闭
* @param {Object} options.data 自定义数据(如跳转链接)
* @param {number} options.autoClose 自动关闭时间(毫秒)
* @returns {Notification|null} 通知对象
*/
sendNotification({
title,
body,
icon = '/logo192.png',
tag,
requireInteraction = false,
data = {},
autoClose = 0,
}) {
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
return null;
}
if (this.permission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted');
return null;
}
try {
// 关闭相同 tag 的旧通知
if (tag && this.activeNotifications.has(tag)) {
const oldNotification = this.activeNotifications.get(tag);
oldNotification.close();
}
// 创建通知
const notification = new Notification(title, {
body,
icon,
badge: '/badge.png',
tag: tag || `notification_${Date.now()}`,
requireInteraction,
data,
silent: false, // 允许声音
});
// 存储通知引用
if (tag) {
this.activeNotifications.set(tag, notification);
}
// 自动关闭
if (autoClose > 0 && !requireInteraction) {
setTimeout(() => {
notification.close();
}, autoClose);
}
// 通知关闭时清理引用
notification.onclose = () => {
if (tag) {
this.activeNotifications.delete(tag);
}
};
logger.info('browserNotificationService', 'Notification sent', { title, tag });
return notification;
} catch (error) {
logger.error('browserNotificationService', 'sendNotification', error);
return null;
}
}
/**
* 设置通知点击处理
* @param {Notification} notification 通知对象
* @param {Function} navigate React Router navigate 函数
*/
setupClickHandler(notification, navigate) {
if (!notification) return;
notification.onclick = (event) => {
event.preventDefault();
// 聚焦窗口
window.focus();
// 跳转链接
if (notification.data?.link) {
navigate(notification.data.link);
}
// 关闭通知
notification.close();
logger.info('browserNotificationService', 'Notification clicked', notification.data);
};
}
/**
* 关闭所有活跃通知
*/
closeAll() {
this.activeNotifications.forEach(notification => {
notification.close();
});
this.activeNotifications.clear();
logger.info('browserNotificationService', 'All notifications closed');
}
/**
* 根据通知数据发送浏览器通知
* @param {Object} notificationData 通知数据
* @param {Function} navigate React Router navigate 函数
*/
sendFromNotificationData(notificationData, navigate) {
const { type, priority, title, content, link, extra } = notificationData;
// 生成唯一 tag
const tag = `${type}_${Date.now()}`;
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === 'urgent';
// 发送通知
const notification = this.sendNotification({
title: title || '新通知',
body: content || '',
tag,
requireInteraction,
data: { link, ...extra },
autoClose: requireInteraction ? 0 : 8000, // 紧急通知不自动关闭
});
// 设置点击处理
if (notification && navigate) {
this.setupClickHandler(notification, navigate);
}
return notification;
}
}
// 导出单例
export const browserNotificationService = new BrowserNotificationService();
export default browserNotificationService;

View File

@@ -1,30 +1,36 @@
// src/services/eventService.js
const apiRequest = async (url, options = {}) => {
try {
console.log(`Making API request to: ${url}`);
import { logger } from '../utils/logger';
const apiRequest = async (url, options = {}) => {
const method = options.method || 'GET';
const requestData = options.body ? JSON.parse(options.body) : null;
logger.api.request(method, url, requestData);
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // 包含 cookies以便后端识别登录状态
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
console.error(`API request failed: ${response.status} - ${errorText}`);
throw new Error(`HTTP error! status: ${response.status}`);
const error = new Error(`HTTP error! status: ${response.status}`);
logger.api.error(method, url, error, { errorText, ...requestData });
throw error;
}
const data = await response.json();
console.log(`API response from ${url}:`, data);
logger.api.response(method, url, response.status, data);
return data;
} catch (error) {
console.error(`API request failed for ${url}:`, error);
logger.api.error(method, url, error, requestData);
throw error;
}
};
@@ -160,7 +166,7 @@ export const eventService = {
try {
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
} catch (error) {
console.error('获取帖子失败:', error);
logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
return { success: false, data: [], pagination: {} };
}
},
@@ -172,7 +178,7 @@ export const eventService = {
body: JSON.stringify(postData)
});
} catch (error) {
console.error('创建帖子失败:', error);
logger.error('eventService', 'createPost', error, { eventId });
return { success: false, message: '创建帖子失败' };
}
},
@@ -183,7 +189,7 @@ export const eventService = {
method: 'DELETE'
});
} catch (error) {
console.error('删除帖子失败:', error);
logger.error('eventService', 'deletePost', error, { postId });
return { success: false, message: '删除帖子失败' };
}
},
@@ -194,7 +200,7 @@ export const eventService = {
method: 'POST'
});
} catch (error) {
console.error('点赞失败:', error);
logger.error('eventService', 'likePost', error, { postId });
return { success: false, message: '点赞失败' };
}
},
@@ -204,7 +210,7 @@ export const eventService = {
try {
return await apiRequest(`/api/posts/${postId}/comments?sort=${sortType}`);
} catch (error) {
console.error('获取评论失败:', error);
logger.error('eventService', 'getPostComments', error, { postId, sortType });
return { success: false, data: [] };
}
},
@@ -216,7 +222,7 @@ export const eventService = {
body: JSON.stringify(commentData)
});
} catch (error) {
console.error('添加评论失败:', error);
logger.error('eventService', 'addPostComment', error, { postId });
return { success: false, message: '添加评论失败' };
}
},
@@ -227,7 +233,7 @@ export const eventService = {
method: 'DELETE'
});
} catch (error) {
console.error('删除评论失败:', error);
logger.error('eventService', 'deleteComment', error, { commentId });
return { success: false, message: '删除评论失败' };
}
},
@@ -238,33 +244,31 @@ export const eventService = {
method: 'POST'
});
} catch (error) {
console.error('点赞失败:', error);
logger.error('eventService', 'likeComment', error, { commentId });
return { success: false, message: '点赞失败' };
}
},
// 兼容旧版本的评论API
getComments: async (eventId, sortType = 'latest') => {
console.warn('getComments 已废弃,请使用 getPosts');
// 直接调用 getPosts 的实现,避免循环引用
logger.warn('eventService', 'getComments 已废弃,请使用 getPosts');
try {
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=1&per_page=20`);
} catch (error) {
console.error('获取帖子失败:', error);
logger.error('eventService', 'getComments', error, { eventId, sortType });
return { success: false, data: [], pagination: {} };
}
},
addComment: async (eventId, commentData) => {
console.warn('addComment 已废弃,请使用 createPost');
// 直接调用 createPost 的实现,避免循环引用
logger.warn('eventService', 'addComment 已废弃,请使用 createPost');
try {
return await apiRequest(`/api/events/${eventId}/posts`, {
method: 'POST',
body: JSON.stringify(commentData)
});
} catch (error) {
console.error('创建帖子失败:', error);
logger.error('eventService', 'addComment', error, { eventId });
return { success: false, message: '创建帖子失败' };
}
},
@@ -281,26 +285,26 @@ export const stockService = {
requestBody.event_time = eventTime;
}
console.log(`获取股票报价,请求体:`, requestBody);
logger.debug('stockService', '获取股票报价', requestBody);
const response = await apiRequest(`/api/stock/quotes`, {
method: 'POST',
body: JSON.stringify(requestBody)
});
console.log('股票报价原始响应:', response);
console.log('response.success:', response.success);
console.log('response.data:', response.data);
logger.debug('stockService', '股票报价响应', {
success: response.success,
dataKeys: response.data ? Object.keys(response.data) : []
});
if (response.success && response.data) {
console.log('返回股票报价数据:', response.data);
return response.data;
} else {
console.warn('股票报价响应格式异常:', response);
logger.warn('stockService', '股票报价响应格式异常', response);
return {};
}
} catch (error) {
console.error('获取股票报价失败:', error);
logger.error('stockService', 'getQuotes', error, { codes, eventTime });
throw error;
}
},
@@ -317,18 +321,18 @@ export const stockService = {
}
const url = `/api/stock/${stockCode}/kline?${params.toString()}`;
console.log(`获取K线数据: ${url}`);
logger.debug('stockService', '获取K线数据', { stockCode, chartType, eventTime });
const response = await apiRequest(url);
if (response.error) {
console.warn('K线数据响应包含错误:', response.error);
logger.warn('stockService', 'K线数据响应包含错误', response.error);
return response;
}
return response;
} catch (error) {
console.error('获取K线数据失败:', error);
logger.error('stockService', 'getKlineData', error, { stockCode, chartType });
throw error;
}
},
@@ -343,21 +347,10 @@ export const stockService = {
node_level: nodeInfo.level
});
const response = await fetch(`/api/events/${eventId}/sankey-node-detail?${params}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
const url = `/api/events/${eventId}/sankey-node-detail?${params}`;
return await apiRequest(url);
} catch (error) {
console.error('Error fetching sankey node detail:', error);
logger.error('stockService', 'getSankeyNodeDetail', error, { eventId, nodeInfo });
throw error;
}
},
@@ -382,11 +375,11 @@ export const indexService = {
}
const url = `/api/index/${indexCode}/kline?${params.toString()}`;
console.log(`获取指数K线数据: ${url}`);
logger.debug('indexService', '获取指数K线数据', { indexCode, chartType, eventTime });
const response = await apiRequest(url);
return response;
} catch (error) {
console.error('获取指数K线数据失败:', error);
logger.error('indexService', 'getKlineData', error, { indexCode, chartType });
throw error;
}
},

View File

@@ -1,15 +1,21 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/financialService.js
/**
* 完整的财务数据服务层
* 对应Flask后端的所有财务API接口
*/
import { logger } from '../utils/logger';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
const API_BASE_URL = getApiBase();
const apiRequest = async (url, options = {}) => {
try {
console.log(`Making Financial API request to: ${API_BASE_URL}${url}`);
logger.debug('financialService', 'API请求', {
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET'
});
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
@@ -22,16 +28,24 @@ const apiRequest = async (url, options = {}) => {
if (!response.ok) {
const errorText = await response.text();
console.error(`Financial API request failed: ${response.status} - ${errorText}`);
logger.error('financialService', 'apiRequest', new Error(`HTTP ${response.status}`), {
url,
status: response.status,
errorText: errorText.substring(0, 200)
});
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Financial API response from ${url}:`, data);
logger.debug('financialService', 'API响应', {
url,
success: data.success,
hasData: !!data.data
});
return data;
} catch (error) {
console.error(`Financial API request failed for ${url}:`, error);
logger.error('financialService', 'apiRequest', error, { url });
throw error;
}
};

View File

@@ -1,28 +1,29 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/industryService.js
import axios from 'axios';
// 判断当前是否是生产环境
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
const API_BASE_URL = getApiBase();
// 配置 axios 默认包含 credentials
axios.defaults.withCredentials = true;
export const industryService = {
// 获取所有行业分类体系
async getClassifications() {
const res = await axios.get(`${API_BASE_URL}/api/classifications`);
return res.data;
},
// 获取指定体系下的多级行业
async getLevels({ classification, level = 1, level1_name, level2_name, level3_name }) {
let url = `${API_BASE_URL}/api/levels?classification=${encodeURIComponent(classification)}&level=${level}`;
if (level1_name) url += `&level1_name=${encodeURIComponent(level1_name)}`;
if (level2_name) url += `&level2_name=${encodeURIComponent(level2_name)}`;
if (level3_name) url += `&level3_name=${encodeURIComponent(level3_name)}`;
/**
* 获取行业分类完整树形结构
* @param {string} classification - 可选,指定分类体系名称,不传则返回所有
* @returns {Promise} 返回树形结构数据
*/
async getClassifications(classification) {
let url = `${API_BASE_URL}/api/classifications`;
if (classification) {
url += `?classification=${encodeURIComponent(classification)}`;
}
const res = await axios.get(url);
return res.data;
}
// 注意getLevels 接口已废弃,使用 getClassifications 替代
};

View File

@@ -1,15 +1,21 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/marketService.js
/**
* 完整的市场行情数据服务层
* 对应Flask后端的所有市场API接口
*/
import { logger } from '../utils/logger';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? "" : process.env.REACT_APP_API_URL;
const API_BASE_URL = getApiBase();
const apiRequest = async (url, options = {}) => {
try {
console.log(`Making Market API request to: ${API_BASE_URL}${url}`);
logger.debug('marketService', 'API请求', {
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET'
});
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
@@ -22,16 +28,24 @@ const apiRequest = async (url, options = {}) => {
if (!response.ok) {
const errorText = await response.text();
console.error(`Market API request failed: ${response.status} - ${errorText}`);
logger.error('marketService', 'apiRequest', new Error(`HTTP ${response.status}`), {
url,
status: response.status,
errorText: errorText.substring(0, 200)
});
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Market API response from ${url}:`, data);
logger.debug('marketService', 'API响应', {
url,
success: data.success,
hasData: !!data.data
});
return data;
} catch (error) {
console.error(`Market API request failed for ${url}:`, error);
logger.error('marketService', 'apiRequest', error, { url });
throw error;
}
};
@@ -449,7 +463,7 @@ export const marketCacheManager = {
if (cached) {
const { data, timestamp } = cached;
if (Date.now() - timestamp < maxAge) {
console.log(`Cache hit for key: ${key}`);
logger.debug('marketCacheManager', '缓存命中', { key });
return data;
}
this.cache.delete(key);
@@ -467,15 +481,16 @@ export const marketCacheManager = {
data,
timestamp: Date.now()
});
console.log(`Data cached for key: ${key}`);
logger.debug('marketCacheManager', '数据已缓存', { key });
},
/**
* 清除所有缓存
*/
clear: function() {
const cacheSize = this.cache.size;
this.cache.clear();
console.log('All market cache cleared');
logger.debug('marketCacheManager', '清除所有缓存', { clearedCount: cacheSize });
},
/**
@@ -483,12 +498,17 @@ export const marketCacheManager = {
* @param {string} stockCode - 股票代码
*/
clearStock: function(stockCode) {
let clearedCount = 0;
for (const key of this.cache.keys()) {
if (key.includes(`market_${stockCode}_`)) {
this.cache.delete(key);
clearedCount++;
}
}
console.log(`Cache cleared for stock: ${stockCode}`);
logger.debug('marketCacheManager', '清除股票缓存', {
stockCode,
clearedCount
});
}
};

View File

@@ -0,0 +1,723 @@
// src/services/mockSocketService.js
/**
* Mock Socket 服务 - 用于开发环境模拟实时推送
* 模拟金融资讯、事件动向、分析报告等实时消息推送
*/
import { logger } from '../utils/logger';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../constants/notificationTypes';
// 模拟金融资讯数据
const mockFinancialNews = [
// ========== 公告通知 ==========
{
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '贵州茅台发布2024年度财报公告',
content: '2024年度营收同比增长15.2%净利润创历史新高董事会建议每10股派息180元',
publishTime: new Date('2024-03-28T15:30:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann001',
extra: {
announcementType: '财报',
companyCode: '600519',
companyName: '贵州茅台',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.URGENT,
title: '宁德时代发布重大资产重组公告',
content: '公司拟收购某新能源材料公司100%股权交易金额约120亿元预计增厚业绩20%',
publishTime: new Date('2024-03-28T09:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann002',
extra: {
announcementType: '重组',
companyCode: '300750',
companyName: '宁德时代',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.NORMAL,
title: '中国平安发布分红派息公告',
content: '2023年度利润分配方案每10股派发现金红利23.0元含税分红率达30.5%',
publishTime: new Date('2024-03-27T16:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann003',
extra: {
announcementType: '分红',
companyCode: '601318',
companyName: '中国平安',
},
autoClose: 10000,
},
// ========== 股票动向 ==========
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.URGENT,
title: '您关注的股票触发预警',
content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/stock-overview?code=300750',
extra: {
stockCode: '300750',
stockName: '宁德时代',
priceChange: '+5.2%',
currentPrice: '245.50',
triggerType: '目标价',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '您关注的股票异常波动',
content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/stock-overview?code=002594',
extra: {
stockCode: '002594',
stockName: '比亚迪',
priceChange: '-3.8%',
currentPrice: '198.20',
triggerType: '异常波动',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '持仓股票表现',
content: '隆基绿能(601012) 今日表现优异,涨幅 +4.5%,您当前持仓浮盈 +¥8,200',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/trading-simulation',
extra: {
stockCode: '601012',
stockName: '隆基绿能',
priceChange: '+4.5%',
profit: '+8200',
},
autoClose: 8000,
},
// ========== 事件动向 ==========
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '央行宣布降准0.5个百分点',
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点释放长期资金约1万亿元利好股市',
publishTime: new Date('2024-03-28T09:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt001',
extra: {
eventId: 'evt001',
relatedStocks: 12,
impactLevel: '重大利好',
sectors: ['银行', '地产', '基建'],
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '新能源汽车补贴政策延期',
content: '财政部宣布新能源汽车购置补贴政策延长至2024年底涉及比亚迪、理想汽车等5家龙头企业',
publishTime: new Date('2024-03-28T10:30:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt002',
extra: {
eventId: 'evt002',
relatedStocks: 5,
impactLevel: '重大利好',
sectors: ['新能源汽车'],
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '芯片产业扶持政策出台',
content: '工信部发布《半导体产业发展指导意见》未来三年投入500亿专项资金支持芯片研发',
publishTime: new Date('2024-03-27T14:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt003',
extra: {
eventId: 'evt003',
relatedStocks: 8,
impactLevel: '中长期利好',
sectors: ['半导体', '芯片设计'],
},
autoClose: 10000,
},
// ========== 预测通知 ==========
{
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: '详细报告生成中...',
relatedPredictionId: 'pred_001',
},
autoClose: 15000,
},
{
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_002',
},
autoClose: 15000,
},
// ========== 分析报告 ==========
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '医药行业深度报告:创新药迎来政策拐点',
content: 'CXO板块持续受益于全球创新药研发外包需求建议关注药明康德、凯莱英等龙头企业',
publishTime: new Date('2024-03-28T08:00:00').getTime(),
pushTime: Date.now(),
author: {
name: '李明',
organization: '中信证券',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=rpt001',
extra: {
reportType: '行业研报',
industry: '医药',
rating: '强烈推荐',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: 'AI产业链投资机会分析',
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
publishTime: new Date('2024-03-28T07:30:00').getTime(),
pushTime: Date.now(),
author: {
name: '王芳',
organization: '招商证券',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=rpt002',
extra: {
reportType: '策略报告',
industry: '人工智能',
rating: '推荐',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '比亚迪:新能源汽车龙头业绩持续超预期',
content: '2024年销量目标400万辆海外市场拓展顺利维持"买入"评级目标价280元',
publishTime: new Date('2024-03-27T09:00:00').getTime(),
pushTime: Date.now(),
author: {
name: '张伟',
organization: '国泰君安',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=rpt003',
extra: {
reportType: '公司研报',
industry: '新能源汽车',
rating: '买入',
targetPrice: '280',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '2024年A股市场展望结构性行情延续',
content: 'AI应用、高端制造、自主可控三大主线贯穿全年建议关注科技成长板块配置机会',
publishTime: new Date('2024-03-26T16:00:00').getTime(),
pushTime: Date.now(),
author: {
name: 'AI分析师',
organization: '价值前沿',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=rpt004',
extra: {
reportType: '策略报告',
industry: '市场策略',
rating: '谨慎乐观',
},
autoClose: 10000,
},
];
class MockSocketService {
constructor() {
this.connected = false;
this.listeners = new Map();
this.intervals = [];
this.messageQueue = [];
this.reconnectAttempts = 0;
this.customReconnectTimer = null;
this.failConnection = false; // 是否模拟连接失败
}
/**
* 计算指数退避延迟Mock 模式使用更短的时间便于测试)
* 第1次: 10秒, 第2次: 20秒, 第3次: 40秒, 第4次及以后: 40秒
*/
getReconnectionDelay(attempt) {
const delays = [10000, 20000, 40000]; // 10s, 20s, 40s (缩短10倍便于测试)
const index = Math.min(attempt - 1, delays.length - 1);
return delays[index];
}
/**
* 连接到 mock socket
*/
connect() {
if (this.connected) {
logger.warn('mockSocketService', 'Already connected');
return;
}
logger.info('mockSocketService', 'Connecting to mock socket service...');
// 模拟连接延迟
setTimeout(() => {
// 检查是否应该模拟连接失败
if (this.failConnection) {
logger.warn('mockSocketService', 'Simulated connection failure');
// 触发连接错误事件
this.emit('connect_error', {
message: 'Mock connection error for testing',
timestamp: Date.now(),
});
// 安排下次重连(会继续失败,直到 failConnection 被清除)
this.scheduleReconnection();
return;
}
// 正常连接成功
this.connected = true;
this.reconnectAttempts = 0;
// 清除自定义重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
logger.info('mockSocketService', 'Mock socket connected successfully');
// 触发连接成功事件
this.emit('connect', { timestamp: Date.now() });
// 在连接后3秒发送欢迎消息
setTimeout(() => {
if (this.connected) {
this.emit('new_event', {
type: 'system_notification',
severity: 'info',
title: '连接成功',
message: '实时消息推送服务已启动 (Mock 模式)',
timestamp: Date.now(),
autoClose: 5000,
});
}
}, 3000);
}, 1000);
}
/**
* 断开连接
* @param {boolean} triggerReconnect - 是否触发自动重连(模拟意外断开)
*/
disconnect(triggerReconnect = false) {
if (!this.connected) {
return;
}
logger.info('mockSocketService', 'Disconnecting from mock socket service...');
// 清除所有定时器
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
const wasConnected = this.connected;
this.connected = false;
this.emit('disconnect', {
timestamp: Date.now(),
reason: triggerReconnect ? 'transport close' : 'io client disconnect'
});
// 如果需要触发重连(模拟意外断开)
if (triggerReconnect && wasConnected) {
this.scheduleReconnection();
} else {
// 清除重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
this.reconnectAttempts = 0;
}
}
/**
* 使用指数退避策略安排重连
*/
scheduleReconnection() {
// 清除之前的定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
}
this.reconnectAttempts++;
const delay = this.getReconnectionDelay(this.reconnectAttempts);
logger.info('mockSocketService', `Scheduling reconnection in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
// 触发 connect_error 事件通知UI
this.emit('connect_error', {
message: 'Mock connection error for testing',
timestamp: Date.now(),
});
this.customReconnectTimer = setTimeout(() => {
if (!this.connected) {
logger.info('mockSocketService', 'Attempting reconnection...', {
attempt: this.reconnectAttempts,
});
this.connect();
}
}, delay);
}
/**
* 手动重连
* @returns {boolean} 是否触发重连
*/
reconnect() {
if (this.connected) {
logger.info('mockSocketService', 'Already connected, no need to reconnect');
return false;
}
logger.info('mockSocketService', 'Manually triggering reconnection...');
// 清除自动重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
// 重置重连计数
this.reconnectAttempts = 0;
// 立即触发重连
this.connect();
return true;
}
/**
* 模拟意外断线(测试用)
* @param {number} duration - 断线持续时间毫秒0表示需要手动重连
*/
simulateDisconnection(duration = 0) {
logger.info('mockSocketService', `Simulating disconnection${duration > 0 ? ` for ${duration}ms` : ' (manual reconnect required)'}...`);
if (duration > 0) {
// 短暂断线,自动重连
this.disconnect(true);
} else {
// 需要手动重连
this.disconnect(false);
}
}
/**
* 模拟持续连接失败(测试用)
* 连接会一直失败,直到调用 allowReconnection()
*/
simulateConnectionFailure() {
logger.info('mockSocketService', '🚫 Simulating persistent connection failure...');
logger.info('mockSocketService', 'Connection will keep failing until allowReconnection() is called');
// 设置失败标志
this.failConnection = true;
// 如果当前已连接,先断开并触发重连(会失败)
if (this.connected) {
this.disconnect(true);
} else {
// 如果未连接,直接触发一次连接尝试(会失败)
this.connect();
}
}
/**
* 允许重连成功(测试用)
* 清除连接失败标志,下次重连将会成功
*/
allowReconnection() {
logger.info('mockSocketService', '✅ Allowing reconnection to succeed...');
logger.info('mockSocketService', 'Next reconnection attempt will succeed');
// 清除失败标志
this.failConnection = false;
// 不立即重连,等待自动重连或手动重连
}
/**
* 监听事件
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
logger.info('mockSocketService', `Event listener added: ${event}`);
}
/**
* 移除事件监听
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
logger.info('mockSocketService', `Event listener removed: ${event}`);
}
// 如果没有监听器了,删除该事件
if (callbacks.length === 0) {
this.listeners.delete(event);
}
}
/**
* 触发事件
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('mockSocketService', 'emit', error, { event, data });
}
});
}
/**
* 启动模拟消息推送
* @param {number} interval - 推送间隔(毫秒)
* @param {number} burstCount - 每次推送的消息数量1-3条
*/
startMockPush(interval = 15000, burstCount = 1) {
if (!this.connected) {
logger.warn('mockSocketService', 'Cannot start mock push: not connected');
return;
}
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
const pushInterval = setInterval(() => {
// 随机选择 1-burstCount 条消息
const count = Math.floor(Math.random() * burstCount) + 1;
for (let i = 0; i < count; i++) {
// 从模拟数据中随机选择一条
const randomIndex = Math.floor(Math.random() * mockFinancialNews.length);
const alert = {
...mockFinancialNews[randomIndex],
timestamp: Date.now(),
id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
};
// 延迟发送(模拟层叠效果)
setTimeout(() => {
this.emit('new_event', alert);
logger.info('mockSocketService', 'Mock notification sent', alert);
}, i * 500); // 每条消息间隔500ms
}
}, interval);
this.intervals.push(pushInterval);
}
/**
* 停止模拟推送
*/
stopMockPush() {
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
logger.info('mockSocketService', 'Mock push stopped');
}
/**
* 手动触发一条测试消息
* @param {object} customData - 自定义消息数据(可选)
*/
sendTestNotification(customData = null) {
const notification = customData || {
type: 'trade_alert',
severity: 'info',
title: '测试消息',
message: '这是一条手动触发的测试消息',
timestamp: Date.now(),
autoClose: 5000,
id: `test_${Date.now()}`,
};
this.emit('new_event', notification);
logger.info('mockSocketService', 'Test notification sent', notification);
}
/**
* 获取连接状态
*/
isConnected() {
return this.connected;
}
/**
* 获取当前重连尝试次数
*/
getReconnectAttempts() {
return this.reconnectAttempts;
}
/**
* 获取最大重连次数Mock 模式无限重试)
*/
getMaxReconnectAttempts() {
return Infinity;
}
}
// 导出单例
export const mockSocketService = new MockSocketService();
// 开发模式下添加全局测试函数
if (process.env.NODE_ENV === 'development') {
window.__mockSocket = {
// 模拟意外断线(自动重连成功)
simulateDisconnection: () => {
logger.info('mockSocketService', '🔌 Simulating disconnection (will auto-reconnect)...');
mockSocketService.simulateDisconnection(1); // 触发自动重连
},
// 模拟持续连接失败
simulateConnectionFailure: () => {
logger.info('mockSocketService', '🚫 Simulating connection failure (will keep retrying)...');
mockSocketService.simulateConnectionFailure();
},
// 允许重连成功
allowReconnection: () => {
logger.info('mockSocketService', '✅ Allowing next reconnection to succeed...');
mockSocketService.allowReconnection();
},
// 获取连接状态
isConnected: () => {
const connected = mockSocketService.isConnected();
logger.info('mockSocketService', `Connection status: ${connected ? '✅ Connected' : '❌ Disconnected'}`);
return connected;
},
// 手动重连
reconnect: () => {
logger.info('mockSocketService', '🔄 Manually triggering reconnection...');
return mockSocketService.reconnect();
},
// 获取重连尝试次数
getAttempts: () => {
const attempts = mockSocketService.getReconnectAttempts();
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
return attempts;
},
};
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
logger.info('mockSocketService', ' __mockSocket.simulateDisconnection() - 模拟断线(自动重连成功)');
logger.info('mockSocketService', ' __mockSocket.simulateConnectionFailure() - 模拟连接失败(持续失败)');
logger.info('mockSocketService', ' __mockSocket.allowReconnection() - 允许重连成功');
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
}
export default mockSocketService;

View File

@@ -0,0 +1,402 @@
// src/services/notificationHistoryService.js
/**
* 通知历史记录服务
* 持久化存储通知历史,支持查询、筛选、搜索、导出
*/
import { logger } from '../utils/logger';
const STORAGE_KEY = 'notification_history';
const MAX_HISTORY_SIZE = 500; // 最多保留 500 条历史记录
class NotificationHistoryService {
constructor() {
this.history = this.loadHistory();
}
/**
* 从 localStorage 加载历史记录
*/
loadHistory() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// 确保是数组
return Array.isArray(parsed) ? parsed : [];
}
} catch (error) {
logger.error('notificationHistoryService', 'loadHistory', error);
}
return [];
}
/**
* 保存历史记录到 localStorage
*/
saveHistory() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
logger.debug('notificationHistoryService', 'History saved', {
count: this.history.length
});
} catch (error) {
logger.error('notificationHistoryService', 'saveHistory', error);
// localStorage 可能已满,尝试清理旧数据
if (error.name === 'QuotaExceededError') {
this.cleanup(100); // 清理 100 条
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
} catch (retryError) {
logger.error('notificationHistoryService', 'saveHistory retry failed', retryError);
}
}
}
}
/**
* 保存通知到历史记录
* @param {object} notification - 通知对象
*/
saveNotification(notification) {
const record = {
id: notification.id || `notif_${Date.now()}`,
notification: { ...notification }, // 深拷贝
receivedAt: Date.now(),
readAt: null,
clickedAt: null,
};
// 检查是否已存在(去重)
const existingIndex = this.history.findIndex(r => r.id === record.id);
if (existingIndex !== -1) {
logger.debug('notificationHistoryService', 'Notification already exists', { id: record.id });
return;
}
// 添加到历史记录开头
this.history.unshift(record);
// 限制最大数量
if (this.history.length > MAX_HISTORY_SIZE) {
this.history = this.history.slice(0, MAX_HISTORY_SIZE);
}
this.saveHistory();
logger.info('notificationHistoryService', 'Notification saved', { id: record.id });
}
/**
* 标记通知为已读
* @param {string} id - 通知 ID
*/
markAsRead(id) {
const record = this.history.find(r => r.id === id);
if (record && !record.readAt) {
record.readAt = Date.now();
this.saveHistory();
logger.debug('notificationHistoryService', 'Marked as read', { id });
}
}
/**
* 标记通知为已点击
* @param {string} id - 通知 ID
*/
markAsClicked(id) {
const record = this.history.find(r => r.id === id);
if (record && !record.clickedAt) {
record.clickedAt = Date.now();
// 点击也意味着已读
if (!record.readAt) {
record.readAt = Date.now();
}
this.saveHistory();
logger.debug('notificationHistoryService', 'Marked as clicked', { id });
}
}
/**
* 获取历史记录
* @param {object} filters - 筛选条件
* @param {string} filters.type - 通知类型
* @param {string} filters.priority - 优先级
* @param {string} filters.readStatus - 阅读状态 ('read' | 'unread' | 'all')
* @param {number} filters.startDate - 开始日期(时间戳)
* @param {number} filters.endDate - 结束日期(时间戳)
* @param {number} filters.page - 页码(从 1 开始)
* @param {number} filters.pageSize - 每页数量
* @returns {object} - { records, total, page, pageSize }
*/
getHistory(filters = {}) {
let filtered = [...this.history];
// 按类型筛选
if (filters.type && filters.type !== 'all') {
filtered = filtered.filter(r => r.notification.type === filters.type);
}
// 按优先级筛选
if (filters.priority && filters.priority !== 'all') {
filtered = filtered.filter(r => r.notification.priority === filters.priority);
}
// 按阅读状态筛选
if (filters.readStatus === 'read') {
filtered = filtered.filter(r => r.readAt !== null);
} else if (filters.readStatus === 'unread') {
filtered = filtered.filter(r => r.readAt === null);
}
// 按日期范围筛选
if (filters.startDate) {
filtered = filtered.filter(r => r.receivedAt >= filters.startDate);
}
if (filters.endDate) {
filtered = filtered.filter(r => r.receivedAt <= filters.endDate);
}
// 分页
const page = filters.page || 1;
const pageSize = filters.pageSize || 20;
const total = filtered.length;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const records = filtered.slice(startIndex, endIndex);
return {
records,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* 搜索历史记录
* @param {string} keyword - 搜索关键词
* @returns {array} - 匹配的记录
*/
searchHistory(keyword) {
if (!keyword || keyword.trim() === '') {
return [];
}
const lowerKeyword = keyword.toLowerCase();
const results = this.history.filter(record => {
const { title, content, message } = record.notification;
const searchText = `${title || ''} ${content || ''} ${message || ''}`.toLowerCase();
return searchText.includes(lowerKeyword);
});
logger.info('notificationHistoryService', 'Search completed', {
keyword,
resultsCount: results.length
});
return results;
}
/**
* 删除单条历史记录
* @param {string} id - 通知 ID
*/
deleteRecord(id) {
const initialLength = this.history.length;
this.history = this.history.filter(r => r.id !== id);
if (this.history.length < initialLength) {
this.saveHistory();
logger.info('notificationHistoryService', 'Record deleted', { id });
return true;
}
return false;
}
/**
* 批量删除历史记录
* @param {array} ids - 通知 ID 数组
*/
deleteRecords(ids) {
const initialLength = this.history.length;
this.history = this.history.filter(r => !ids.includes(r.id));
const deletedCount = initialLength - this.history.length;
if (deletedCount > 0) {
this.saveHistory();
logger.info('notificationHistoryService', 'Batch delete completed', {
deletedCount
});
}
return deletedCount;
}
/**
* 清空所有历史记录
*/
clearHistory() {
this.history = [];
this.saveHistory();
logger.info('notificationHistoryService', 'History cleared');
}
/**
* 清理旧数据
* @param {number} count - 要清理的数量
*/
cleanup(count) {
if (this.history.length > count) {
this.history = this.history.slice(0, -count);
this.saveHistory();
logger.info('notificationHistoryService', 'Cleanup completed', { count });
}
}
/**
* 获取统计数据
* @returns {object} - 统计信息
*/
getStats() {
const total = this.history.length;
const read = this.history.filter(r => r.readAt !== null).length;
const unread = total - read;
const clicked = this.history.filter(r => r.clickedAt !== null).length;
// 按类型统计
const byType = {};
const byPriority = {};
this.history.forEach(record => {
const { type, priority } = record.notification;
// 类型统计
if (type) {
byType[type] = (byType[type] || 0) + 1;
}
// 优先级统计
if (priority) {
byPriority[priority] = (byPriority[priority] || 0) + 1;
}
});
// 计算点击率
const clickRate = total > 0 ? ((clicked / total) * 100).toFixed(2) : '0.00';
return {
total,
read,
unread,
clicked,
clickRate,
byType,
byPriority,
};
}
/**
* 导出历史记录为 JSON
* @param {object} filters - 筛选条件(可选)
*/
exportToJSON(filters = {}) {
const { records } = this.getHistory(filters);
const exportData = {
exportedAt: new Date().toISOString(),
total: records.length,
records: records.map(r => ({
id: r.id,
notification: r.notification,
receivedAt: new Date(r.receivedAt).toISOString(),
readAt: r.readAt ? new Date(r.readAt).toISOString() : null,
clickedAt: r.clickedAt ? new Date(r.clickedAt).toISOString() : null,
})),
};
return JSON.stringify(exportData, null, 2);
}
/**
* 导出历史记录为 CSV
* @param {object} filters - 筛选条件(可选)
*/
exportToCSV(filters = {}) {
const { records } = this.getHistory(filters);
let csv = 'ID,Type,Priority,Title,Content,Received At,Read At,Clicked At\n';
records.forEach(record => {
const { id, notification, receivedAt, readAt, clickedAt } = record;
const { type, priority, title, content, message } = notification;
const escapeCsv = (str) => {
if (!str) return '';
return `"${String(str).replace(/"/g, '""')}"`;
};
csv += [
escapeCsv(id),
escapeCsv(type),
escapeCsv(priority),
escapeCsv(title),
escapeCsv(content || message),
new Date(receivedAt).toISOString(),
readAt ? new Date(readAt).toISOString() : '',
clickedAt ? new Date(clickedAt).toISOString() : '',
].join(',') + '\n';
});
return csv;
}
/**
* 触发下载文件
* @param {string} content - 文件内容
* @param {string} filename - 文件名
* @param {string} mimeType - MIME 类型
*/
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
logger.info('notificationHistoryService', 'File downloaded', { filename });
}
/**
* 导出并下载 JSON 文件
* @param {object} filters - 筛选条件(可选)
*/
downloadJSON(filters = {}) {
const json = this.exportToJSON(filters);
const filename = `notifications_${new Date().toISOString().split('T')[0]}.json`;
this.downloadFile(json, filename, 'application/json');
}
/**
* 导出并下载 CSV 文件
* @param {object} filters - 筛选条件(可选)
*/
downloadCSV(filters = {}) {
const csv = this.exportToCSV(filters);
const filename = `notifications_${new Date().toISOString().split('T')[0]}.csv`;
this.downloadFile(csv, filename, 'text/csv');
}
}
// 导出单例
export const notificationHistoryService = new NotificationHistoryService();
export default notificationHistoryService;

View File

@@ -0,0 +1,450 @@
// src/services/notificationMetricsService.js
/**
* 通知性能监控服务
* 追踪推送到达率、点击率、响应时间等指标
*/
import { logger } from '../utils/logger';
const STORAGE_KEY = 'notification_metrics';
const MAX_HISTORY_DAYS = 30; // 保留最近 30 天数据
class NotificationMetricsService {
constructor() {
this.metrics = this.loadMetrics();
}
/**
* 从 localStorage 加载指标数据
*/
loadMetrics() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// 清理过期数据
this.cleanOldData(parsed);
return parsed;
}
} catch (error) {
logger.error('notificationMetricsService', 'loadMetrics', error);
}
// 返回默认结构
return this.getDefaultMetrics();
}
/**
* 获取默认指标结构
*/
getDefaultMetrics() {
return {
summary: {
totalSent: 0,
totalReceived: 0,
totalClicked: 0,
totalDismissed: 0,
totalResponseTime: 0, // 总响应时间(毫秒)
},
byType: {
announcement: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
stock_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
event_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
analysis_report: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
},
byPriority: {
urgent: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
important: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
normal: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
},
hourlyDistribution: Array(24).fill(0), // 每小时推送分布
dailyData: {}, // 按日期存储的每日数据
lastUpdated: Date.now(),
};
}
/**
* 保存指标数据到 localStorage
*/
saveMetrics() {
try {
this.metrics.lastUpdated = Date.now();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
logger.debug('notificationMetricsService', 'Metrics saved', this.metrics.summary);
} catch (error) {
logger.error('notificationMetricsService', 'saveMetrics', error);
}
}
/**
* 清理过期数据
*/
cleanOldData(metrics) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - MAX_HISTORY_DAYS);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
if (metrics.dailyData) {
Object.keys(metrics.dailyData).forEach(date => {
if (date < cutoffDateStr) {
delete metrics.dailyData[date];
}
});
}
}
/**
* 获取当前日期字符串
*/
getTodayDateStr() {
return new Date().toISOString().split('T')[0];
}
/**
* 获取当前小时
*/
getCurrentHour() {
return new Date().getHours();
}
/**
* 确保每日数据结构存在
*/
ensureDailyData(dateStr) {
if (!this.metrics.dailyData[dateStr]) {
this.metrics.dailyData[dateStr] = {
sent: 0,
received: 0,
clicked: 0,
dismissed: 0,
};
}
}
/**
* 追踪通知发送
* @param {object} notification - 通知对象
*/
trackSent(notification) {
const { type, priority } = notification;
const today = this.getTodayDateStr();
const hour = this.getCurrentHour();
// 汇总统计
this.metrics.summary.totalSent++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].sent++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].sent++;
}
// 每小时分布
this.metrics.hourlyDistribution[hour]++;
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].sent++;
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked sent', { type, priority });
}
/**
* 追踪通知接收
* @param {object} notification - 通知对象
*/
trackReceived(notification) {
const { type, priority, id, timestamp } = notification;
const today = this.getTodayDateStr();
// 汇总统计
this.metrics.summary.totalReceived++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].received++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].received++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].received++;
// 存储接收时间(用于计算响应时间)
this.storeReceivedTime(id, timestamp || Date.now());
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked received', { type, priority });
}
/**
* 追踪通知点击
* @param {object} notification - 通知对象
*/
trackClicked(notification) {
const { type, priority, id } = notification;
const today = this.getTodayDateStr();
const clickTime = Date.now();
// 汇总统计
this.metrics.summary.totalClicked++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].clicked++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].clicked++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].clicked++;
// 计算响应时间
const receivedTime = this.getReceivedTime(id);
if (receivedTime) {
const responseTime = clickTime - receivedTime;
this.metrics.summary.totalResponseTime += responseTime;
this.removeReceivedTime(id);
logger.debug('notificationMetricsService', 'Response time', { responseTime });
}
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked clicked', { type, priority });
}
/**
* 追踪通知关闭
* @param {object} notification - 通知对象
*/
trackDismissed(notification) {
const { type, priority, id } = notification;
const today = this.getTodayDateStr();
// 汇总统计
this.metrics.summary.totalDismissed++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].dismissed++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].dismissed++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].dismissed++;
// 清理接收时间记录
this.removeReceivedTime(id);
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked dismissed', { type, priority });
}
/**
* 存储通知接收时间(用于计算响应时间)
*/
storeReceivedTime(id, timestamp) {
if (!this.receivedTimes) {
this.receivedTimes = new Map();
}
this.receivedTimes.set(id, timestamp);
}
/**
* 获取通知接收时间
*/
getReceivedTime(id) {
if (!this.receivedTimes) {
return null;
}
return this.receivedTimes.get(id);
}
/**
* 移除通知接收时间记录
*/
removeReceivedTime(id) {
if (this.receivedTimes) {
this.receivedTimes.delete(id);
}
}
/**
* 获取汇总统计
*/
getSummary() {
const summary = { ...this.metrics.summary };
// 计算平均响应时间
if (summary.totalClicked > 0) {
summary.avgResponseTime = Math.round(summary.totalResponseTime / summary.totalClicked);
} else {
summary.avgResponseTime = 0;
}
// 计算点击率
if (summary.totalReceived > 0) {
summary.clickRate = ((summary.totalClicked / summary.totalReceived) * 100).toFixed(2);
} else {
summary.clickRate = '0.00';
}
// 计算到达率(假设 sent = received
if (summary.totalSent > 0) {
summary.deliveryRate = ((summary.totalReceived / summary.totalSent) * 100).toFixed(2);
} else {
summary.deliveryRate = '100.00';
}
return summary;
}
/**
* 获取按类型统计
*/
getByType() {
const result = {};
Object.keys(this.metrics.byType).forEach(type => {
const data = this.metrics.byType[type];
result[type] = {
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
};
});
return result;
}
/**
* 获取按优先级统计
*/
getByPriority() {
const result = {};
Object.keys(this.metrics.byPriority).forEach(priority => {
const data = this.metrics.byPriority[priority];
result[priority] = {
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
};
});
return result;
}
/**
* 获取每小时分布
*/
getHourlyDistribution() {
return [...this.metrics.hourlyDistribution];
}
/**
* 获取每日数据
* @param {number} days - 获取最近多少天的数据
*/
getDailyData(days = 7) {
const result = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const data = this.metrics.dailyData[dateStr] || {
sent: 0,
received: 0,
clicked: 0,
dismissed: 0,
};
result.push({
date: dateStr,
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
});
}
return result;
}
/**
* 获取完整指标数据
*/
getAllMetrics() {
return {
summary: this.getSummary(),
byType: this.getByType(),
byPriority: this.getByPriority(),
hourlyDistribution: this.getHourlyDistribution(),
dailyData: this.getDailyData(30),
lastUpdated: this.metrics.lastUpdated,
};
}
/**
* 重置所有指标
*/
reset() {
this.metrics = this.getDefaultMetrics();
this.saveMetrics();
logger.info('notificationMetricsService', 'Metrics reset');
}
/**
* 导出指标数据为 JSON
*/
exportToJSON() {
return JSON.stringify(this.getAllMetrics(), null, 2);
}
/**
* 导出指标数据为 CSV
*/
exportToCSV() {
const summary = this.getSummary();
const dailyData = this.getDailyData(30);
let csv = '# Summary\n';
csv += 'Metric,Value\n';
csv += `Total Sent,${summary.totalSent}\n`;
csv += `Total Received,${summary.totalReceived}\n`;
csv += `Total Clicked,${summary.totalClicked}\n`;
csv += `Total Dismissed,${summary.totalDismissed}\n`;
csv += `Delivery Rate,${summary.deliveryRate}%\n`;
csv += `Click Rate,${summary.clickRate}%\n`;
csv += `Avg Response Time,${summary.avgResponseTime}ms\n\n`;
csv += '# Daily Data\n';
csv += 'Date,Sent,Received,Clicked,Dismissed,Click Rate\n';
dailyData.forEach(day => {
csv += `${day.date},${day.sent},${day.received},${day.clicked},${day.dismissed},${day.clickRate}%\n`;
});
return csv;
}
}
// 导出单例
export const notificationMetricsService = new NotificationMetricsService();
export default notificationMetricsService;

View File

@@ -0,0 +1,28 @@
// src/services/socket/index.js
/**
* Socket 服务统一导出
* 根据环境变量自动选择使用 Mock 或真实 Socket.IO 服务
*/
import { mockSocketService } from '../mockSocketService';
import { socketService } from '../socketService';
// 判断是否使用 Mock
const useMock = process.env.REACT_APP_ENABLE_MOCK === 'true' || process.env.REACT_APP_USE_MOCK_SOCKET === 'true';
// 根据环境选择服务
export const socket = useMock ? mockSocketService : socketService;
// 同时导出两个服务,方便测试和调试
export { mockSocketService, socketService };
// 导出服务类型标识
export const SOCKET_TYPE = useMock ? 'MOCK' : 'REAL';
// 打印当前使用的服务类型
console.log(
`%c[Socket Service] Using ${SOCKET_TYPE} Socket Service`,
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
);
export default socket;

View File

@@ -0,0 +1,464 @@
// src/services/socketService.js
/**
* 真实 Socket.IO 服务 - 用于生产环境连接真实后端
*/
import { io } from 'socket.io-client';
import { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig';
const API_BASE_URL = getApiBase();
class SocketService {
constructor() {
this.socket = null;
this.connected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = Infinity; // 无限重试
this.customReconnectTimer = null; // 自定义重连定时器
}
/**
* 计算指数退避延迟
* 第1次: 60秒, 第2次: 120秒, 第3次: 240秒, 第4次及以后: 240秒
*/
getReconnectionDelay(attempt) {
const delays = [60000, 120000, 240000]; // 1min, 2min, 4min
const index = Math.min(attempt - 1, delays.length - 1);
return delays[index];
}
/**
* 连接到 Socket.IO 服务器
* @param {object} options - 连接选项
*/
connect(options = {}) {
if (this.socket && this.connected) {
logger.warn('socketService', 'Already connected');
return;
}
logger.info('socketService', 'Connecting to Socket.IO server...', { url: API_BASE_URL });
// 创建 socket 连接 - 禁用 Socket.IO 自带的重连机制,使用自定义指数退避
this.socket = io(API_BASE_URL, {
transports: ['websocket', 'polling'],
reconnection: false, // 禁用自动重连,改用自定义策略
timeout: 20000,
autoConnect: true,
withCredentials: true, // 允许携带认证信息
...options,
});
// 监听连接成功
this.socket.on('connect', () => {
this.connected = true;
this.reconnectAttempts = 0;
// 清除自定义重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
});
// 监听断开连接
this.socket.on('disconnect', (reason) => {
const wasConnected = this.connected;
this.connected = false;
logger.warn('socketService', 'Socket.IO disconnected', { reason });
// 如果是意外断开(非主动断开),触发自定义重连
if (wasConnected && reason !== 'io client disconnect') {
this.scheduleReconnection();
}
});
// 监听连接错误
this.socket.on('connect_error', (error) => {
this.reconnectAttempts++;
logger.error('socketService', 'connect_error', error, {
attempts: this.reconnectAttempts,
});
// 使用指数退避策略安排下次重连
this.scheduleReconnection();
});
}
/**
* 使用指数退避策略安排重连
*/
scheduleReconnection() {
// 清除之前的定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
}
const delay = this.getReconnectionDelay(this.reconnectAttempts);
logger.info('socketService', `Scheduling reconnection in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
this.customReconnectTimer = setTimeout(() => {
if (!this.connected && this.socket) {
logger.info('socketService', 'Attempting reconnection...', {
attempt: this.reconnectAttempts,
});
this.socket.connect();
}
}, delay);
}
/**
* 断开连接
*/
disconnect() {
if (!this.socket) {
return;
}
logger.info('socketService', 'Disconnecting from Socket.IO server...');
// 清除自定义重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
this.socket.disconnect();
this.socket = null;
this.connected = false;
this.reconnectAttempts = 0;
}
/**
* 监听事件
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.socket) {
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
return;
}
this.socket.on(event, callback);
logger.info('socketService', `Event listener added: ${event}`);
}
/**
* 移除事件监听
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数(可选)
*/
off(event, callback) {
if (!this.socket) {
return;
}
if (callback) {
this.socket.off(event, callback);
} else {
this.socket.off(event);
}
logger.info('socketService', `Event listener removed: ${event}`);
}
/**
* 发送消息到服务器
* @param {string} event - 事件名称
* @param {*} data - 发送的数据
* @param {Function} callback - 确认回调(可选)
*/
emit(event, data, callback) {
if (!this.socket || !this.connected) {
logger.warn('socketService', 'Cannot emit: socket not connected', { event, data });
return;
}
if (callback) {
this.socket.emit(event, data, callback);
} else {
this.socket.emit(event, data);
}
logger.info('socketService', `Event emitted: ${event}`, data);
}
/**
* 加入房间
* @param {string} room - 房间名称
*/
joinRoom(room) {
this.emit('join_room', { room });
}
/**
* 离开房间
* @param {string} room - 房间名称
*/
leaveRoom(room) {
this.emit('leave_room', { room });
}
/**
* 获取连接状态
*/
isConnected() {
return this.connected;
}
/**
* 获取 Socket ID
*/
getSocketId() {
return this.socket?.id || null;
}
/**
* 手动重连
* @returns {boolean} 是否触发重连
*/
reconnect() {
if (!this.socket) {
logger.warn('socketService', 'Cannot reconnect: socket not initialized');
return false;
}
if (this.connected) {
logger.info('socketService', 'Already connected, no need to reconnect');
return false;
}
logger.info('socketService', 'Manually triggering reconnection...');
// 清除自动重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
// 重置重连计数
this.reconnectAttempts = 0;
// 立即触发重连
this.socket.connect();
return true;
}
/**
* 获取当前重连尝试次数
*/
getReconnectAttempts() {
return this.reconnectAttempts;
}
/**
* 获取最大重连次数
*/
getMaxReconnectAttempts() {
return this.maxReconnectAttempts;
}
// ==================== 事件推送专用方法 ====================
/**
* 订阅事件推送
* @param {object} options - 订阅选项
* @param {string} options.eventType - 事件类型 ('all' | 'policy' | 'market' | 'tech' | ...)
* @param {string} options.importance - 重要性 ('all' | 'S' | 'A' | 'B' | 'C')
* @param {Function} options.onNewEvent - 收到新事件时的回调函数
* @param {Function} options.onSubscribed - 订阅成功的回调函数(可选)
*/
subscribeToEvents(options = {}) {
const {
eventType = 'all',
importance = 'all',
onNewEvent,
onSubscribed,
} = options;
if (!this.socket || !this.connected) {
logger.warn('socketService', 'Cannot subscribe: socket not connected');
// 自动连接
this.connect();
// 等待连接成功后再订阅
this.socket.once('connect', () => {
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
});
return;
}
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
}
/**
* 执行订阅操作(内部方法)
*/
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
console.log('\n========== [SocketService DEBUG] 开始订阅 ==========');
console.log('[SocketService DEBUG] 事件类型:', eventType);
console.log('[SocketService DEBUG] 重要性:', importance);
console.log('[SocketService DEBUG] Socket 连接状态:', this.connected);
console.log('[SocketService DEBUG] Socket ID:', this.socket?.id);
// 发送订阅请求
const subscribeData = {
event_type: eventType,
importance: importance,
};
console.log('[SocketService DEBUG] 准备发送 subscribe_events:', subscribeData);
this.emit('subscribe_events', subscribeData);
console.log('[SocketService DEBUG] ✓ 已发送 subscribe_events');
// 监听订阅确认
this.socket.once('subscription_confirmed', (data) => {
console.log('\n[SocketService DEBUG] ========== 收到订阅确认 ==========');
console.log('[SocketService DEBUG] 订阅确认数据:', data);
logger.info('socketService', 'Subscription confirmed', data);
if (onSubscribed) {
console.log('[SocketService DEBUG] 调用 onSubscribed 回调');
onSubscribed(data);
}
console.log('[SocketService DEBUG] ========== 订阅确认处理完成 ==========\n');
});
// 监听订阅错误
this.socket.once('subscription_error', (error) => {
console.error('\n[SocketService ERROR] ========== 订阅错误 ==========');
console.error('[SocketService ERROR] 错误信息:', error);
logger.error('socketService', 'Subscription error', error);
console.error('[SocketService ERROR] ========== 订阅错误处理完成 ==========\n');
});
// 监听新事件推送
if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// 先移除之前的监听器(避免重复)
this.socket.off('new_event');
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
// 添加新的监听器
this.socket.on('new_event', (eventData) => {
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
console.log('[SocketService DEBUG] 事件数据:', eventData);
console.log('[SocketService DEBUG] 事件 ID:', eventData?.id);
console.log('[SocketService DEBUG] 事件标题:', eventData?.title);
logger.info('socketService', 'New event received', eventData);
console.log('[SocketService DEBUG] 准备调用 onNewEvent 回调');
onNewEvent(eventData);
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
});
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
}
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
}
/**
* 取消订阅事件推送
* @param {object} options - 取消订阅选项
* @param {string} options.eventType - 事件类型
* @param {Function} options.onUnsubscribed - 取消订阅成功的回调函数(可选)
*/
unsubscribeFromEvents(options = {}) {
const {
eventType = 'all',
onUnsubscribed,
} = options;
if (!this.socket || !this.connected) {
logger.warn('socketService', 'Cannot unsubscribe: socket not connected');
return;
}
// 发送取消订阅请求
this.emit('unsubscribe_events', {
event_type: eventType,
});
// 监听取消订阅确认
this.socket.once('unsubscription_confirmed', (data) => {
logger.info('socketService', 'Unsubscription confirmed', data);
// 移除新事件监听器
this.socket.off('new_event');
if (onUnsubscribed) {
onUnsubscribed(data);
}
});
// 监听取消订阅错误
this.socket.once('unsubscription_error', (error) => {
logger.error('socketService', 'Unsubscription error', error);
});
}
/**
* 快捷方法:订阅所有类型的事件
* @param {Function} onNewEvent - 收到新事件时的回调函数
* @returns {Function} 取消订阅的函数
*/
subscribeToAllEvents(onNewEvent) {
this.subscribeToEvents({
eventType: 'all',
importance: 'all',
onNewEvent,
});
// 返回取消订阅的清理函数
return () => {
this.unsubscribeFromEvents({ eventType: 'all' });
};
}
/**
* 快捷方法:订阅特定重要性的事件
* @param {string} importance - 重要性级别 ('S' | 'A' | 'B' | 'C')
* @param {Function} onNewEvent - 收到新事件时的回调函数
* @returns {Function} 取消订阅的函数
*/
subscribeToImportantEvents(importance, onNewEvent) {
this.subscribeToEvents({
eventType: 'all',
importance,
onNewEvent,
});
// 返回取消订阅的清理函数
return () => {
this.unsubscribeFromEvents({ eventType: 'all' });
};
}
/**
* 快捷方法:订阅特定类型的事件
* @param {string} eventType - 事件类型
* @param {Function} onNewEvent - 收到新事件时的回调函数
* @returns {Function} 取消订阅的函数
*/
subscribeToEventType(eventType, onNewEvent) {
this.subscribeToEvents({
eventType,
importance: 'all',
onNewEvent,
});
// 返回取消订阅的清理函数
return () => {
this.unsubscribeFromEvents({ eventType });
};
}
}
// 导出单例
export const socketService = new SocketService();
export default socketService;

56
src/utils/apiConfig.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* API 配置工具
* 提供统一的 API 基础地址获取方法
*/
/**
* 获取 API 基础 URL
*
* 工作原理:
* - 生产环境: 返回空字符串,使用相对路径
* - Mock 模式 (REACT_APP_API_URL=""): 返回空字符串,让 MSW 拦截请求
* - 开发模式: 返回后端服务器地址
*
* @returns {string} API 基础地址
*
* @example
* const response = await fetch(getApiBase() + '/api/users');
*/
export const getApiBase = () => {
// 生产环境使用相对路径
if (process.env.NODE_ENV === 'production') {
return '';
}
// 检查是否定义了 REACT_APP_API_URL包括空字符串
// 使用 !== undefined 而不是 || 运算符,正确处理空字符串
const apiUrl = process.env.REACT_APP_API_URL;
if (apiUrl !== undefined) {
return apiUrl; // Mock 模式下返回 '',其他情况返回配置的值
}
// 未配置时的默认后端地址
return 'http://49.232.185.254:5001';
};
/**
* 检查是否处于 Mock 模式
* @returns {boolean}
*/
export const isMockMode = () => {
return process.env.REACT_APP_ENABLE_MOCK === 'true';
};
/**
* 获取完整的 API URL
* @param {string} path - API 路径,应以 / 开头
* @returns {string} 完整的 URL
*
* @example
* const url = getApiUrl('/api/users');
* // Mock 模式: '/api/users'
* // 开发模式: 'http://49.232.185.254:5001/api/users'
*/
export const getApiUrl = (path) => {
return getApiBase() + path;
};

Some files were not shown because too many files have changed in this diff Show More