Compare commits
159 Commits
main
...
feature_lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad21398e1c | ||
|
|
0e1cc11330 | ||
|
|
e9b54ce10d | ||
|
|
e5ab99bae6 | ||
|
|
8632e40c94 | ||
|
|
173b13bc70 | ||
|
|
02cd234def | ||
|
|
e3a953559f | ||
|
|
78e4b8f696 | ||
|
|
1cf6169370 | ||
|
|
e3721b22ff | ||
|
|
357b8bbdd7 | ||
|
|
c6a6444d9a | ||
|
|
c42a14aa8f | ||
|
|
cddd0e860e | ||
|
|
fbe3434521 | ||
|
|
bca2ad4f81 | ||
|
|
8f3af4ed07 | ||
|
|
fb76e442f7 | ||
|
|
6506cb222b | ||
|
|
542b20368e | ||
|
|
d456c3cd5f | ||
|
|
b221c2669c | ||
|
|
356f865f09 | ||
| 71df2b605b | |||
| 5892dc3156 | |||
|
|
e05ea154a2 | ||
| 8787d5ddb7 | |||
|
|
c33181a689 | ||
| 29f035b1cf | |||
| 513134f285 | |||
|
|
7da50aca40 | ||
|
|
72aae585d0 | ||
| 24c6c9e1c6 | |||
|
|
58254d3e8f | ||
|
|
760ce4d5e1 | ||
|
|
95c1eaf97b | ||
|
|
657c446594 | ||
|
|
10f519a764 | ||
|
|
f072256021 | ||
|
|
0e3bdc9b8c | ||
|
|
5e4c4e7cea | ||
|
|
31a7500388 | ||
|
|
03c113fe1b | ||
|
|
0f3bc06716 | ||
|
|
e568b5e05f | ||
| c5aaaabf17 | |||
| 9ede603c9f | |||
|
|
629c63f4ee | ||
|
|
d6bc2c7245 | ||
|
|
dc38199ae6 | ||
|
|
d93b5de319 | ||
|
|
199a54bc12 | ||
|
|
39feae87a6 | ||
|
|
a9dc1191bf | ||
|
|
227e1c9d15 | ||
|
|
b5cdceb92b | ||
|
|
aacbe5c31c | ||
|
|
197c792219 | ||
|
|
794581e429 | ||
|
|
b06d51813a | ||
|
|
5b25136c28 | ||
|
|
97c5ce0d4d | ||
|
|
f1bd9680b6 | ||
|
|
f02d0d0bd0 | ||
|
|
aa332537d4 | ||
|
|
b4b7eae1ba | ||
|
|
4559c57a62 | ||
|
|
9eb13206cc | ||
|
|
8db9a9429e | ||
|
|
916537f25b | ||
|
|
3d90ae7f74 | ||
|
|
3580385967 | ||
|
|
67c3d3a875 | ||
|
|
65d0ec5354 | ||
|
|
05307d6501 | ||
|
|
a5702b631c | ||
|
|
a96f778779 | ||
|
|
0a0d617b20 | ||
|
|
506f89e64e | ||
|
|
094793c022 | ||
|
|
873adda1fd | ||
|
|
b0ae5a2871 | ||
|
|
6f34cab6d1 | ||
|
|
5aebd4b113 | ||
|
|
70f2676c79 | ||
|
|
0b316a5ed8 | ||
|
|
72a009e1ae | ||
|
|
a92d556486 | ||
| 6df66abcb4 | |||
| 16d04a6d28 | |||
|
|
3f881d000b | ||
|
|
801113b7e5 | ||
|
|
e0cd71880b | ||
|
|
10a4dcb5d5 | ||
|
|
9429eb0559 | ||
|
|
e69f822150 | ||
|
|
13c3c74b92 | ||
|
|
bcf81f4d47 | ||
|
|
f0d30244d2 | ||
|
|
f2cdc0756c | ||
|
|
e91656d332 | ||
| 62d6487cbb | |||
| 246adf4538 | |||
| 8dcf643db7 | |||
|
|
5eb4227e29 | ||
|
|
34a6c402c4 | ||
|
|
6ad38594bb | ||
|
|
1ba8b8fd2f | ||
|
|
45b88309b3 | ||
|
|
28975f74e9 | ||
|
|
4eaeab521f | ||
|
|
9dcd4bfbf3 | ||
|
|
d2988d1a33 | ||
|
|
30520542c8 | ||
|
|
035bb9a66d | ||
|
|
8bd7f59d35 | ||
| 37eba48906 | |||
| 9ad2dc7fab | |||
| 0b1591c3dd | |||
| 0a28f235d3 | |||
|
|
db0d0ed269 | ||
|
|
43229a21c0 | ||
|
|
35198aa548 | ||
|
|
1f3fe8ce39 | ||
|
|
a9fee411ea | ||
|
|
433a982a20 | ||
|
|
cc210f9fda | ||
|
|
23188d5690 | ||
|
|
09c9273190 | ||
|
|
c93f689954 | ||
|
|
38499ce650 | ||
|
|
955e0db740 | ||
|
|
98653f042b | ||
|
|
eef383f56f | ||
| 74968d5bc8 | |||
| cfb00ba895 | |||
| 4b6d86e923 | |||
|
|
d32cd616de | ||
|
|
31eb322ecc | ||
|
|
5a3a3ad42b | ||
|
|
6c96299b8f | ||
|
|
d695f8ff7b | ||
|
|
b2681231b0 | ||
|
|
44f9fea624 | ||
|
|
923611f3a8 | ||
|
|
c0aaa5bde1 | ||
|
|
5eab62c673 | ||
|
|
47fcb570c0 | ||
|
|
a7695c7365 | ||
|
|
4ebb17190f | ||
|
|
87b77af187 | ||
|
|
3a3cac75f7 | ||
|
|
c1bea7a75d | ||
|
|
32121c416e | ||
|
|
ea627f867e | ||
|
|
3821b88f28 | ||
|
|
b46ee4a18e | ||
|
|
36558e0715 |
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"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:*)",
|
||||
"Bash(npm cache clean --force)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm run start:mock)",
|
||||
"Bash(npm install fsevents@latest --save-optional --force)",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(ps -p 20502,53360 -o pid,command)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
63
.env.deploy.example
Normal file
63
.env.deploy.example
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
# 开发环境配置(连接真实后端)
|
||||
# 使用方式: npm start
|
||||
# 使用方式: npm run start:dev
|
||||
|
||||
# React 构建优化配置
|
||||
GENERATE_SOURCEMAP=false
|
||||
@@ -18,3 +18,10 @@ REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 开发环境标识
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# PostHog 配置(开发环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
32
.env.mock
32
.env.mock
@@ -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,11 +25,24 @@ 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 环境标识
|
||||
REACT_APP_ENV=mock
|
||||
|
||||
# PostHog 配置(Mock 环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# PostHog Debug 模式(Mock 环境永久启用)
|
||||
# 在浏览器 Console 中打印详细的事件追踪日志
|
||||
REACT_APP_POSTHOG_DEBUG=true
|
||||
|
||||
42
.env.test
Normal file
42
.env.test
Normal file
@@ -0,0 +1,42 @@
|
||||
# ========================================
|
||||
# 本地测试环境(前后端都在本地)
|
||||
# ========================================
|
||||
# 使用方式: npm run start:test
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. concurrently 同时启动前端和后端
|
||||
# 2. 前端: localhost:3000
|
||||
# 3. 后端: localhost:5001 (python app_2.py)
|
||||
# 4. 数据: 本地数据库
|
||||
#
|
||||
# 适用场景:
|
||||
# - 调试后端代码
|
||||
# - 性能测试
|
||||
# - 离线开发
|
||||
# - 数据库调试
|
||||
# ========================================
|
||||
|
||||
# 环境标识
|
||||
REACT_APP_ENV=test
|
||||
NODE_ENV=development
|
||||
|
||||
# Mock 配置(关闭 MSW)
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 后端 API 地址(本地后端)
|
||||
REACT_APP_API_URL=http://localhost:5001
|
||||
|
||||
# PostHog 配置(测试环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# React 构建优化配置
|
||||
GENERATE_SOURCEMAP=true # 测试环境保留 sourcemap 便于调试
|
||||
SKIP_PREFLIGHT_CHECK=true
|
||||
DISABLE_ESLINT_PLUGIN=false # 测试环境开启 ESLint
|
||||
TSC_COMPILE_ON_ERROR=true
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
415
API_ENDPOINTS.md
Normal 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
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
307
DARK_MODE_TEST.md
Normal 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. 刷新页面
|
||||
|
||||
#### 方法 C:Chakra 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
626
ENHANCED_FEATURES_GUIDE.md
Normal 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
|
||||
370
MESSAGE_PUSH_INTEGRATION_TEST.md
Normal file
370
MESSAGE_PUSH_INTEGRATION_TEST.md
Normal 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 onNewEvent(Toast + 列表更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 整合完成
|
||||
|
||||
所有代码和功能已经就绪!你现在可以:
|
||||
|
||||
1. ✅ 在 Mock 模式下测试实时推送
|
||||
2. ✅ 在 Real 模式下连接后端
|
||||
3. ✅ 查看右下角通知卡片
|
||||
4. ✅ 体验事件列表实时更新
|
||||
5. ✅ 随时切换 Mock/Real 模式
|
||||
|
||||
**祝测试顺利!🎉**
|
||||
695
MOCK_DATA_CENTER_SUPPLEMENT.md
Normal file
695
MOCK_DATA_CENTER_SUPPLEMENT.md
Normal 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个API(Promise.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` - 登录弹窗改造计划
|
||||
|
||||
280
NOTIFICATION_OPTIMIZATION_SUMMARY.md
Normal file
280
NOTIFICATION_OPTIMIZATION_SUMMARY.md
Normal 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
1551
NOTIFICATION_SYSTEM.md
Normal file
File diff suppressed because it is too large
Load Diff
546
WEBSOCKET_INTEGRATION_GUIDE.md
Normal file
546
WEBSOCKET_INTEGRATION_GUIDE.md
Normal 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
|
||||
|
||||
---
|
||||
|
||||
**完成!🎉** 现在你的前端可以实时接收事件推送了!
|
||||
BIN
__pycache__/app.cpython-310.pyc
Normal file
BIN
__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'
|
||||
)
|
||||
504
app/models.py
504
app/models.py
@@ -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'),
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# 路由包初始化文件
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
24
package.json
24
package.json
@@ -20,6 +20,7 @@
|
||||
"@fullcalendar/react": "^5.9.0",
|
||||
"@react-three/drei": "^9.11.3",
|
||||
"@react-three/fiber": "^8.0.27",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/visx": "^3.12.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"react": "18.3.1",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
@@ -59,6 +61,7 @@
|
||||
"react-leaflet": "^3.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-quill": "^2.0.0-beta.4",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
@@ -90,22 +93,31 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco start",
|
||||
"start:mock": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||
"prestart": "kill-port 3000",
|
||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
|
||||
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
|
||||
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
|
||||
"dev": "npm start",
|
||||
"backend": "python app_2.py",
|
||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses",
|
||||
"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"
|
||||
"clean": "rm -rf node_modules/ package-lock.json",
|
||||
"reinstall": "npm run clean && npm install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"ajv": "^8.17.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^8.2.2",
|
||||
"env-cmd": "^11.0.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.4.0",
|
||||
@@ -114,6 +126,7 @@
|
||||
"imagemin": "^9.0.1",
|
||||
"imagemin-mozjpeg": "^10.0.0",
|
||||
"imagemin-pngquant": "^10.0.0",
|
||||
"kill-port": "^2.0.1",
|
||||
"msw": "^2.11.5",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "2.2.1",
|
||||
@@ -140,5 +153,8 @@
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "^2.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.11.5'
|
||||
const PACKAGE_VERSION = '2.11.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
392
scripts/deploy-from-local.sh
Executable file
392
scripts/deploy-from-local.sh
Executable 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
313
scripts/deploy-on-server.sh
Executable 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
234
scripts/notify-wechat.sh
Executable 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
187
scripts/rollback-from-local.sh
Executable 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
176
scripts/rollback-on-server.sh
Executable 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
407
scripts/setup-deployment.sh
Executable 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 "$@"
|
||||
176
src/App.js
176
src/App.js
@@ -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";
|
||||
@@ -41,21 +40,91 @@ const StockOverview = React.lazy(() => import("views/StockOverview"));
|
||||
const EventDetail = React.lazy(() => import("views/EventDetail"));
|
||||
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||
|
||||
// Redux
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
|
||||
// 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";
|
||||
|
||||
// PostHog Redux 集成
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { initializePostHog } from "store/slices/posthogSlice";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
dispatch(initializePostHog());
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
{/* Socket 连接状态条 */}
|
||||
<ConnectionStatusBarWrapper />
|
||||
|
||||
{/* 路由切换时自动滚动到顶部 */}
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
@@ -131,30 +200,68 @@ function AppContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 事件详情独立页面路由 (不经 Admin 布局) */}
|
||||
<Route path="event-detail/:eventId" element={<EventDetail />} />
|
||||
|
||||
{/* 公司相关页面 */}
|
||||
<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>
|
||||
|
||||
{/* 管理后台路由 - 需要登录,不使用 MainLayout */}
|
||||
{/* 这些路由有自己的加载状态处理 */}
|
||||
{/* 事件详情独立页面路由 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="admin/*"
|
||||
path="event-detail/:eventId"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader message="加载中..." />}>
|
||||
<ProtectedRoute>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
</Suspense>
|
||||
<ProtectedRouteRedirect>
|
||||
<EventDetail />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司相关页面 */}
|
||||
{/* 财报预测 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="forecast-report"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<ForecastReport />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 财务全景 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="Financial"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FinancialPanorama />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司页面 - 需要登录(弹窗模式) */}
|
||||
<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 +279,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,15 +306,32 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<ReduxProvider store={store}>
|
||||
<ChakraProvider
|
||||
theme={theme}
|
||||
toastOptions={{
|
||||
defaultOptions: {
|
||||
position: 'top',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<NotificationProvider>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<IndustryProvider>
|
||||
<AppContent />
|
||||
<AuthModalManager />
|
||||
<NotificationContainer />
|
||||
<NotificationTestTool />
|
||||
</IndustryProvider>
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
</NotificationProvider>
|
||||
</ErrorBoundary>
|
||||
</ChakraProvider>
|
||||
</ReduxProvider>
|
||||
);
|
||||
}
|
||||
@@ -30,11 +30,14 @@ 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';
|
||||
import { useAuthEvents } from '../../hooks/useAuthEvents';
|
||||
|
||||
// 统一配置对象
|
||||
const AUTH_CONFIG = {
|
||||
@@ -65,6 +68,7 @@ export default function AuthFormContent() {
|
||||
const navigate = useNavigate();
|
||||
const { checkSession } = useAuth();
|
||||
const { handleLoginSuccess } = useAuthModal();
|
||||
const { showWelcomeGuide } = useNotification();
|
||||
|
||||
// 使用统一配置
|
||||
const config = AUTH_CONFIG;
|
||||
@@ -83,8 +87,14 @@ export default function AuthFormContent() {
|
||||
|
||||
// 响应式布局配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'AuthFormContent',
|
||||
isMobile: isMobile
|
||||
});
|
||||
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({
|
||||
@@ -104,6 +114,16 @@ export default function AuthFormContent() {
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
|
||||
if (name === 'phone' && value.length === 1 && !formData.phone) {
|
||||
authEvents.trackPhoneLoginInitiated(value);
|
||||
}
|
||||
|
||||
// 追踪验证码输入变化
|
||||
if (name === 'verificationCode') {
|
||||
authEvents.trackVerificationCodeInputChanged(value.length);
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时逻辑
|
||||
@@ -141,6 +161,10 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
// 追踪手机号验证失败
|
||||
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
|
||||
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号');
|
||||
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -149,19 +173,27 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪手机号验证通过
|
||||
authEvents.trackPhoneNumberValidated(credential, true);
|
||||
|
||||
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 +202,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 +211,55 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
toast({
|
||||
title: "验证码已发送",
|
||||
description: "验证码已发送到您的手机号",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
// 追踪验证码发送成功 (或重发)
|
||||
const isResend = verificationCodeSent;
|
||||
if (isResend) {
|
||||
authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1);
|
||||
} else {
|
||||
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
|
||||
}
|
||||
|
||||
// ❌ 移除成功 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) {
|
||||
// 追踪验证码发送失败
|
||||
authEvents.trackVerificationCodeSendFailed(credential, error);
|
||||
authEvents.trackError('api', error.message || '发送验证码失败', {
|
||||
endpoint: '/api/auth/send-verification-code',
|
||||
phone_masked: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
|
||||
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 +273,7 @@ export default function AuthFormContent() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { phone, verificationCode, nickname } = formData;
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 表单验证
|
||||
if (!phone || !verificationCode) {
|
||||
@@ -232,20 +295,29 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪验证码提交
|
||||
authEvents.trackVerificationCodeSubmitted(phone);
|
||||
|
||||
// 构建请求体
|
||||
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 +327,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,25 +342,40 @@ 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();
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 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) {
|
||||
// 新注册用户,延迟后显示昵称设置引导
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
setShowNicknamePrompt(true);
|
||||
// 追踪昵称设置引导显示
|
||||
authEvents.trackNicknamePromptShown(phone);
|
||||
}, config.features.successDelay);
|
||||
} else {
|
||||
// 已有用户,直接登录成功
|
||||
@@ -291,19 +383,46 @@ 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) {
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 追踪登录失败
|
||||
const errorType = error.message.includes('网络') ? 'network' :
|
||||
error.message.includes('服务器') ? 'api' : 'validation';
|
||||
authEvents.trackLoginFailed('phone', errorType, error.message, {
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
has_verification_code: !!verificationCode
|
||||
});
|
||||
|
||||
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 || "请稍后重试",
|
||||
description: error.message || "请检查验证码是否正确",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
containerStyle: {
|
||||
zIndex: 10000,
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
@@ -313,6 +432,9 @@ export default function AuthFormContent() {
|
||||
|
||||
// 微信H5登录处理
|
||||
const handleWechatH5Login = async () => {
|
||||
// 追踪用户选择微信登录
|
||||
authEvents.trackWechatLoginInitiated('icon_button');
|
||||
|
||||
try {
|
||||
// 1. 构建回调URL
|
||||
const redirectUrl = `${window.location.origin}/home/wechat-callback`;
|
||||
@@ -333,12 +455,20 @@ export default function AuthFormContent() {
|
||||
throw new Error('获取授权链接失败');
|
||||
}
|
||||
|
||||
// 追踪微信H5跳转
|
||||
authEvents.trackWechatH5Redirect();
|
||||
|
||||
// 4. 延迟跳转,让用户看到提示
|
||||
setTimeout(() => {
|
||||
window.location.href = response.auth_url;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('微信H5登录失败:', error);
|
||||
// 追踪跳转失败
|
||||
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
|
||||
context: 'wechat_h5_redirect'
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleWechatH5Login', error);
|
||||
toast({
|
||||
title: "跳转失败",
|
||||
description: error.message || "请稍后重试",
|
||||
@@ -349,14 +479,17 @@ export default function AuthFormContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
// 组件挂载时追踪页面浏览
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 追踪登录页面浏览
|
||||
authEvents.trackLoginPageViewed();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
}, [authEvents]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -416,6 +549,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackUserAgreementClicked}
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
@@ -428,6 +562,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
@@ -438,8 +573,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和p,WechatRegister自带白色背景和padding */}
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
@@ -455,8 +590,30 @@ export default function AuthFormContent() {
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||||
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
|
||||
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/home/profile'); }, 300); }} ml={3}>去设置</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptSkipped();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptAccepted();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
setTimeout(() => {
|
||||
navigate('/home/profile');
|
||||
}, 300);
|
||||
}}
|
||||
ml={3}
|
||||
>
|
||||
去设置
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,21 +3,61 @@ 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 { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { useAuthEvents } from "../../hooks/useAuthEvents";
|
||||
|
||||
// 配置常量
|
||||
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 { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'WechatRegister',
|
||||
isMobile: false // WechatRegister 只在桌面端显示
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
@@ -31,6 +71,7 @@ export default function WechatRegister() {
|
||||
const timeoutRef = useRef(null);
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
@@ -63,6 +104,7 @@ export default function WechatRegister() {
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* 注意:不清理 sessionIdRef,因为 startPolling 时也会调用此函数
|
||||
*/
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -84,9 +126,20 @@ export default function WechatRegister() {
|
||||
*/
|
||||
const handleLoginSuccess = useCallback(async (sessionId, status) => {
|
||||
try {
|
||||
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
|
||||
|
||||
const response = await authService.loginWithWechat(sessionId);
|
||||
|
||||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
response.user,
|
||||
'wechat',
|
||||
response.isNewUser || false
|
||||
);
|
||||
|
||||
// Session cookie 会自动管理,不需要手动存储
|
||||
// 如果后端返回了 token,可以选择性存储(兼容旧方式)
|
||||
if (response.token) {
|
||||
@@ -97,54 +150,94 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "注册成功",
|
||||
"正在跳转..."
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!"
|
||||
);
|
||||
|
||||
// 延迟跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate("/home");
|
||||
}, 1000);
|
||||
// 刷新 AuthContext 状态
|
||||
await refreshSession();
|
||||
|
||||
// 关闭认证弹窗,留在当前页面
|
||||
closeModal();
|
||||
} else {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
// 追踪微信登录失败
|
||||
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败', {
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
status: status
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
}
|
||||
}, [navigate, showSuccess, showError]);
|
||||
}, [showSuccess, showError, closeModal, refreshSession, authEvents]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
* 使用 sessionIdRef.current 避免闭包陷阱
|
||||
*/
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current || !wechatSessionId) return;
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||||
isMounted: isMountedRef.current,
|
||||
hasSessionId: !!sessionIdRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(wechatSessionId);
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
console.warn('微信状态检查返回无效数据:', response);
|
||||
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = response;
|
||||
logger.debug('WechatRegister', '微信状态', { status });
|
||||
|
||||
logger.debug('WechatRegister', '检测到微信状态', {
|
||||
sessionId: wechatSessionId.substring(0, 8) + '...',
|
||||
status,
|
||||
userInfo: response.user_info
|
||||
});
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化
|
||||
if (wechatStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
authEvents.trackWechatQRScanned(currentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
setWechatStatus(status);
|
||||
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
|
||||
clearTimers(); // 停止轮询
|
||||
await handleLoginSuccess(wechatSessionId, status);
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
// 追踪二维码过期
|
||||
authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000);
|
||||
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已过期",
|
||||
@@ -156,11 +249,12 @@ export default function WechatRegister() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("检查微信状态失败:", error);
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
// 但如果错误持续发生,停止轮询避免无限重试
|
||||
if (error.message.includes('网络连接失败')) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "网络连接失败",
|
||||
@@ -172,12 +266,17 @@ export default function WechatRegister() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [wechatSessionId, handleLoginSuccess, clearTimers, toast]);
|
||||
}, [handleLoginSuccess, clearTimers, toast]);
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
logger.debug('WechatRegister', '启动轮询', {
|
||||
sessionId: sessionIdRef.current,
|
||||
interval: POLL_INTERVAL
|
||||
});
|
||||
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
@@ -188,7 +287,9 @@ export default function WechatRegister() {
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
logger.debug('WechatRegister', '二维码超时');
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
}, QR_CODE_TIMEOUT);
|
||||
}, [checkWechatStatus, clearTimers]);
|
||||
@@ -200,6 +301,16 @@ export default function WechatRegister() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 追踪用户选择微信登录(首次或刷新)
|
||||
const isRefresh = Boolean(wechatSessionId);
|
||||
if (isRefresh) {
|
||||
const oldSessionId = wechatSessionId;
|
||||
authEvents.trackWechatLoginInitiated('qr_refresh');
|
||||
// 稍后会在成功时追踪刷新事件
|
||||
} else {
|
||||
authEvents.trackWechatLoginInitiated('qr_area');
|
||||
}
|
||||
|
||||
// 生产环境:调用真实 API
|
||||
const response = await authService.getWechatQRCode();
|
||||
|
||||
@@ -215,14 +326,33 @@ export default function WechatRegister() {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 追踪二维码显示 (首次或刷新)
|
||||
if (isRefresh) {
|
||||
authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id);
|
||||
} else {
|
||||
authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url);
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
logger.debug('WechatRegister', '获取二维码成功', {
|
||||
sessionId: response.data.session_id,
|
||||
authUrl: response.data.auth_url
|
||||
});
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
console.error('获取微信授权失败:', error);
|
||||
// 追踪获取二维码失败
|
||||
authEvents.trackError('api', error.message || '获取二维码失败', {
|
||||
context: 'get_wechat_qrcode'
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'getWechatQRCode', error);
|
||||
if (isMountedRef.current) {
|
||||
showError("获取微信授权失败", error.message || "请稍后重试");
|
||||
}
|
||||
@@ -231,7 +361,7 @@ export default function WechatRegister() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [startPolling, showError]);
|
||||
}, [startPolling, showError, wechatSessionId, authEvents]);
|
||||
|
||||
/**
|
||||
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||||
@@ -241,7 +371,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]);
|
||||
|
||||
@@ -254,50 +384,17 @@ export default function WechatRegister() {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
/**
|
||||
* 备用轮询机制 - 防止丢失状态
|
||||
* 每3秒检查一次,仅在获取到二维码URL且状态为waiting时执行
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只在有auth_url、session_id且状态为waiting时启动备用轮询
|
||||
if (wechatAuthUrl && wechatSessionId && wechatStatus === WECHAT_STATUS.WAITING) {
|
||||
console.log('备用轮询:启动备用轮询机制');
|
||||
|
||||
backupPollIntervalRef.current = setInterval(() => {
|
||||
try {
|
||||
if (wechatStatus === WECHAT_STATUS.WAITING && isMountedRef.current) {
|
||||
console.log('备用轮询:检查微信状态');
|
||||
// 添加 .catch() 静默处理异步错误,防止被 ErrorBoundary 捕获
|
||||
checkWechatStatus().catch(error => {
|
||||
console.warn('备用轮询检查失败(静默处理):', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 捕获所有同步错误,防止被 ErrorBoundary 捕获
|
||||
console.warn('备用轮询执行出错(静默处理):', error);
|
||||
}
|
||||
}, BACKUP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// 清理备用轮询
|
||||
return () => {
|
||||
if (backupPollIntervalRef.current) {
|
||||
clearInterval(backupPollIntervalRef.current);
|
||||
backupPollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [wechatAuthUrl, wechatSessionId, wechatStatus, checkWechatStatus]);
|
||||
|
||||
/**
|
||||
* 测量容器尺寸并计算缩放比例
|
||||
*/
|
||||
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 +428,165 @@ export default function WechatRegister() {
|
||||
};
|
||||
}, [wechatStatus]); // 当状态变化时重新计算
|
||||
|
||||
/**
|
||||
* 渲染状态提示文本
|
||||
*/
|
||||
const renderStatusText = () => {
|
||||
if (!wechatAuthUrl || wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) {
|
||||
return null;
|
||||
}
|
||||
// 渲染状态提示文本 - 已注释掉,如需使用可取消注释
|
||||
// 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 (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{STATUS_MESSAGES[wechatStatus]}
|
||||
</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>
|
||||
|
||||
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>
|
||||
{/* ========== 二维码区域 ========== */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
width="150px"
|
||||
height="100px"
|
||||
maxWidth="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="230px" // ✅ 升级尺寸
|
||||
height="230px"
|
||||
mx="auto"
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center'
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
</Box>
|
||||
{/* {renderStatusText()} */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
|
||||
微信扫码
|
||||
</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}>
|
||||
/* 未获取:显示占位符 */
|
||||
<Center width="100%" height="100%" flexDirection="column">
|
||||
<Icon as={FaQrcode} w={16} h={16} color="gray.300" mb={4} />
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="green"
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={handleGetQRCodeClick}
|
||||
isLoading={isLoading}
|
||||
leftIcon={<Icon as={FaQrcode} />}
|
||||
_hover={{ bg: "green.50" }}
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "点击刷新" : "获取二维码"}
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
|
||||
</Button>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
二维码已过期
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* ========== 过期蒙层 ========== */}
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="rgba(0,0,0,0.6)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backdropFilter="blur(4px)"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 扫码状态提示 */}
|
||||
{/* {renderStatusText()} */}
|
||||
</>
|
||||
{/* ========== 状态指示器 ========== */}
|
||||
{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>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<Space
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Space>
|
||||
{/* AI 标识 - 固定在右上角 */}
|
||||
{showAIBadge && (
|
||||
<Tag
|
||||
icon={<RobotOutlined />}
|
||||
color="purple"
|
||||
style={{ margin: 0 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
margin: 0,
|
||||
zIndex: 10,
|
||||
fontSize: 12,
|
||||
padding: '2px 8px'
|
||||
}}
|
||||
className="ai-badge-responsive"
|
||||
>
|
||||
AI 生成
|
||||
AI合成
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* 标题栏 */}
|
||||
{title && (
|
||||
<div style={{ marginBottom: 12, paddingRight: 80 }}>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
144
src/components/ConnectionStatusBar/index.js
Normal file
144
src/components/ConnectionStatusBar/index.js
Normal 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;
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
VStack,
|
||||
Container
|
||||
} from '@chakra-ui/react';
|
||||
// import {
|
||||
// Box,
|
||||
// Alert,
|
||||
// AlertIcon,
|
||||
// AlertTitle,
|
||||
// AlertDescription,
|
||||
// Button,
|
||||
// VStack,
|
||||
// Container
|
||||
// } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -17,25 +18,21 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// 开发环境:不拦截错误,让 React DevTools 显示完整堆栈
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return { hasError: false };
|
||||
}
|
||||
// 生产环境:拦截错误,显示友好界面
|
||||
// 所有环境都捕获错误,避免无限重渲染
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// 开发环境:打印错误到控制台,但不显示错误边界
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('🔴 ErrorBoundary 捕获到错误(开发模式,不拦截):');
|
||||
console.error('错误:', error);
|
||||
console.error('错误信息:', errorInfo);
|
||||
// 不更新 state,让错误继续抛出
|
||||
return;
|
||||
}
|
||||
// 记录详细的错误日志
|
||||
logger.error('ErrorBoundary', 'Component Error Caught', error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
environment: process.env.NODE_ENV,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// 生产环境:保存错误信息到 state
|
||||
// 保存错误信息到 state
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
@@ -43,57 +40,68 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// 开发环境:直接渲染子组件,不显示错误边界
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return this.props.children;
|
||||
}
|
||||
// 静默模式:捕获错误并记录日志(已在 componentDidCatch 中完成)
|
||||
// 但继续渲染子组件,不显示错误页面
|
||||
// 注意:如果组件因错误无法渲染,该区域可能显示为空白
|
||||
// // 如果有错误,显示错误边界(所有环境)
|
||||
// if (this.state.hasError) {
|
||||
// return (
|
||||
// <Container maxW="lg" py={20}>
|
||||
// <VStack spacing={6}>
|
||||
// <Alert status="error" borderRadius="lg" p={6}>
|
||||
// <AlertIcon boxSize="24px" />
|
||||
// <Box>
|
||||
// <AlertTitle fontSize="lg" mb={2}>
|
||||
// 页面出现错误!
|
||||
// </AlertTitle>
|
||||
// <AlertDescription>
|
||||
// {process.env.NODE_ENV === 'development'
|
||||
// ? '组件渲染时发生错误,请查看下方详情和控制台日志。'
|
||||
// : '页面加载时发生了未预期的错误,请尝试刷新页面。'}
|
||||
// </AlertDescription>
|
||||
// </Box>
|
||||
// </Alert>
|
||||
|
||||
// 生产环境:如果有错误,显示错误边界
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Container maxW="lg" py={20}>
|
||||
<VStack spacing={6}>
|
||||
<Alert status="error" borderRadius="lg" p={6}>
|
||||
<AlertIcon boxSize="24px" />
|
||||
<Box>
|
||||
<AlertTitle fontSize="lg" mb={2}>
|
||||
页面出现错误!
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
页面加载时发生了未预期的错误,请尝试刷新页面。
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box
|
||||
w="100%"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
fontSize="sm"
|
||||
overflow="auto"
|
||||
maxH="200px"
|
||||
>
|
||||
<Box fontWeight="bold" mb={2}>错误详情:</Box>
|
||||
<Box as="pre" whiteSpace="pre-wrap">
|
||||
{this.state.error && this.state.error.toString()}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载页面
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
// {/* 开发环境显示详细错误信息 */}
|
||||
// {process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
// <Box
|
||||
// w="100%"
|
||||
// bg="red.50"
|
||||
// p={4}
|
||||
// borderRadius="lg"
|
||||
// fontSize="sm"
|
||||
// overflow="auto"
|
||||
// maxH="400px"
|
||||
// border="1px"
|
||||
// borderColor="red.200"
|
||||
// >
|
||||
// <Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
|
||||
// <Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
|
||||
// <Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
|
||||
// {this.state.error.stack && (
|
||||
// <Box mt={2} color="gray.700">{this.state.error.stack}</Box>
|
||||
// )}
|
||||
// {this.state.errorInfo && this.state.errorInfo.componentStack && (
|
||||
// <>
|
||||
// <Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
|
||||
// <Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
|
||||
// </>
|
||||
// )}
|
||||
// </Box>
|
||||
// </Box>
|
||||
// )}
|
||||
|
||||
// <Button
|
||||
// colorScheme="blue"
|
||||
// size="lg"
|
||||
// onClick={() => window.location.reload()}
|
||||
// >
|
||||
// 重新加载页面
|
||||
// </Button>
|
||||
// </VStack>
|
||||
// </Container>
|
||||
// );
|
||||
// }
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -31,33 +31,53 @@ 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';
|
||||
import { useNavigationEvents } from '../../hooks/useNavigationEvents';
|
||||
|
||||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||||
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');
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'secondary_nav' });
|
||||
|
||||
// 定义二级导航结构
|
||||
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 +121,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}>
|
||||
@@ -146,7 +166,11 @@ const SecondaryNav = () => {
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => navigate(item.path)}
|
||||
onClick={() => {
|
||||
// 🎯 追踪侧边栏菜单点击
|
||||
navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false);
|
||||
navigate(item.path);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg={isActive ? 'blue.50' : 'transparent'}
|
||||
@@ -179,6 +203,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 +318,12 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'top_nav' });
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
return paths.some(path => location.pathname.includes(path));
|
||||
@@ -212,7 +348,11 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2}>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/community')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
||||
navigate('/community');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
|
||||
@@ -220,7 +360,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>
|
||||
@@ -228,7 +368,11 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/concepts')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
||||
navigate('/concepts');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
|
||||
@@ -339,7 +483,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 +493,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 +508,20 @@ export default function HomeNavbar() {
|
||||
const brandHover = useColorModeValue('blue.600', 'blue.300');
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'main_navbar' });
|
||||
|
||||
// ⚡ 提取 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 +540,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 +560,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 +570,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 +599,9 @@ export default function HomeNavbar() {
|
||||
setWatchlistQuotes([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载自选股实时行情失败:', e);
|
||||
logger.warn('HomeNavbar', '加载自选股实时行情失败', {
|
||||
error: e.message
|
||||
});
|
||||
setWatchlistQuotes([]);
|
||||
} finally {
|
||||
setWatchlistLoading(false);
|
||||
@@ -482,7 +649,9 @@ export default function HomeNavbar() {
|
||||
setFollowingEvents([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载关注事件失败:', e);
|
||||
logger.warn('HomeNavbar', '加载关注事件失败', {
|
||||
error: e.message
|
||||
});
|
||||
setFollowingEvents([]);
|
||||
} finally {
|
||||
setEventsLoading(false);
|
||||
@@ -550,12 +719,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 +742,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(() => {
|
||||
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 +837,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 +892,87 @@ 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' }}
|
||||
onClick={() => {
|
||||
// 🎯 追踪Logo点击
|
||||
navEvents.trackLogoClicked();
|
||||
navigate('/home');
|
||||
}}
|
||||
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}
|
||||
onClick={() => {
|
||||
// 🎯 追踪主题切换
|
||||
const fromTheme = colorMode;
|
||||
const toTheme = colorMode === 'light' ? 'dark' : 'light';
|
||||
navEvents.trackThemeChanged(fromTheme, toTheme);
|
||||
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>
|
||||
投资日历
|
||||
</Button>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* 自选股 - 头像右侧 */}
|
||||
{/* 自选股 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
<Menu onOpen={loadWatchlistQuotes}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
@@ -827,8 +1047,10 @@ export default function HomeNavbar() {
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{/* 关注的事件 - 头像右侧 */}
|
||||
{/* 关注的事件 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
<Menu onOpen={loadFollowingEvents}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
@@ -909,6 +1131,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 +1439,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 +1602,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
750
src/components/NotificationContainer/index.js
Normal file
750
src/components/NotificationContainer/index.js
Normal 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();
|
||||
// 使用带过期时间的 localStorage(2分钟 = 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;
|
||||
664
src/components/NotificationTestTool/index.js
Normal file
664
src/components/NotificationTestTool/index.js
Normal 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;
|
||||
83
src/components/PostHogProvider.js
Normal file
83
src/components/PostHogProvider.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/PostHogProvider.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { initPostHog } from '../lib/posthog';
|
||||
import { usePageTracking } from '../hooks/usePageTracking';
|
||||
|
||||
/**
|
||||
* PostHog Provider Component
|
||||
* Initializes PostHog SDK and provides automatic page view tracking
|
||||
*
|
||||
* Usage:
|
||||
* <PostHogProvider>
|
||||
* <App />
|
||||
* </PostHogProvider>
|
||||
*/
|
||||
export const PostHogProvider = ({ children }) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Initialize PostHog once when component mounts
|
||||
useEffect(() => {
|
||||
// Only run in browser
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
setIsInitialized(true);
|
||||
|
||||
// Log initialization
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHogProvider initialized');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Automatically track page views
|
||||
usePageTracking({
|
||||
enabled: isInitialized,
|
||||
getProperties: (location) => {
|
||||
// Add custom properties based on route
|
||||
const properties = {};
|
||||
|
||||
// Identify page type based on path
|
||||
if (location.pathname === '/home' || location.pathname === '/home/') {
|
||||
properties.page_type = 'landing';
|
||||
} else if (location.pathname.startsWith('/home/center')) {
|
||||
properties.page_type = 'dashboard';
|
||||
} else if (location.pathname.startsWith('/auth/')) {
|
||||
properties.page_type = 'auth';
|
||||
} else if (location.pathname.startsWith('/community')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'community';
|
||||
} else if (location.pathname.startsWith('/concepts')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'concepts';
|
||||
} else if (location.pathname.startsWith('/stocks')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'stocks';
|
||||
} else if (location.pathname.startsWith('/limit-analyse')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'limit_analyse';
|
||||
} else if (location.pathname.startsWith('/trading-simulation')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'trading_simulation';
|
||||
} else if (location.pathname.startsWith('/company')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'company';
|
||||
} else if (location.pathname.startsWith('/event-detail')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'event';
|
||||
}
|
||||
|
||||
return properties;
|
||||
},
|
||||
});
|
||||
|
||||
// Don't render children until PostHog is initialized
|
||||
// This prevents tracking events before SDK is ready
|
||||
if (!isInitialized) {
|
||||
return children; // Or return a loading spinner
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PostHogProvider;
|
||||
@@ -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) {
|
||||
|
||||
38
src/components/ProtectedRouteRedirect.js
Normal file
38
src/components/ProtectedRouteRedirect.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
137
src/components/Subscription/CrownTooltip.js
Normal file
137
src/components/Subscription/CrownTooltip.js
Normal 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,
|
||||
};
|
||||
124
src/components/Subscription/SubscriptionBadge.js
Normal file
124
src/components/Subscription/SubscriptionBadge.js
Normal 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,
|
||||
};
|
||||
211
src/components/Subscription/SubscriptionButton.js
Normal file
211
src/components/Subscription/SubscriptionButton.js
Normal 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,
|
||||
};
|
||||
1240
src/components/Subscription/SubscriptionContent.js
Normal file
1240
src/components/Subscription/SubscriptionContent.js
Normal file
File diff suppressed because it is too large
Load Diff
42
src/components/Subscription/SubscriptionModal.js
Normal file
42
src/components/Subscription/SubscriptionModal.js
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
77
src/constants/importanceLevels.js
Normal file
77
src/constants/importanceLevels.js
Normal 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);
|
||||
};
|
||||
254
src/constants/notificationTypes.js
Normal file
254
src/constants/notificationTypes.js
Normal 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,
|
||||
};
|
||||
@@ -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,37 @@ 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);
|
||||
|
||||
// ⚡ 请求节流:记录上次请求时间,防止短时间内重复请求
|
||||
const lastCheckTimeRef = React.useRef(0);
|
||||
const MIN_CHECK_INTERVAL = 1000; // 最少间隔1秒
|
||||
|
||||
// 检查Session状态
|
||||
const checkSession = async () => {
|
||||
// 节流检查
|
||||
const now = Date.now();
|
||||
const timeSinceLastCheck = now - lastCheckTimeRef.current;
|
||||
|
||||
if (timeSinceLastCheck < MIN_CHECK_INTERVAL) {
|
||||
logger.warn('AuthContext', 'checkSession 请求被节流(防止频繁请求)', {
|
||||
timeSinceLastCheck: `${timeSinceLastCheck}ms`,
|
||||
minInterval: `${MIN_CHECK_INTERVAL}ms`,
|
||||
reason: '距离上次请求间隔太短'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lastCheckTimeRef.current = now;
|
||||
|
||||
try {
|
||||
console.log('🔍 检查Session状态...');
|
||||
logger.debug('AuthContext', '开始检查Session状态', {
|
||||
timestamp: new Date().toISOString(),
|
||||
timeSinceLastCheck: timeSinceLastCheck > 0 ? `${timeSinceLastCheck}ms` : '首次请求'
|
||||
});
|
||||
|
||||
// 创建超时控制器
|
||||
const controller = new AbortController();
|
||||
@@ -34,11 +62,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 +76,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 +112,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 +130,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 +141,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 +154,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 +164,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,81 +197,21 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 注册方法
|
||||
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) => {
|
||||
@@ -273,19 +250,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 +302,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 +325,7 @@ export const AuthProvider = ({ children }) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // 必须包含以支持跨域 session cookie
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ phone })
|
||||
});
|
||||
|
||||
@@ -366,27 +335,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 +354,7 @@ export const AuthProvider = ({ children }) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // 必须包含以支持跨域 session cookie
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
@@ -409,27 +364,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 +388,7 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
|
||||
// ✅ 保留登出成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: "已登出",
|
||||
description: "您已成功退出登录",
|
||||
@@ -455,14 +397,11 @@ export const AuthProvider = ({ children }) => {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 不再跳转,用户留在当前页面
|
||||
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
logger.error('AuthContext', 'logout', error);
|
||||
// 即使API调用失败也清除本地状态
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
// 不再跳转,用户留在当前页面
|
||||
}
|
||||
};
|
||||
|
||||
@@ -483,7 +422,6 @@ export const AuthProvider = ({ children }) => {
|
||||
isLoading,
|
||||
updateUser,
|
||||
login,
|
||||
register,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
src/contexts/IndustryContext.js
Normal file
176
src/contexts/IndustryContext.js
Normal 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>
|
||||
);
|
||||
};
|
||||
828
src/contexts/NotificationContext.js
Normal file
828
src/contexts/NotificationContext.js
Normal file
@@ -0,0 +1,828 @@
|
||||
// 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...');
|
||||
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
|
||||
// 监听连接状态
|
||||
socket.on('connect', () => {
|
||||
const wasDisconnected = connectionStatus !== CONNECTION_STATUS.CONNECTED;
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
|
||||
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// 如果之前断开过,显示 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);
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// ✅ 第二步: 获取最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ✅ 第三步: 调用 socket.connect()
|
||||
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
socket.connect();
|
||||
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
|
||||
|
||||
// 清理函数
|
||||
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();
|
||||
};
|
||||
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
/**
|
||||
* 标签页聚焦时自动重试
|
||||
*/
|
||||
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
4340
src/data/industryData.js
Normal file
File diff suppressed because it is too large
Load Diff
463
src/hooks/useAuthEvents.js
Normal file
463
src/hooks/useAuthEvents.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// src/hooks/useAuthEvents.js
|
||||
// 认证事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack, usePostHogUser } from './usePostHogRedux';
|
||||
import { ACTIVATION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 认证事件追踪 Hook
|
||||
* 提供登录/注册流程中所有关键节点的事件追踪功能
|
||||
*
|
||||
* 用法示例:
|
||||
*
|
||||
* ```jsx
|
||||
* import { useAuthEvents } from 'hooks/useAuthEvents';
|
||||
*
|
||||
* function AuthComponent() {
|
||||
* const {
|
||||
* trackLoginPageViewed,
|
||||
* trackPhoneLoginInitiated,
|
||||
* trackVerificationCodeSent,
|
||||
* trackLoginSuccess
|
||||
* } = useAuthEvents();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* trackLoginPageViewed();
|
||||
* }, [trackLoginPageViewed]);
|
||||
*
|
||||
* const handlePhoneFocus = () => {
|
||||
* trackPhoneLoginInitiated(formData.phone);
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.component - 组件名称 ('AuthFormContent' | 'WechatRegister')
|
||||
* @param {boolean} options.isMobile - 是否为移动设备
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
const { identify } = usePostHogUser();
|
||||
|
||||
// 通用事件属性
|
||||
const getBaseProperties = useCallback(() => ({
|
||||
component,
|
||||
device: isMobile ? 'mobile' : 'desktop',
|
||||
timestamp: new Date().toISOString(),
|
||||
}), [component, isMobile]);
|
||||
|
||||
// ==================== 页面浏览事件 ====================
|
||||
|
||||
/**
|
||||
* 追踪登录页面浏览
|
||||
*/
|
||||
const trackLoginPageViewed = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 Login Page Viewed', { component });
|
||||
}, [track, getBaseProperties, component]);
|
||||
|
||||
// ==================== 登录方式选择 ====================
|
||||
|
||||
/**
|
||||
* 追踪用户开始手机号登录
|
||||
* @param {string} phone - 手机号(可选,用于判断是否已填写)
|
||||
*/
|
||||
const trackPhoneLoginInitiated = useCallback((phone = '') => {
|
||||
track(ACTIVATION_EVENTS.PHONE_LOGIN_INITIATED, {
|
||||
...getBaseProperties(),
|
||||
has_phone: Boolean(phone),
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 Phone Login Initiated', { hasPhone: Boolean(phone) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户选择微信登录
|
||||
* @param {string} source - 触发来源 ('qr_area' | 'icon_button' | 'h5_redirect')
|
||||
*/
|
||||
const trackWechatLoginInitiated = useCallback((source = 'qr_area') => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_LOGIN_INITIATED, {
|
||||
...getBaseProperties(),
|
||||
source,
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 手机验证码流程 ====================
|
||||
|
||||
/**
|
||||
* 追踪验证码发送成功
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} purpose - 发送目的 ('login' | 'register')
|
||||
*/
|
||||
const trackVerificationCodeSent = useCallback((phone, purpose = 'login') => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SENT, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
purpose,
|
||||
});
|
||||
logger.debug('useAuthEvents', '✉️ Verification Code Sent', { phone: phone?.substring(0, 3) + '****', purpose });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪验证码发送失败
|
||||
* @param {string} phone - 手机号
|
||||
* @param {Error|string} error - 错误对象或错误消息
|
||||
*/
|
||||
const trackVerificationCodeSendFailed = useCallback((phone, error) => {
|
||||
const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error';
|
||||
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SEND_FAILED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
error_message: errorMessage,
|
||||
error_type: 'send_code_failed',
|
||||
});
|
||||
logger.debug('useAuthEvents', '❌ Verification Code Send Failed', { error: errorMessage });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户输入验证码
|
||||
* @param {number} codeLength - 当前输入的验证码长度
|
||||
*/
|
||||
const trackVerificationCodeInputChanged = useCallback((codeLength) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_INPUT_CHANGED, {
|
||||
...getBaseProperties(),
|
||||
code_length: codeLength,
|
||||
is_complete: codeLength >= 6,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⌨️ Verification Code Input Changed', { codeLength });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪重新发送验证码
|
||||
* @param {string} phone - 手机号
|
||||
* @param {number} attemptCount - 第几次重发(可选)
|
||||
*/
|
||||
const trackVerificationCodeResent = useCallback((phone, attemptCount = 1) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_RESENT, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
attempt_count: attemptCount,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 Verification Code Resent', { attempt: attemptCount });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪手机号验证结果
|
||||
* @param {string} phone - 手机号
|
||||
* @param {boolean} isValid - 是否有效
|
||||
* @param {string} errorType - 错误类型(可选)
|
||||
*/
|
||||
const trackPhoneNumberValidated = useCallback((phone, isValid, errorType = '') => {
|
||||
track(ACTIVATION_EVENTS.PHONE_NUMBER_VALIDATED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
is_valid: isValid,
|
||||
error_type: errorType,
|
||||
});
|
||||
logger.debug('useAuthEvents', '✓ Phone Number Validated', { isValid, errorType });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪验证码提交
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackVerificationCodeSubmitted = useCallback((phone) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SUBMITTED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📤 Verification Code Submitted');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 微信登录流程 ====================
|
||||
|
||||
/**
|
||||
* 追踪微信二维码显示
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {string} authUrl - 授权URL
|
||||
*/
|
||||
const trackWechatQRDisplayed = useCallback((sessionId, authUrl = '') => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_DISPLAYED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
has_auth_url: Boolean(authUrl),
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔲 WeChat QR Code Displayed', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信二维码被扫描
|
||||
* @param {string} sessionId - 会话ID
|
||||
*/
|
||||
const trackWechatQRScanned = useCallback((sessionId) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_SCANNED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信二维码过期
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {number} timeElapsed - 经过时间(秒)
|
||||
*/
|
||||
const trackWechatQRExpired = useCallback((sessionId, timeElapsed = 0) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_EXPIRED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
time_elapsed: timeElapsed,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪刷新微信二维码
|
||||
* @param {string} oldSessionId - 旧会话ID
|
||||
* @param {string} newSessionId - 新会话ID
|
||||
*/
|
||||
const trackWechatQRRefreshed = useCallback((oldSessionId, newSessionId) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_REFRESHED, {
|
||||
...getBaseProperties(),
|
||||
old_session_id: oldSessionId?.substring(0, 8) + '...',
|
||||
new_session_id: newSessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信登录状态变化
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {string} oldStatus - 旧状态
|
||||
* @param {string} newStatus - 新状态
|
||||
*/
|
||||
const trackWechatStatusChanged = useCallback((sessionId, oldStatus, newStatus) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_STATUS_CHANGED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
old_status: oldStatus,
|
||||
new_status: newStatus,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪移动端跳转微信H5授权
|
||||
*/
|
||||
const trackWechatH5Redirect = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 登录/注册结果 ====================
|
||||
|
||||
/**
|
||||
* 追踪登录成功并识别用户
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {string} loginMethod - 登录方式 ('wechat' | 'phone')
|
||||
* @param {boolean} isNewUser - 是否为新注册用户
|
||||
*/
|
||||
const trackLoginSuccess = useCallback((user, loginMethod, isNewUser = false) => {
|
||||
// 追踪登录成功事件
|
||||
const eventName = isNewUser ? ACTIVATION_EVENTS.USER_SIGNED_UP : ACTIVATION_EVENTS.USER_LOGGED_IN;
|
||||
|
||||
track(eventName, {
|
||||
...getBaseProperties(),
|
||||
user_id: user.id,
|
||||
login_method: loginMethod,
|
||||
is_new_user: isNewUser,
|
||||
has_nickname: Boolean(user.nickname),
|
||||
has_email: Boolean(user.email),
|
||||
has_wechat: Boolean(user.wechat_open_id),
|
||||
});
|
||||
|
||||
// 识别用户(关联 PostHog 用户)
|
||||
identify(user.id.toString(), {
|
||||
phone: user.phone,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
login_method: loginMethod,
|
||||
is_new_user: isNewUser,
|
||||
registration_date: user.created_at,
|
||||
last_login: new Date().toISOString(),
|
||||
has_wechat: Boolean(user.wechat_open_id),
|
||||
wechat_open_id: user.wechat_open_id,
|
||||
wechat_union_id: user.wechat_union_id,
|
||||
});
|
||||
|
||||
logger.debug('useAuthEvents', `✅ ${isNewUser ? 'User Signed Up' : 'User Logged In'}`, {
|
||||
userId: user.id,
|
||||
method: loginMethod,
|
||||
isNewUser,
|
||||
});
|
||||
}, [track, identify, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪登录失败
|
||||
* @param {string} loginMethod - 登录方式 ('wechat' | 'phone')
|
||||
* @param {string} errorType - 错误类型
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @param {Object} context - 额外上下文信息
|
||||
*/
|
||||
const trackLoginFailed = useCallback((loginMethod, errorType, errorMessage, context = {}) => {
|
||||
track(ACTIVATION_EVENTS.LOGIN_FAILED, {
|
||||
...getBaseProperties(),
|
||||
login_method: loginMethod,
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
...context,
|
||||
});
|
||||
logger.debug('useAuthEvents', '❌ Login Failed', { method: loginMethod, errorType, errorMessage });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 用户行为细节 ====================
|
||||
|
||||
/**
|
||||
* 追踪表单字段聚焦
|
||||
* @param {string} fieldName - 字段名称 ('phone' | 'verificationCode')
|
||||
*/
|
||||
const trackFormFocused = useCallback((fieldName) => {
|
||||
track(ACTIVATION_EVENTS.AUTH_FORM_FOCUSED, {
|
||||
...getBaseProperties(),
|
||||
field_name: fieldName,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🎯 Form Field Focused', { fieldName });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪表单验证错误
|
||||
* @param {string} fieldName - 字段名称
|
||||
* @param {string} errorType - 错误类型
|
||||
* @param {string} errorMessage - 错误消息
|
||||
*/
|
||||
const trackFormValidationError = useCallback((fieldName, errorType, errorMessage) => {
|
||||
track(ACTIVATION_EVENTS.AUTH_FORM_VALIDATION_ERROR, {
|
||||
...getBaseProperties(),
|
||||
field_name: fieldName,
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⚠️ Form Validation Error', { fieldName, errorType });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪昵称设置引导弹窗显示
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackNicknamePromptShown = useCallback((phone) => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_SHOWN, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 Nickname Prompt Shown');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户接受设置昵称
|
||||
*/
|
||||
const trackNicknamePromptAccepted = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_ACCEPTED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '✅ Nickname Prompt Accepted');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户跳过设置昵称
|
||||
*/
|
||||
const trackNicknamePromptSkipped = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_SKIPPED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '⏭️ Nickname Prompt Skipped');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户点击用户协议链接
|
||||
*/
|
||||
const trackUserAgreementClicked = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.USER_AGREEMENT_LINK_CLICKED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 User Agreement Link Clicked');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户点击隐私政策链接
|
||||
*/
|
||||
const trackPrivacyPolicyClicked = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.PRIVACY_POLICY_LINK_CLICKED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 Privacy Policy Link Clicked');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 错误追踪 ====================
|
||||
|
||||
/**
|
||||
* 追踪通用错误
|
||||
* @param {string} errorType - 错误类型 ('network' | 'api' | 'validation' | 'session')
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @param {Object} context - 错误上下文
|
||||
*/
|
||||
const trackError = useCallback((errorType, errorMessage, context = {}) => {
|
||||
const eventMap = {
|
||||
network: ACTIVATION_EVENTS.NETWORK_ERROR_OCCURRED,
|
||||
api: ACTIVATION_EVENTS.API_ERROR_OCCURRED,
|
||||
session: ACTIVATION_EVENTS.SESSION_EXPIRED,
|
||||
default: ACTIVATION_EVENTS.LOGIN_ERROR_OCCURRED,
|
||||
};
|
||||
|
||||
const eventName = eventMap[errorType] || eventMap.default;
|
||||
|
||||
track(eventName, {
|
||||
...getBaseProperties(),
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
...context,
|
||||
});
|
||||
logger.error('useAuthEvents', `❌ ${errorType} Error`, { errorMessage, context });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// 页面浏览
|
||||
trackLoginPageViewed,
|
||||
|
||||
// 登录方式选择
|
||||
trackPhoneLoginInitiated,
|
||||
trackWechatLoginInitiated,
|
||||
|
||||
// 手机验证码流程
|
||||
trackVerificationCodeSent,
|
||||
trackVerificationCodeSendFailed,
|
||||
trackVerificationCodeInputChanged,
|
||||
trackVerificationCodeResent,
|
||||
trackPhoneNumberValidated,
|
||||
trackVerificationCodeSubmitted,
|
||||
|
||||
// 微信登录流程
|
||||
trackWechatQRDisplayed,
|
||||
trackWechatQRScanned,
|
||||
trackWechatQRExpired,
|
||||
trackWechatQRRefreshed,
|
||||
trackWechatStatusChanged,
|
||||
trackWechatH5Redirect,
|
||||
|
||||
// 登录/注册结果
|
||||
trackLoginSuccess,
|
||||
trackLoginFailed,
|
||||
|
||||
// 用户行为
|
||||
trackFormFocused,
|
||||
trackFormValidationError,
|
||||
trackNicknamePromptShown,
|
||||
trackNicknamePromptAccepted,
|
||||
trackNicknamePromptSkipped,
|
||||
trackUserAgreementClicked,
|
||||
trackPrivacyPolicyClicked,
|
||||
|
||||
// 错误追踪
|
||||
trackError,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAuthEvents;
|
||||
325
src/hooks/useDashboardEvents.js
Normal file
325
src/hooks/useDashboardEvents.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// src/hooks/useDashboardEvents.js
|
||||
// 个人中心(Dashboard/Center)事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 个人中心事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.pageType - 页面类型 ('center' | 'profile' | 'settings')
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useDashboardEvents = ({ pageType = 'center', navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
const eventMap = {
|
||||
'center': RETENTION_EVENTS.DASHBOARD_CENTER_VIEWED,
|
||||
'profile': RETENTION_EVENTS.PROFILE_PAGE_VIEWED,
|
||||
'settings': RETENTION_EVENTS.SETTINGS_PAGE_VIEWED,
|
||||
};
|
||||
|
||||
const eventName = eventMap[pageType] || RETENTION_EVENTS.DASHBOARD_VIEWED;
|
||||
|
||||
track(eventName, {
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', `📊 Dashboard Page Viewed: ${pageType}`);
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪功能卡片点击
|
||||
* @param {string} cardName - 卡片名称 ('watchlist' | 'following_events' | 'comments' | 'subscription')
|
||||
* @param {Object} cardData - 卡片数据
|
||||
*/
|
||||
const trackFunctionCardClicked = useCallback((cardName, cardData = {}) => {
|
||||
if (!cardName) {
|
||||
logger.warn('useDashboardEvents', 'Card name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.FUNCTION_CARD_CLICKED, {
|
||||
card_name: cardName,
|
||||
data_count: cardData.count || 0,
|
||||
has_data: Boolean(cardData.count && cardData.count > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '🎴 Function Card Clicked', {
|
||||
cardName,
|
||||
count: cardData.count,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股列表查看
|
||||
* @param {number} stockCount - 自选股数量
|
||||
* @param {boolean} hasRealtime - 是否有实时行情
|
||||
*/
|
||||
const trackWatchlistViewed = useCallback((stockCount = 0, hasRealtime = false) => {
|
||||
track('Watchlist Viewed', {
|
||||
stock_count: stockCount,
|
||||
has_realtime: hasRealtime,
|
||||
is_empty: stockCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⭐ Watchlist Viewed', {
|
||||
stockCount,
|
||||
hasRealtime,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackWatchlistStockClicked = useCallback((stock, position = 0) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'watchlist',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '🎯 Watchlist Stock Clicked', {
|
||||
stockCode: stock.code,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股添加
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {string} source - 来源 ('search' | 'stock_detail' | 'manual')
|
||||
*/
|
||||
const trackWatchlistStockAdded = useCallback((stock, source = 'manual') => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Watchlist Stock Added', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '➕ Watchlist Stock Added', {
|
||||
stockCode: stock.code,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股移除
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
*/
|
||||
const trackWatchlistStockRemoved = useCallback((stock) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Watchlist Stock Removed', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '➖ Watchlist Stock Removed', {
|
||||
stockCode: stock.code,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪关注的事件列表查看
|
||||
* @param {number} eventCount - 关注的事件数量
|
||||
*/
|
||||
const trackFollowingEventsViewed = useCallback((eventCount = 0) => {
|
||||
track('Following Events Viewed', {
|
||||
event_count: eventCount,
|
||||
is_empty: eventCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '📌 Following Events Viewed', {
|
||||
eventCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪关注的事件点击
|
||||
* @param {Object} event - 事件对象
|
||||
* @param {number} event.id - 事件ID
|
||||
* @param {string} event.title - 事件标题
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackFollowingEventClicked = useCallback((event, position = 0) => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useDashboardEvents', 'Event object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: event.id,
|
||||
news_title: event.title || '',
|
||||
source: 'following_events',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '📰 Following Event Clicked', {
|
||||
eventId: event.id,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪事件评论列表查看
|
||||
* @param {number} commentCount - 评论数量
|
||||
*/
|
||||
const trackCommentsViewed = useCallback((commentCount = 0) => {
|
||||
track('Event Comments Viewed', {
|
||||
comment_count: commentCount,
|
||||
is_empty: commentCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '💬 Comments Viewed', {
|
||||
commentCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪订阅信息查看
|
||||
* @param {Object} subscription - 订阅信息
|
||||
* @param {string} subscription.plan - 订阅计划 ('free' | 'pro' | 'enterprise')
|
||||
* @param {string} subscription.status - 订阅状态 ('active' | 'expired' | 'cancelled')
|
||||
*/
|
||||
const trackSubscriptionViewed = useCallback((subscription = {}) => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
subscription_plan: subscription.plan || 'free',
|
||||
subscription_status: subscription.status || 'unknown',
|
||||
is_paid_user: subscription.plan !== 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '💳 Subscription Viewed', {
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} currentPlan - 当前计划
|
||||
* @param {string} targetPlan - 目标计划
|
||||
* @param {string} source - 来源位置
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback((currentPlan = 'free', targetPlan = 'pro', source = 'dashboard') => {
|
||||
track(RETENTION_EVENTS.UPGRADE_PLAN_CLICKED, {
|
||||
current_plan: currentPlan,
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⬆️ Upgrade Plan Clicked', {
|
||||
currentPlan,
|
||||
targetPlan,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新
|
||||
* @param {Array<string>} updatedFields - 更新的字段列表
|
||||
*/
|
||||
const trackProfileUpdated = useCallback((updatedFields = []) => {
|
||||
track(RETENTION_EVENTS.PROFILE_UPDATED, {
|
||||
updated_fields: updatedFields,
|
||||
field_count: updatedFields.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '✏️ Profile Updated', {
|
||||
updatedFields,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪设置更改
|
||||
* @param {string} settingName - 设置名称
|
||||
* @param {any} oldValue - 旧值
|
||||
* @param {any} newValue - 新值
|
||||
*/
|
||||
const trackSettingChanged = useCallback((settingName, oldValue, newValue) => {
|
||||
if (!settingName) {
|
||||
logger.warn('useDashboardEvents', 'Setting name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SETTINGS_CHANGED, {
|
||||
setting_name: settingName,
|
||||
old_value: String(oldValue),
|
||||
new_value: String(newValue),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⚙️ Setting Changed', {
|
||||
settingName,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
// 功能卡片事件
|
||||
trackFunctionCardClicked,
|
||||
|
||||
// 自选股相关事件
|
||||
trackWatchlistViewed,
|
||||
trackWatchlistStockClicked,
|
||||
trackWatchlistStockAdded,
|
||||
trackWatchlistStockRemoved,
|
||||
|
||||
// 关注事件相关
|
||||
trackFollowingEventsViewed,
|
||||
trackFollowingEventClicked,
|
||||
|
||||
// 评论相关
|
||||
trackCommentsViewed,
|
||||
|
||||
// 订阅相关
|
||||
trackSubscriptionViewed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 个人资料和设置
|
||||
trackProfileUpdated,
|
||||
trackSettingChanged,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDashboardEvents;
|
||||
242
src/hooks/useEventNotifications.js
Normal file
242
src/hooks/useEventNotifications.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// 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 socket from '../services/socket';
|
||||
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之前设置,否则可能错过事件)
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
socket.on('connect_error', handleConnectError);
|
||||
|
||||
// 连接 WebSocket
|
||||
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
|
||||
logger.info('useEventNotifications', 'Initializing WebSocket connection');
|
||||
|
||||
// 先检查是否已经连接
|
||||
const alreadyConnected = socket.connected || false;
|
||||
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
|
||||
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
|
||||
|
||||
if (alreadyConnected) {
|
||||
// 如果已经连接,直接更新状态
|
||||
console.log('[useEventNotifications DEBUG] Socket已连接,直接更新状态');
|
||||
logger.info('useEventNotifications', 'Socket already connected, updating state immediately');
|
||||
setIsConnected(true);
|
||||
// 验证状态更新
|
||||
setTimeout(() => {
|
||||
console.log('[useEventNotifications DEBUG] 1秒后验证状态更新 - isConnected应该为true');
|
||||
}, 1000);
|
||||
} else {
|
||||
// 否则建立新连接
|
||||
socket.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);
|
||||
|
||||
// 检查 socket 是否有 subscribeToEvents 方法(mockSocketService 和 socketService 都有)
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.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');
|
||||
} else {
|
||||
console.warn('[useEventNotifications] socket.subscribeToEvents 方法不存在');
|
||||
}
|
||||
|
||||
// 保存取消订阅函数
|
||||
unsubscribeRef.current = () => {
|
||||
if (socket.unsubscribeFromEvents) {
|
||||
socket.unsubscribeFromEvents({ eventType });
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 清理 WebSocket 订阅 ==========');
|
||||
|
||||
// 取消订阅
|
||||
if (unsubscribeRef.current) {
|
||||
console.log('[useEventNotifications DEBUG] 取消订阅...');
|
||||
unsubscribeRef.current();
|
||||
}
|
||||
|
||||
// 移除监听器
|
||||
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
socket.off('connect_error', handleConnectError);
|
||||
|
||||
// 注意:不断开连接,因为 socket 是全局共享的
|
||||
// 由 NotificationContext 统一管理连接生命周期
|
||||
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
|
||||
};
|
||||
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
|
||||
|
||||
// 监控 isConnected 状态变化(调试用)
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== isConnected 状态变化 ==========');
|
||||
console.log('[useEventNotifications DEBUG] isConnected:', isConnected);
|
||||
console.log('[useEventNotifications DEBUG] ===========================================');
|
||||
}, [isConnected]);
|
||||
|
||||
console.log('[useEventNotifications DEBUG] Hook返回值 - isConnected:', isConnected);
|
||||
|
||||
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;
|
||||
293
src/hooks/useNavigationEvents.js
Normal file
293
src/hooks/useNavigationEvents.js
Normal file
@@ -0,0 +1,293 @@
|
||||
// src/hooks/useNavigationEvents.js
|
||||
// 导航和菜单事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 导航事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.component - 组件名称 ('top_nav' | 'sidebar' | 'breadcrumb' | 'footer')
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useNavigationEvents = ({ component = 'navigation' } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪顶部导航点击
|
||||
* @param {string} itemName - 导航项名称
|
||||
* @param {string} path - 导航目标路径
|
||||
* @param {string} category - 导航分类 ('main' | 'user' | 'utility')
|
||||
*/
|
||||
const trackTopNavClicked = useCallback((itemName, path = '', category = 'main') => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackTopNavClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.TOP_NAV_CLICKED, {
|
||||
item_name: itemName,
|
||||
path,
|
||||
category,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🔝 Top Navigation Clicked', {
|
||||
itemName,
|
||||
path,
|
||||
category,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪侧边栏菜单点击
|
||||
* @param {string} itemName - 菜单项名称
|
||||
* @param {string} path - 目标路径
|
||||
* @param {number} level - 菜单层级 (1=主菜单, 2=子菜单)
|
||||
* @param {boolean} isExpanded - 是否展开状态
|
||||
*/
|
||||
const trackSidebarMenuClicked = useCallback((itemName, path = '', level = 1, isExpanded = false) => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackSidebarMenuClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SIDEBAR_MENU_CLICKED, {
|
||||
item_name: itemName,
|
||||
path,
|
||||
level,
|
||||
is_expanded: isExpanded,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '📂 Sidebar Menu Clicked', {
|
||||
itemName,
|
||||
path,
|
||||
level,
|
||||
isExpanded,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪通用菜单项点击
|
||||
* @param {string} itemName - 菜单项名称
|
||||
* @param {string} menuType - 菜单类型 ('dropdown' | 'context' | 'tab')
|
||||
* @param {string} path - 目标路径
|
||||
*/
|
||||
const trackMenuItemClicked = useCallback((itemName, menuType = 'dropdown', path = '') => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackMenuItemClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.MENU_ITEM_CLICKED, {
|
||||
item_name: itemName,
|
||||
menu_type: menuType,
|
||||
path,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '📋 Menu Item Clicked', {
|
||||
itemName,
|
||||
menuType,
|
||||
path,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪面包屑导航点击
|
||||
* @param {string} itemName - 面包屑项名称
|
||||
* @param {string} path - 目标路径
|
||||
* @param {number} position - 在面包屑中的位置
|
||||
* @param {number} totalItems - 面包屑总项数
|
||||
*/
|
||||
const trackBreadcrumbClicked = useCallback((itemName, path = '', position = 0, totalItems = 0) => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackBreadcrumbClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.BREADCRUMB_CLICKED, {
|
||||
item_name: itemName,
|
||||
path,
|
||||
position,
|
||||
total_items: totalItems,
|
||||
is_last: position === totalItems - 1,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🍞 Breadcrumb Clicked', {
|
||||
itemName,
|
||||
position,
|
||||
totalItems,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪Logo点击(返回首页)
|
||||
*/
|
||||
const trackLogoClicked = useCallback(() => {
|
||||
track('Logo Clicked', {
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🏠 Logo Clicked');
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪用户菜单展开
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {number} menuItemCount - 菜单项数量
|
||||
*/
|
||||
const trackUserMenuOpened = useCallback((user = {}, menuItemCount = 0) => {
|
||||
track('User Menu Opened', {
|
||||
user_id: user.id || null,
|
||||
menu_item_count: menuItemCount,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '👤 User Menu Opened', {
|
||||
userId: user.id,
|
||||
menuItemCount,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪通知中心打开
|
||||
* @param {number} unreadCount - 未读通知数量
|
||||
*/
|
||||
const trackNotificationCenterOpened = useCallback((unreadCount = 0) => {
|
||||
track('Notification Center Opened', {
|
||||
unread_count: unreadCount,
|
||||
has_unread: unreadCount > 0,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🔔 Notification Center Opened', {
|
||||
unreadCount,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪语言切换
|
||||
* @param {string} fromLanguage - 原语言
|
||||
* @param {string} toLanguage - 目标语言
|
||||
*/
|
||||
const trackLanguageChanged = useCallback((fromLanguage, toLanguage) => {
|
||||
if (!fromLanguage || !toLanguage) {
|
||||
logger.warn('useNavigationEvents', 'trackLanguageChanged: both languages are required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Language Changed', {
|
||||
from_language: fromLanguage,
|
||||
to_language: toLanguage,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🌐 Language Changed', {
|
||||
fromLanguage,
|
||||
toLanguage,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪主题切换(深色/浅色模式)
|
||||
* @param {string} fromTheme - 原主题
|
||||
* @param {string} toTheme - 目标主题
|
||||
*/
|
||||
const trackThemeChanged = useCallback((fromTheme, toTheme) => {
|
||||
if (!fromTheme || !toTheme) {
|
||||
logger.warn('useNavigationEvents', 'trackThemeChanged: both themes are required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Theme Changed', {
|
||||
from_theme: fromTheme,
|
||||
to_theme: toTheme,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🎨 Theme Changed', {
|
||||
fromTheme,
|
||||
toTheme,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪快捷键使用
|
||||
* @param {string} shortcut - 快捷键组合 (如 'Ctrl+K', 'Cmd+/')
|
||||
* @param {string} action - 触发的动作
|
||||
*/
|
||||
const trackShortcutUsed = useCallback((shortcut, action = '') => {
|
||||
if (!shortcut) {
|
||||
logger.warn('useNavigationEvents', 'trackShortcutUsed: shortcut is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Keyboard Shortcut Used', {
|
||||
shortcut,
|
||||
action,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '⌨️ Keyboard Shortcut Used', {
|
||||
shortcut,
|
||||
action,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪返回按钮点击
|
||||
* @param {string} fromPage - 当前页面
|
||||
* @param {string} toPage - 返回到的页面
|
||||
*/
|
||||
const trackBackButtonClicked = useCallback((fromPage = '', toPage = '') => {
|
||||
track('Back Button Clicked', {
|
||||
from_page: fromPage,
|
||||
to_page: toPage,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '◀️ Back Button Clicked', {
|
||||
fromPage,
|
||||
toPage,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
return {
|
||||
// 导航点击事件
|
||||
trackTopNavClicked,
|
||||
trackSidebarMenuClicked,
|
||||
trackMenuItemClicked,
|
||||
trackBreadcrumbClicked,
|
||||
trackLogoClicked,
|
||||
|
||||
// 用户交互事件
|
||||
trackUserMenuOpened,
|
||||
trackNotificationCenterOpened,
|
||||
|
||||
// 设置变更事件
|
||||
trackLanguageChanged,
|
||||
trackThemeChanged,
|
||||
|
||||
// 其他交互
|
||||
trackShortcutUsed,
|
||||
trackBackButtonClicked,
|
||||
};
|
||||
};
|
||||
|
||||
export default useNavigationEvents;
|
||||
55
src/hooks/usePageTracking.js
Normal file
55
src/hooks/usePageTracking.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/hooks/usePageTracking.js
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* Custom hook for automatic page view tracking with PostHog
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.enabled - Whether tracking is enabled
|
||||
* @param {Function} options.getProperties - Function to get custom properties for each page view
|
||||
*/
|
||||
export const usePageTracking = ({ enabled = true, getProperties } = {}) => {
|
||||
const location = useLocation();
|
||||
const previousPathRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Get the current path
|
||||
const currentPath = location.pathname + location.search;
|
||||
|
||||
// Skip if it's the same page (prevents duplicate tracking)
|
||||
if (previousPathRef.current === currentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the previous path
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
// Get custom properties if function provided
|
||||
const customProperties = getProperties ? getProperties(location) : {};
|
||||
|
||||
// Track page view with PostHog
|
||||
if (posthog && posthog.__loaded) {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
path: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
...customProperties,
|
||||
});
|
||||
|
||||
// Log in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📊 PostHog $pageview:', {
|
||||
path: location.pathname,
|
||||
...customProperties,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [location, enabled, getProperties]);
|
||||
};
|
||||
|
||||
export default usePageTracking;
|
||||
170
src/hooks/usePermissionGuide.js
Normal file
170
src/hooks/usePermissionGuide.js
Normal 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,
|
||||
};
|
||||
}
|
||||
101
src/hooks/usePostHog.js
Normal file
101
src/hooks/usePostHog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/hooks/usePostHog.js
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
getPostHog,
|
||||
trackEvent,
|
||||
trackPageView,
|
||||
identifyUser,
|
||||
setUserProperties,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
hasOptedOut,
|
||||
getFeatureFlag,
|
||||
isFeatureEnabled,
|
||||
} from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* Custom hook to access PostHog functionality
|
||||
* Provides convenient methods for tracking events and managing user sessions
|
||||
*
|
||||
* @returns {object} PostHog methods
|
||||
*/
|
||||
export const usePostHog = () => {
|
||||
// Get PostHog instance
|
||||
const posthog = getPostHog();
|
||||
|
||||
// Track custom event
|
||||
const track = useCallback((eventName, properties = {}) => {
|
||||
trackEvent(eventName, properties);
|
||||
}, []);
|
||||
|
||||
// Track page view
|
||||
const trackPage = useCallback((pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
}, []);
|
||||
|
||||
// Identify user
|
||||
const identify = useCallback((userId, userProperties = {}) => {
|
||||
identifyUser(userId, userProperties);
|
||||
}, []);
|
||||
|
||||
// Set user properties
|
||||
const setProperties = useCallback((properties) => {
|
||||
setUserProperties(properties);
|
||||
}, []);
|
||||
|
||||
// Reset user session (logout)
|
||||
const reset = useCallback(() => {
|
||||
resetUser();
|
||||
}, []);
|
||||
|
||||
// Opt out of tracking
|
||||
const optOutTracking = useCallback(() => {
|
||||
optOut();
|
||||
}, []);
|
||||
|
||||
// Opt in to tracking
|
||||
const optInTracking = useCallback(() => {
|
||||
optIn();
|
||||
}, []);
|
||||
|
||||
// Check if user has opted out
|
||||
const isOptedOut = useCallback(() => {
|
||||
return hasOptedOut();
|
||||
}, []);
|
||||
|
||||
// Get feature flag value
|
||||
const getFlag = useCallback((flagKey, defaultValue = false) => {
|
||||
return getFeatureFlag(flagKey, defaultValue);
|
||||
}, []);
|
||||
|
||||
// Check if feature is enabled
|
||||
const isEnabled = useCallback((flagKey) => {
|
||||
return isFeatureEnabled(flagKey);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Core PostHog instance
|
||||
posthog,
|
||||
|
||||
// Tracking methods
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// User management
|
||||
identify,
|
||||
setProperties,
|
||||
reset,
|
||||
|
||||
// Privacy controls
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHog;
|
||||
272
src/hooks/usePostHogRedux.js
Normal file
272
src/hooks/usePostHogRedux.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// src/hooks/usePostHogRedux.js
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
trackEvent,
|
||||
identifyUser,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
selectPostHog,
|
||||
selectIsInitialized,
|
||||
selectUser,
|
||||
selectFeatureFlags,
|
||||
selectFeatureFlag,
|
||||
selectIsOptedOut,
|
||||
selectStats,
|
||||
flushCachedEvents,
|
||||
} from '../store/slices/posthogSlice';
|
||||
import { trackPageView } from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* PostHog Redux Hook
|
||||
* 提供便捷的 PostHog 功能访问接口
|
||||
*
|
||||
* 用法示例:
|
||||
*
|
||||
* ```jsx
|
||||
* import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from 'lib/constants';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const { track, identify, user, isInitialized } = usePostHogRedux();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
* article_id: '123',
|
||||
* article_title: '标题',
|
||||
* });
|
||||
* };
|
||||
*
|
||||
* if (!isInitialized) {
|
||||
* return <div>正在加载...</div>;
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <button onClick={handleClick}>点击追踪</button>
|
||||
* {user && <p>当前用户: {user.userId}</p>}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePostHogRedux = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Selectors
|
||||
const posthog = useSelector(selectPostHog);
|
||||
const isInitialized = useSelector(selectIsInitialized);
|
||||
const user = useSelector(selectUser);
|
||||
const featureFlags = useSelector(selectFeatureFlags);
|
||||
const stats = useSelector(selectStats);
|
||||
|
||||
// ==================== 追踪事件 ====================
|
||||
|
||||
/**
|
||||
* 追踪自定义事件
|
||||
* @param {string} eventName - 事件名称(建议使用 constants.js 中的常量)
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
const track = useCallback(
|
||||
(eventName, properties = {}) => {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪页面浏览
|
||||
* @param {string} pagePath - 页面路径
|
||||
* @param {object} properties - 页面属性
|
||||
*/
|
||||
const trackPage = useCallback(
|
||||
(pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
/**
|
||||
* 识别用户(登录后调用)
|
||||
* @param {string} userId - 用户 ID
|
||||
* @param {object} userProperties - 用户属性
|
||||
*/
|
||||
const identify = useCallback(
|
||||
(userId, userProperties = {}) => {
|
||||
dispatch(identifyUser({ userId, userProperties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 重置用户会话(登出时调用)
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
dispatch(resetUser());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 隐私控制 ====================
|
||||
|
||||
/**
|
||||
* 用户选择退出追踪
|
||||
*/
|
||||
const optOutTracking = useCallback(() => {
|
||||
dispatch(optOut());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 用户选择加入追踪
|
||||
*/
|
||||
const optInTracking = useCallback(() => {
|
||||
dispatch(optIn());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 检查用户是否已退出追踪
|
||||
*/
|
||||
const isOptedOut = selectIsOptedOut();
|
||||
|
||||
// ==================== Feature Flags ====================
|
||||
|
||||
/**
|
||||
* 获取特定 Feature Flag 的值
|
||||
* @param {string} flagKey - Flag 键名
|
||||
* @returns {any} Flag 值
|
||||
*/
|
||||
const getFlag = useCallback(
|
||||
(flagKey) => {
|
||||
return selectFeatureFlag(flagKey)({ posthog });
|
||||
},
|
||||
[posthog]
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查 Feature Flag 是否启用
|
||||
* @param {string} flagKey - Flag 键名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isEnabled = useCallback(
|
||||
(flagKey) => {
|
||||
const value = getFlag(flagKey);
|
||||
return Boolean(value);
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
// ==================== 离线事件管理 ====================
|
||||
|
||||
/**
|
||||
* 刷新缓存的离线事件
|
||||
*/
|
||||
const flushEvents = useCallback(() => {
|
||||
dispatch(flushCachedEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isInitialized,
|
||||
user,
|
||||
featureFlags,
|
||||
stats,
|
||||
posthog, // 完整的 PostHog 状态
|
||||
|
||||
// 追踪方法
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// 用户管理
|
||||
identify,
|
||||
reset,
|
||||
|
||||
// 隐私控制
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature Flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
|
||||
// 离线事件
|
||||
flushEvents,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 便捷 Hooks ====================
|
||||
|
||||
/**
|
||||
* 仅获取追踪功能的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogTrack = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const track = useCallback(
|
||||
(eventName, properties = {}) => {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return { track };
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅获取 Feature Flags 的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogFlags = () => {
|
||||
const featureFlags = useSelector(selectFeatureFlags);
|
||||
const posthog = useSelector(selectPostHog);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flagKey) => {
|
||||
return selectFeatureFlag(flagKey)({ posthog });
|
||||
},
|
||||
[posthog]
|
||||
);
|
||||
|
||||
const isEnabled = useCallback(
|
||||
(flagKey) => {
|
||||
const value = getFlag(flagKey);
|
||||
return Boolean(value);
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
return {
|
||||
featureFlags,
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户信息的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogUser = () => {
|
||||
const user = useSelector(selectUser);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const identify = useCallback(
|
||||
(userId, userProperties = {}) => {
|
||||
dispatch(identifyUser({ userId, userProperties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch(resetUser());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
user,
|
||||
identify,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHogRedux;
|
||||
334
src/hooks/useProfileEvents.js
Normal file
334
src/hooks/useProfileEvents.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// src/hooks/useProfileEvents.js
|
||||
// 个人资料和设置事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 个人资料和设置事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.pageType - 页面类型 ('profile' | 'settings' | 'security')
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useProfileEvents = ({ pageType = 'profile' } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪个人资料字段编辑开始
|
||||
* @param {string} fieldName - 字段名称 ('nickname' | 'email' | 'phone' | 'avatar' | 'bio')
|
||||
*/
|
||||
const trackProfileFieldEditStarted = useCallback((fieldName) => {
|
||||
if (!fieldName) {
|
||||
logger.warn('useProfileEvents', 'trackProfileFieldEditStarted: fieldName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Profile Field Edit Started', {
|
||||
field_name: fieldName,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '✏️ Profile Field Edit Started', {
|
||||
fieldName,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新成功
|
||||
* @param {Array<string>} updatedFields - 更新的字段列表
|
||||
* @param {Object} changes - 变更详情
|
||||
*/
|
||||
const trackProfileUpdated = useCallback((updatedFields = [], changes = {}) => {
|
||||
if (!updatedFields || updatedFields.length === 0) {
|
||||
logger.warn('useProfileEvents', 'trackProfileUpdated: updatedFields array is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.PROFILE_UPDATED, {
|
||||
updated_fields: updatedFields,
|
||||
field_count: updatedFields.length,
|
||||
changes: changes,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '✅ Profile Updated', {
|
||||
updatedFields,
|
||||
fieldCount: updatedFields.length,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新失败
|
||||
* @param {Array<string>} attemptedFields - 尝试更新的字段
|
||||
* @param {string} errorMessage - 错误信息
|
||||
*/
|
||||
const trackProfileUpdateFailed = useCallback((attemptedFields = [], errorMessage = '') => {
|
||||
track('Profile Update Failed', {
|
||||
attempted_fields: attemptedFields,
|
||||
error_message: errorMessage,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '❌ Profile Update Failed', {
|
||||
attemptedFields,
|
||||
errorMessage,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪头像上传
|
||||
* @param {string} uploadMethod - 上传方式 ('file_upload' | 'url' | 'camera' | 'default_avatar')
|
||||
* @param {number} fileSize - 文件大小(bytes)
|
||||
*/
|
||||
const trackAvatarUploaded = useCallback((uploadMethod = 'file_upload', fileSize = 0) => {
|
||||
track('Avatar Uploaded', {
|
||||
upload_method: uploadMethod,
|
||||
file_size: fileSize,
|
||||
file_size_mb: (fileSize / (1024 * 1024)).toFixed(2),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🖼️ Avatar Uploaded', {
|
||||
uploadMethod,
|
||||
fileSize,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪密码更改
|
||||
* @param {boolean} success - 是否成功
|
||||
* @param {string} errorReason - 失败原因
|
||||
*/
|
||||
const trackPasswordChanged = useCallback((success = true, errorReason = '') => {
|
||||
track('Password Changed', {
|
||||
success,
|
||||
error_reason: errorReason || null,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', success ? '🔒 Password Changed Successfully' : '❌ Password Change Failed', {
|
||||
success,
|
||||
errorReason,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪邮箱验证发起
|
||||
* @param {string} email - 邮箱地址
|
||||
*/
|
||||
const trackEmailVerificationSent = useCallback((email = '') => {
|
||||
track('Email Verification Sent', {
|
||||
email_provided: Boolean(email),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '📧 Email Verification Sent', {
|
||||
emailProvided: Boolean(email),
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪手机号验证发起
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackPhoneVerificationSent = useCallback((phone = '') => {
|
||||
track('Phone Verification Sent', {
|
||||
phone_provided: Boolean(phone),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '📱 Phone Verification Sent', {
|
||||
phoneProvided: Boolean(phone),
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪账号绑定(微信、邮箱、手机等)
|
||||
* @param {string} accountType - 账号类型 ('wechat' | 'email' | 'phone')
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackAccountBound = useCallback((accountType, success = true) => {
|
||||
if (!accountType) {
|
||||
logger.warn('useProfileEvents', 'trackAccountBound: accountType is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Account Bound', {
|
||||
account_type: accountType,
|
||||
success,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', success ? '🔗 Account Bound' : '❌ Account Bind Failed', {
|
||||
accountType,
|
||||
success,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪账号解绑
|
||||
* @param {string} accountType - 账号类型
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackAccountUnbound = useCallback((accountType, success = true) => {
|
||||
if (!accountType) {
|
||||
logger.warn('useProfileEvents', 'trackAccountUnbound: accountType is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Account Unbound', {
|
||||
account_type: accountType,
|
||||
success,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', success ? '🔓 Account Unbound' : '❌ Account Unbind Failed', {
|
||||
accountType,
|
||||
success,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪设置项更改
|
||||
* @param {string} settingName - 设置名称
|
||||
* @param {any} oldValue - 旧值
|
||||
* @param {any} newValue - 新值
|
||||
* @param {string} category - 设置分类 ('notification' | 'privacy' | 'display' | 'advanced')
|
||||
*/
|
||||
const trackSettingChanged = useCallback((settingName, oldValue, newValue, category = 'general') => {
|
||||
if (!settingName) {
|
||||
logger.warn('useProfileEvents', 'trackSettingChanged: settingName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SETTINGS_CHANGED, {
|
||||
setting_name: settingName,
|
||||
old_value: String(oldValue),
|
||||
new_value: String(newValue),
|
||||
category,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '⚙️ Setting Changed', {
|
||||
settingName,
|
||||
oldValue,
|
||||
newValue,
|
||||
category,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪通知偏好更改
|
||||
* @param {Object} preferences - 通知偏好设置
|
||||
* @param {boolean} preferences.email - 邮件通知
|
||||
* @param {boolean} preferences.push - 推送通知
|
||||
* @param {boolean} preferences.sms - 短信通知
|
||||
*/
|
||||
const trackNotificationPreferencesChanged = useCallback((preferences = {}) => {
|
||||
track('Notification Preferences Changed', {
|
||||
email_enabled: preferences.email || false,
|
||||
push_enabled: preferences.push || false,
|
||||
sms_enabled: preferences.sms || false,
|
||||
total_enabled: Object.values(preferences).filter(Boolean).length,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🔔 Notification Preferences Changed', {
|
||||
preferences,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪隐私设置更改
|
||||
* @param {string} privacySetting - 隐私设置名称
|
||||
* @param {boolean} isPublic - 是否公开
|
||||
*/
|
||||
const trackPrivacySettingChanged = useCallback((privacySetting, isPublic = false) => {
|
||||
if (!privacySetting) {
|
||||
logger.warn('useProfileEvents', 'trackPrivacySettingChanged: privacySetting is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Privacy Setting Changed', {
|
||||
privacy_setting: privacySetting,
|
||||
is_public: isPublic,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🔐 Privacy Setting Changed', {
|
||||
privacySetting,
|
||||
isPublic,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪账号删除请求
|
||||
* @param {string} reason - 删除原因
|
||||
*/
|
||||
const trackAccountDeletionRequested = useCallback((reason = '') => {
|
||||
track('Account Deletion Requested', {
|
||||
reason,
|
||||
has_reason: Boolean(reason),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🗑️ Account Deletion Requested', {
|
||||
reason,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
return {
|
||||
// 个人资料编辑
|
||||
trackProfileFieldEditStarted,
|
||||
trackProfileUpdated,
|
||||
trackProfileUpdateFailed,
|
||||
trackAvatarUploaded,
|
||||
|
||||
// 安全和验证
|
||||
trackPasswordChanged,
|
||||
trackEmailVerificationSent,
|
||||
trackPhoneVerificationSent,
|
||||
|
||||
// 账号绑定
|
||||
trackAccountBound,
|
||||
trackAccountUnbound,
|
||||
|
||||
// 设置更改
|
||||
trackSettingChanged,
|
||||
trackNotificationPreferencesChanged,
|
||||
trackPrivacySettingChanged,
|
||||
|
||||
// 账号管理
|
||||
trackAccountDeletionRequested,
|
||||
};
|
||||
};
|
||||
|
||||
export default useProfileEvents;
|
||||
244
src/hooks/useSearchEvents.js
Normal file
244
src/hooks/useSearchEvents.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// src/hooks/useSearchEvents.js
|
||||
// 全局搜索功能事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 全局搜索事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.context - 搜索上下文 ('global' | 'stock' | 'news' | 'concept' | 'simulation')
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useSearchEvents = ({ context = 'global' } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪搜索开始(聚焦搜索框)
|
||||
* @param {string} placeholder - 搜索框提示文本
|
||||
*/
|
||||
const trackSearchInitiated = useCallback((placeholder = '') => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context,
|
||||
placeholder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🔍 Search Initiated', {
|
||||
context,
|
||||
placeholder,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索查询提交
|
||||
* @param {string} query - 搜索查询词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
* @param {Object} filters - 应用的筛选条件
|
||||
*/
|
||||
const trackSearchQuerySubmitted = useCallback((query, resultCount = 0, filters = {}) => {
|
||||
if (!query) {
|
||||
logger.warn('useSearchEvents', 'trackSearchQuerySubmitted: query is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
query_length: query.length,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
context,
|
||||
filters: filters,
|
||||
filter_count: Object.keys(filters).length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context,
|
||||
filters,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '❌ Search No Results', {
|
||||
query,
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
logger.debug('useSearchEvents', '✅ Search Query Submitted', {
|
||||
query,
|
||||
resultCount,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索结果点击
|
||||
* @param {Object} result - 被点击的搜索结果
|
||||
* @param {string} result.type - 结果类型 ('stock' | 'news' | 'concept' | 'event')
|
||||
* @param {string} result.id - 结果ID
|
||||
* @param {string} result.title - 结果标题
|
||||
* @param {number} position - 在搜索结果中的位置
|
||||
* @param {string} query - 搜索查询词
|
||||
*/
|
||||
const trackSearchResultClicked = useCallback((result, position = 0, query = '') => {
|
||||
if (!result || !result.type) {
|
||||
logger.warn('useSearchEvents', 'trackSearchResultClicked: result object with type is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, {
|
||||
result_type: result.type,
|
||||
result_id: result.id || result.code || '',
|
||||
result_title: result.title || result.name || '',
|
||||
position,
|
||||
query,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🎯 Search Result Clicked', {
|
||||
type: result.type,
|
||||
id: result.id || result.code,
|
||||
position,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索筛选应用
|
||||
* @param {Object} filters - 应用的筛选条件
|
||||
* @param {string} filterType - 筛选类型 ('sort' | 'category' | 'date_range' | 'price_range')
|
||||
* @param {any} filterValue - 筛选值
|
||||
*/
|
||||
const trackSearchFilterApplied = useCallback((filterType, filterValue, filters = {}) => {
|
||||
if (!filterType) {
|
||||
logger.warn('useSearchEvents', 'trackSearchFilterApplied: filterType is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: filterType,
|
||||
filter_value: String(filterValue),
|
||||
all_filters: filters,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🔍 Search Filter Applied', {
|
||||
filterType,
|
||||
filterValue,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索建议点击(自动完成)
|
||||
* @param {string} suggestion - 被点击的搜索建议
|
||||
* @param {number} position - 在建议列表中的位置
|
||||
* @param {string} source - 建议来源 ('history' | 'popular' | 'related')
|
||||
*/
|
||||
const trackSearchSuggestionClicked = useCallback((suggestion, position = 0, source = 'popular') => {
|
||||
if (!suggestion) {
|
||||
logger.warn('useSearchEvents', 'trackSearchSuggestionClicked: suggestion is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Search Suggestion Clicked', {
|
||||
suggestion,
|
||||
position,
|
||||
source,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '💡 Search Suggestion Clicked', {
|
||||
suggestion,
|
||||
position,
|
||||
source,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索历史查看
|
||||
* @param {number} historyCount - 历史记录数量
|
||||
*/
|
||||
const trackSearchHistoryViewed = useCallback((historyCount = 0) => {
|
||||
track('Search History Viewed', {
|
||||
history_count: historyCount,
|
||||
has_history: historyCount > 0,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '📜 Search History Viewed', {
|
||||
historyCount,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索历史清除
|
||||
*/
|
||||
const trackSearchHistoryCleared = useCallback(() => {
|
||||
track('Search History Cleared', {
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🗑️ Search History Cleared', {
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪热门搜索词点击
|
||||
* @param {string} keyword - 被点击的热门关键词
|
||||
* @param {number} position - 在列表中的位置
|
||||
* @param {number} heatScore - 热度分数
|
||||
*/
|
||||
const trackPopularKeywordClicked = useCallback((keyword, position = 0, heatScore = 0) => {
|
||||
if (!keyword) {
|
||||
logger.warn('useSearchEvents', 'trackPopularKeywordClicked: keyword is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Popular Keyword Clicked', {
|
||||
keyword,
|
||||
position,
|
||||
heat_score: heatScore,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🔥 Popular Keyword Clicked', {
|
||||
keyword,
|
||||
position,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
return {
|
||||
// 搜索流程事件
|
||||
trackSearchInitiated,
|
||||
trackSearchQuerySubmitted,
|
||||
trackSearchResultClicked,
|
||||
|
||||
// 筛选和建议
|
||||
trackSearchFilterApplied,
|
||||
trackSearchSuggestionClicked,
|
||||
|
||||
// 历史和热门
|
||||
trackSearchHistoryViewed,
|
||||
trackSearchHistoryCleared,
|
||||
trackPopularKeywordClicked,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSearchEvents;
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/hooks/useSubscription.js
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } 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',
|
||||
@@ -95,9 +104,32 @@ export const useSubscription = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = useRef(userId);
|
||||
const prevIsAuthenticatedRef = useRef(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
// ⚡ 只在 userId 或 isAuthenticated 真正变化时才请求
|
||||
const userIdChanged = prevUserIdRef.current !== userId;
|
||||
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
|
||||
|
||||
if (userIdChanged || authChanged) {
|
||||
logger.debug('useSubscription', 'fetchSubscriptionInfo 触发', {
|
||||
userIdChanged,
|
||||
authChanged,
|
||||
prevUserId: prevUserIdRef.current,
|
||||
currentUserId: userId,
|
||||
prevAuth: prevIsAuthenticatedRef.current,
|
||||
currentAuth: isAuthenticated
|
||||
});
|
||||
|
||||
prevUserIdRef.current = userId;
|
||||
prevIsAuthenticatedRef.current = isAuthenticated;
|
||||
fetchSubscriptionInfo();
|
||||
}, [isAuthenticated, user]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, userId]); // 使用 userId 原始值,而不是 user?.id 表达式
|
||||
|
||||
// 获取订阅级别数值
|
||||
const getSubscriptionLevel = (type = null) => {
|
||||
@@ -109,7 +141,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;
|
||||
}
|
||||
|
||||
|
||||
394
src/hooks/useSubscriptionEvents.js
Normal file
394
src/hooks/useSubscriptionEvents.js
Normal file
@@ -0,0 +1,394 @@
|
||||
// src/hooks/useSubscriptionEvents.js
|
||||
// 订阅和支付事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 订阅和支付事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Object} options.currentSubscription - 当前订阅信息
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useSubscriptionEvents = ({ currentSubscription = null } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪付费墙展示
|
||||
* @param {string} feature - 被限制的功能名称
|
||||
* @param {string} requiredPlan - 需要的订阅计划
|
||||
* @param {string} triggerLocation - 触发位置
|
||||
*/
|
||||
const trackPaywallShown = useCallback((feature, requiredPlan = 'pro', triggerLocation = '') => {
|
||||
if (!feature) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaywallShown: feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||
feature,
|
||||
required_plan: requiredPlan,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
trigger_location: triggerLocation,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚧 Paywall Shown', {
|
||||
feature,
|
||||
requiredPlan,
|
||||
triggerLocation,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪付费墙关闭
|
||||
* @param {string} feature - 功能名称
|
||||
* @param {string} closeMethod - 关闭方式 ('dismiss' | 'upgrade_clicked' | 'back_button')
|
||||
*/
|
||||
const trackPaywallDismissed = useCallback((feature, closeMethod = 'dismiss') => {
|
||||
if (!feature) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaywallDismissed: feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
|
||||
feature,
|
||||
close_method: closeMethod,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '❌ Paywall Dismissed', {
|
||||
feature,
|
||||
closeMethod,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} targetPlan - 目标订阅计划
|
||||
* @param {string} source - 来源位置
|
||||
* @param {string} feature - 关联的功能(如果从付费墙点击)
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback((targetPlan = 'pro', source = '', feature = '') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
feature: feature || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '⬆️ Upgrade Plan Clicked', {
|
||||
currentPlan: currentSubscription?.plan,
|
||||
targetPlan,
|
||||
source,
|
||||
feature,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅页面查看
|
||||
* @param {string} source - 来源
|
||||
*/
|
||||
const trackSubscriptionPageViewed = useCallback((source = '') => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
subscription_status: currentSubscription?.status || 'unknown',
|
||||
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💳 Subscription Page Viewed', {
|
||||
currentPlan: currentSubscription?.plan,
|
||||
source,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪定价计划查看
|
||||
* @param {string} planName - 计划名称 ('free' | 'pro' | 'enterprise')
|
||||
* @param {number} price - 价格
|
||||
*/
|
||||
const trackPricingPlanViewed = useCallback((planName, price = 0) => {
|
||||
if (!planName) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanViewed: planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Viewed', {
|
||||
plan_name: planName,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '👀 Pricing Plan Viewed', {
|
||||
planName,
|
||||
price,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪定价计划选择
|
||||
* @param {string} planName - 选择的计划名称
|
||||
* @param {string} billingCycle - 计费周期 ('monthly' | 'yearly')
|
||||
* @param {number} price - 价格
|
||||
*/
|
||||
const trackPricingPlanSelected = useCallback((planName, billingCycle = 'monthly', price = 0) => {
|
||||
if (!planName) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanSelected: planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Selected', {
|
||||
plan_name: planName,
|
||||
billing_cycle: billingCycle,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '✅ Pricing Plan Selected', {
|
||||
planName,
|
||||
billingCycle,
|
||||
price,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付页面查看
|
||||
* @param {string} planName - 购买的计划
|
||||
* @param {number} amount - 支付金额
|
||||
*/
|
||||
const trackPaymentPageViewed = useCallback((planName, amount = 0) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
|
||||
plan_name: planName,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💰 Payment Page Viewed', {
|
||||
planName,
|
||||
amount,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付方式选择
|
||||
* @param {string} paymentMethod - 支付方式 ('wechat_pay' | 'alipay' | 'credit_card')
|
||||
* @param {number} amount - 支付金额
|
||||
*/
|
||||
const trackPaymentMethodSelected = useCallback((paymentMethod, amount = 0) => {
|
||||
if (!paymentMethod) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaymentMethodSelected: paymentMethod is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
|
||||
payment_method: paymentMethod,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💳 Payment Method Selected', {
|
||||
paymentMethod,
|
||||
amount,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付发起
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
* @param {string} paymentInfo.planName - 计划名称
|
||||
* @param {string} paymentInfo.paymentMethod - 支付方式
|
||||
* @param {number} paymentInfo.amount - 金额
|
||||
* @param {string} paymentInfo.billingCycle - 计费周期
|
||||
* @param {string} paymentInfo.orderId - 订单ID
|
||||
*/
|
||||
const trackPaymentInitiated = useCallback((paymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚀 Payment Initiated', {
|
||||
planName: paymentInfo.planName,
|
||||
amount: paymentInfo.amount,
|
||||
paymentMethod: paymentInfo.paymentMethod,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付成功
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
*/
|
||||
const trackPaymentSuccessful = useCallback((paymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
transaction_id: paymentInfo.transactionId,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '✅ Payment Successful', {
|
||||
planName: paymentInfo.planName,
|
||||
amount: paymentInfo.amount,
|
||||
orderId: paymentInfo.orderId,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付失败
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
* @param {string} errorReason - 失败原因
|
||||
*/
|
||||
const trackPaymentFailed = useCallback((paymentInfo = {}, errorReason = '') => {
|
||||
track(REVENUE_EVENTS.PAYMENT_FAILED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
error_reason: errorReason,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '❌ Payment Failed', {
|
||||
planName: paymentInfo.planName,
|
||||
errorReason,
|
||||
orderId: paymentInfo.orderId,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅创建成功
|
||||
* @param {Object} subscription - 订阅信息
|
||||
*/
|
||||
const trackSubscriptionCreated = useCallback((subscription = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
|
||||
plan_name: subscription.plan,
|
||||
billing_cycle: subscription.billingCycle,
|
||||
amount: subscription.amount,
|
||||
start_date: subscription.startDate,
|
||||
end_date: subscription.endDate,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🎉 Subscription Created', {
|
||||
plan: subscription.plan,
|
||||
billingCycle: subscription.billingCycle,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅续费
|
||||
* @param {Object} subscription - 订阅信息
|
||||
*/
|
||||
const trackSubscriptionRenewed = useCallback((subscription = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
|
||||
plan_name: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
previous_end_date: subscription.previousEndDate,
|
||||
new_end_date: subscription.newEndDate,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🔄 Subscription Renewed', {
|
||||
plan: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪订阅取消
|
||||
* @param {string} reason - 取消原因
|
||||
* @param {boolean} cancelImmediately - 是否立即取消
|
||||
*/
|
||||
const trackSubscriptionCancelled = useCallback((reason = '', cancelImmediately = false) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
||||
plan_name: currentSubscription?.plan,
|
||||
reason,
|
||||
has_reason: Boolean(reason),
|
||||
cancel_immediately: cancelImmediately,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚫 Subscription Cancelled', {
|
||||
plan: currentSubscription?.plan,
|
||||
reason,
|
||||
cancelImmediately,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪优惠券应用
|
||||
* @param {string} couponCode - 优惠券代码
|
||||
* @param {number} discountAmount - 折扣金额
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackCouponApplied = useCallback((couponCode, discountAmount = 0, success = true) => {
|
||||
if (!couponCode) {
|
||||
logger.warn('useSubscriptionEvents', 'trackCouponApplied: couponCode is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Coupon Applied', {
|
||||
coupon_code: couponCode,
|
||||
discount_amount: discountAmount,
|
||||
success,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', success ? '🎟️ Coupon Applied' : '❌ Coupon Failed', {
|
||||
couponCode,
|
||||
discountAmount,
|
||||
success,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
return {
|
||||
// 付费墙事件
|
||||
trackPaywallShown,
|
||||
trackPaywallDismissed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 订阅页面事件
|
||||
trackSubscriptionPageViewed,
|
||||
trackPricingPlanViewed,
|
||||
trackPricingPlanSelected,
|
||||
|
||||
// 支付流程事件
|
||||
trackPaymentPageViewed,
|
||||
trackPaymentMethodSelected,
|
||||
trackPaymentInitiated,
|
||||
trackPaymentSuccessful,
|
||||
trackPaymentFailed,
|
||||
|
||||
// 订阅管理事件
|
||||
trackSubscriptionCreated,
|
||||
trackSubscriptionRenewed,
|
||||
trackSubscriptionCancelled,
|
||||
|
||||
// 优惠券事件
|
||||
trackCouponApplied,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubscriptionEvents;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
32
src/layouts/AppFooter.js
Normal file
32
src/layouts/AppFooter.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 应用通用页脚组件
|
||||
* 包含版权信息、备案号等
|
||||
*/
|
||||
const AppFooter = () => {
|
||||
return (
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppFooter;
|
||||
@@ -32,7 +32,6 @@ export default function Home() {
|
||||
<Routes>
|
||||
{/* 首页默认路由 */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/dashboard" element={<HomePage />} />
|
||||
<Route
|
||||
path="/center"
|
||||
element={
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
import PageLoader from "../components/Loading/PageLoader";
|
||||
import AppFooter from "./AppFooter";
|
||||
|
||||
/**
|
||||
* MainLayout - 带导航栏的主布局
|
||||
@@ -15,17 +16,20 @@ import PageLoader from "../components/Loading/PageLoader";
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
<Box minH="100vh" display="flex" flexDirection="column">
|
||||
{/* 导航栏 - 在所有页面间共享,不会重新渲染 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - 通过 Outlet 渲染当前路由对应的组件 */}
|
||||
{/* Suspense 只包裹内容区域,导航栏保持可见 */}
|
||||
<Box>
|
||||
<Box flex="1">
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
{/* 页脚 - 在所有页面间共享 */}
|
||||
<AppFooter />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
381
src/lib/constants.js
Normal file
381
src/lib/constants.js
Normal file
@@ -0,0 +1,381 @@
|
||||
// src/lib/constants.js
|
||||
// PostHog Event Names and Constants
|
||||
// Organized by AARRR Framework (Acquisition, Activation, Retention, Referral, Revenue)
|
||||
|
||||
// ============================================================================
|
||||
// ACQUISITION (获客) - Landing page, marketing website events
|
||||
// ============================================================================
|
||||
export const ACQUISITION_EVENTS = {
|
||||
// Landing page
|
||||
LANDING_PAGE_VIEWED: 'Landing Page Viewed',
|
||||
CTA_BUTTON_CLICKED: 'CTA Button Clicked',
|
||||
FEATURE_CARD_VIEWED: 'Feature Card Viewed',
|
||||
FEATURE_VIDEO_PLAYED: 'Feature Video Played',
|
||||
|
||||
// Pricing page
|
||||
PRICING_PAGE_VIEWED: 'Pricing Page Viewed',
|
||||
PRICING_PLAN_VIEWED: 'Pricing Plan Viewed',
|
||||
PRICING_PLAN_SELECTED: 'Pricing Plan Selected',
|
||||
|
||||
// How to use page
|
||||
HOW_TO_USE_PAGE_VIEWED: 'How To Use Page Viewed',
|
||||
TUTORIAL_STEP_VIEWED: 'Tutorial Step Viewed',
|
||||
|
||||
// Roadmap page
|
||||
ROADMAP_PAGE_VIEWED: 'Roadmap Page Viewed',
|
||||
ROADMAP_ITEM_CLICKED: 'Roadmap Item Clicked',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVATION (激活) - Sign up, login, onboarding
|
||||
// ============================================================================
|
||||
export const ACTIVATION_EVENTS = {
|
||||
// Auth pages
|
||||
LOGIN_PAGE_VIEWED: 'Login Page Viewed',
|
||||
SIGNUP_PAGE_VIEWED: 'Signup Page Viewed',
|
||||
|
||||
// Login method selection
|
||||
PHONE_LOGIN_INITIATED: 'Phone Login Initiated', // 用户开始填写手机号
|
||||
WECHAT_LOGIN_INITIATED: 'WeChat Login Initiated', // 用户选择微信登录
|
||||
|
||||
// Phone verification code flow
|
||||
VERIFICATION_CODE_SENT: 'Verification Code Sent',
|
||||
VERIFICATION_CODE_SEND_FAILED: 'Verification Code Send Failed',
|
||||
VERIFICATION_CODE_INPUT_CHANGED: 'Verification Code Input Changed',
|
||||
VERIFICATION_CODE_RESENT: 'Verification Code Resent',
|
||||
VERIFICATION_CODE_SUBMITTED: 'Verification Code Submitted',
|
||||
PHONE_NUMBER_VALIDATED: 'Phone Number Validated',
|
||||
|
||||
// WeChat login flow
|
||||
WECHAT_QR_DISPLAYED: 'WeChat QR Code Displayed',
|
||||
WECHAT_QR_SCANNED: 'WeChat QR Code Scanned',
|
||||
WECHAT_QR_EXPIRED: 'WeChat QR Code Expired',
|
||||
WECHAT_QR_REFRESHED: 'WeChat QR Code Refreshed',
|
||||
WECHAT_STATUS_CHANGED: 'WeChat Status Changed',
|
||||
WECHAT_H5_REDIRECT: 'WeChat H5 Redirect', // 移动端跳转微信H5
|
||||
|
||||
// Login/Signup results
|
||||
USER_LOGGED_IN: 'User Logged In',
|
||||
USER_SIGNED_UP: 'User Signed Up',
|
||||
LOGIN_FAILED: 'Login Failed',
|
||||
SIGNUP_FAILED: 'Signup Failed',
|
||||
|
||||
// User behavior details
|
||||
AUTH_FORM_FOCUSED: 'Auth Form Field Focused',
|
||||
AUTH_FORM_VALIDATION_ERROR: 'Auth Form Validation Error',
|
||||
NICKNAME_PROMPT_SHOWN: 'Nickname Prompt Shown',
|
||||
NICKNAME_PROMPT_ACCEPTED: 'Nickname Prompt Accepted',
|
||||
NICKNAME_PROMPT_SKIPPED: 'Nickname Prompt Skipped',
|
||||
USER_AGREEMENT_LINK_CLICKED: 'User Agreement Link Clicked',
|
||||
PRIVACY_POLICY_LINK_CLICKED: 'Privacy Policy Link Clicked',
|
||||
|
||||
// Error tracking
|
||||
LOGIN_ERROR_OCCURRED: 'Login Error Occurred',
|
||||
NETWORK_ERROR_OCCURRED: 'Network Error Occurred',
|
||||
SESSION_EXPIRED: 'Session Expired',
|
||||
API_ERROR_OCCURRED: 'API Error Occurred',
|
||||
|
||||
// Onboarding
|
||||
ONBOARDING_STARTED: 'Onboarding Started',
|
||||
ONBOARDING_STEP_COMPLETED: 'Onboarding Step Completed',
|
||||
ONBOARDING_COMPLETED: 'Onboarding Completed',
|
||||
ONBOARDING_SKIPPED: 'Onboarding Skipped',
|
||||
|
||||
// User agreement (deprecated, use link clicked events instead)
|
||||
USER_AGREEMENT_VIEWED: 'User Agreement Viewed',
|
||||
USER_AGREEMENT_ACCEPTED: 'User Agreement Accepted',
|
||||
PRIVACY_POLICY_VIEWED: 'Privacy Policy Viewed',
|
||||
PRIVACY_POLICY_ACCEPTED: 'Privacy Policy Accepted',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RETENTION (留存) - Core product usage, feature engagement
|
||||
// ============================================================================
|
||||
export const RETENTION_EVENTS = {
|
||||
// Dashboard
|
||||
DASHBOARD_VIEWED: 'Dashboard Viewed',
|
||||
DASHBOARD_CENTER_VIEWED: 'Dashboard Center Viewed',
|
||||
FUNCTION_CARD_CLICKED: 'Function Card Clicked', // Core功能卡片点击
|
||||
|
||||
// Navigation
|
||||
TOP_NAV_CLICKED: 'Top Navigation Clicked',
|
||||
SIDEBAR_MENU_CLICKED: 'Sidebar Menu Clicked',
|
||||
MENU_ITEM_CLICKED: 'Menu Item Clicked',
|
||||
BREADCRUMB_CLICKED: 'Breadcrumb Clicked',
|
||||
|
||||
// Search
|
||||
SEARCH_INITIATED: 'Search Initiated',
|
||||
SEARCH_QUERY_SUBMITTED: 'Search Query Submitted',
|
||||
SEARCH_RESULT_CLICKED: 'Search Result Clicked',
|
||||
SEARCH_NO_RESULTS: 'Search No Results',
|
||||
SEARCH_FILTER_APPLIED: 'Search Filter Applied',
|
||||
|
||||
// News/Community (新闻催化分析)
|
||||
COMMUNITY_PAGE_VIEWED: 'Community Page Viewed',
|
||||
NEWS_LIST_VIEWED: 'News List Viewed',
|
||||
NEWS_ARTICLE_CLICKED: 'News Article Clicked',
|
||||
NEWS_DETAIL_OPENED: 'News Detail Opened',
|
||||
NEWS_TAB_CLICKED: 'News Tab Clicked', // 相关标的, 相关概念, etc.
|
||||
NEWS_FILTER_APPLIED: 'News Filter Applied',
|
||||
NEWS_SORTED: 'News Sorted',
|
||||
|
||||
// Concept Center (概念中心)
|
||||
CONCEPT_PAGE_VIEWED: 'Concept Page Viewed',
|
||||
CONCEPT_LIST_VIEWED: 'Concept List Viewed',
|
||||
CONCEPT_CLICKED: 'Concept Clicked',
|
||||
CONCEPT_DETAIL_VIEWED: 'Concept Detail Viewed',
|
||||
CONCEPT_STOCK_CLICKED: 'Concept Stock Clicked',
|
||||
|
||||
// Stock Center (个股中心)
|
||||
STOCK_OVERVIEW_VIEWED: 'Stock Overview Page Viewed',
|
||||
STOCK_LIST_VIEWED: 'Stock List Viewed',
|
||||
STOCK_SEARCHED: 'Stock Searched',
|
||||
STOCK_CLICKED: 'Stock Clicked',
|
||||
STOCK_DETAIL_VIEWED: 'Stock Detail Viewed',
|
||||
STOCK_TAB_CLICKED: 'Stock Tab Clicked', // 公司概览, 股票行情, 财务全景, 盈利预测
|
||||
|
||||
// Company Details
|
||||
COMPANY_OVERVIEW_VIEWED: 'Company Overview Viewed',
|
||||
COMPANY_FINANCIALS_VIEWED: 'Company Financials Viewed',
|
||||
COMPANY_FORECAST_VIEWED: 'Company Forecast Viewed',
|
||||
COMPANY_MARKET_DATA_VIEWED: 'Company Market Data Viewed',
|
||||
|
||||
// Limit Analysis (涨停分析)
|
||||
LIMIT_ANALYSE_PAGE_VIEWED: 'Limit Analyse Page Viewed',
|
||||
LIMIT_BOARD_CLICKED: 'Limit Board Clicked',
|
||||
LIMIT_SECTOR_EXPANDED: 'Limit Sector Expanded',
|
||||
LIMIT_SECTOR_ANALYSIS_VIEWED: 'Limit Sector Analysis Viewed',
|
||||
LIMIT_STOCK_CLICKED: 'Limit Stock Clicked',
|
||||
|
||||
// Trading Simulation (模拟盘交易)
|
||||
TRADING_SIMULATION_ENTERED: 'Trading Simulation Entered',
|
||||
SIMULATION_ORDER_PLACED: 'Simulation Order Placed',
|
||||
SIMULATION_HOLDINGS_VIEWED: 'Simulation Holdings Viewed',
|
||||
SIMULATION_HISTORY_VIEWED: 'Simulation History Viewed',
|
||||
SIMULATION_STOCK_SEARCHED: 'Simulation Stock Searched',
|
||||
|
||||
// Event Details
|
||||
EVENT_DETAIL_VIEWED: 'Event Detail Viewed',
|
||||
EVENT_ANALYSIS_VIEWED: 'Event Analysis Viewed',
|
||||
EVENT_TIMELINE_CLICKED: 'Event Timeline Clicked',
|
||||
|
||||
// Profile & Settings
|
||||
PROFILE_PAGE_VIEWED: 'Profile Page Viewed',
|
||||
PROFILE_UPDATED: 'Profile Updated',
|
||||
SETTINGS_PAGE_VIEWED: 'Settings Page Viewed',
|
||||
SETTINGS_CHANGED: 'Settings Changed',
|
||||
|
||||
// Subscription Management
|
||||
SUBSCRIPTION_PAGE_VIEWED: 'Subscription Page Viewed',
|
||||
UPGRADE_PLAN_CLICKED: 'Upgrade Plan Clicked',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REFERRAL (推荐) - Sharing, inviting
|
||||
// ============================================================================
|
||||
export const REFERRAL_EVENTS = {
|
||||
// Sharing
|
||||
SHARE_BUTTON_CLICKED: 'Share Button Clicked',
|
||||
CONTENT_SHARED: 'Content Shared',
|
||||
SHARE_LINK_GENERATED: 'Share Link Generated',
|
||||
SHARE_MODAL_OPENED: 'Share Modal Opened',
|
||||
SHARE_MODAL_CLOSED: 'Share Modal Closed',
|
||||
|
||||
// Referral
|
||||
REFERRAL_PAGE_VIEWED: 'Referral Page Viewed',
|
||||
REFERRAL_LINK_COPIED: 'Referral Link Copied',
|
||||
REFERRAL_INVITE_SENT: 'Referral Invite Sent',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REVENUE (收入) - Payment, subscription, monetization
|
||||
// ============================================================================
|
||||
export const REVENUE_EVENTS = {
|
||||
// Paywall
|
||||
PAYWALL_SHOWN: 'Paywall Shown',
|
||||
PAYWALL_DISMISSED: 'Paywall Dismissed',
|
||||
PAYWALL_UPGRADE_CLICKED: 'Paywall Upgrade Clicked',
|
||||
|
||||
// Payment
|
||||
PAYMENT_PAGE_VIEWED: 'Payment Page Viewed',
|
||||
PAYMENT_METHOD_SELECTED: 'Payment Method Selected',
|
||||
PAYMENT_INITIATED: 'Payment Initiated',
|
||||
PAYMENT_SUCCESSFUL: 'Payment Successful',
|
||||
PAYMENT_FAILED: 'Payment Failed',
|
||||
|
||||
// Subscription
|
||||
SUBSCRIPTION_CREATED: 'Subscription Created',
|
||||
SUBSCRIPTION_RENEWED: 'Subscription Renewed',
|
||||
SUBSCRIPTION_UPGRADED: 'Subscription Upgraded',
|
||||
SUBSCRIPTION_DOWNGRADED: 'Subscription Downgraded',
|
||||
SUBSCRIPTION_CANCELLED: 'Subscription Cancelled',
|
||||
SUBSCRIPTION_EXPIRED: 'Subscription Expired',
|
||||
|
||||
// Refund
|
||||
REFUND_REQUESTED: 'Refund Requested',
|
||||
REFUND_PROCESSED: 'Refund Processed',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SPECIAL EVENTS (特殊事件) - Errors, performance, chatbot
|
||||
// ============================================================================
|
||||
export const SPECIAL_EVENTS = {
|
||||
// Errors
|
||||
ERROR_OCCURRED: 'Error Occurred',
|
||||
API_ERROR: 'API Error',
|
||||
NOT_FOUND_404: '404 Not Found',
|
||||
|
||||
// Performance
|
||||
PAGE_LOAD_TIME: 'Page Load Time',
|
||||
API_RESPONSE_TIME: 'API Response Time',
|
||||
|
||||
// Chatbot (Dify)
|
||||
CHATBOT_OPENED: 'Chatbot Opened',
|
||||
CHATBOT_CLOSED: 'Chatbot Closed',
|
||||
CHATBOT_MESSAGE_SENT: 'Chatbot Message Sent',
|
||||
CHATBOT_MESSAGE_RECEIVED: 'Chatbot Message Received',
|
||||
CHATBOT_FEEDBACK_PROVIDED: 'Chatbot Feedback Provided',
|
||||
|
||||
// Scroll depth
|
||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||
SCROLL_DEPTH_50: 'Scroll Depth 50%',
|
||||
SCROLL_DEPTH_75: 'Scroll Depth 75%',
|
||||
SCROLL_DEPTH_100: 'Scroll Depth 100%',
|
||||
|
||||
// Session
|
||||
SESSION_STARTED: 'Session Started',
|
||||
SESSION_ENDED: 'Session Ended',
|
||||
USER_IDLE: 'User Idle',
|
||||
USER_RETURNED: 'User Returned',
|
||||
|
||||
// Logout
|
||||
USER_LOGGED_OUT: 'User Logged Out',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// USER PROPERTIES (用户属性)
|
||||
// ============================================================================
|
||||
export const USER_PROPERTIES = {
|
||||
// Identity
|
||||
EMAIL: 'email',
|
||||
USERNAME: 'username',
|
||||
USER_ID: 'user_id',
|
||||
PHONE: 'phone',
|
||||
|
||||
// Subscription
|
||||
SUBSCRIPTION_TIER: 'subscription_tier', // 'free', 'pro', 'enterprise'
|
||||
SUBSCRIPTION_STATUS: 'subscription_status', // 'active', 'expired', 'cancelled'
|
||||
SUBSCRIPTION_START_DATE: 'subscription_start_date',
|
||||
SUBSCRIPTION_END_DATE: 'subscription_end_date',
|
||||
|
||||
// Engagement
|
||||
REGISTRATION_DATE: 'registration_date',
|
||||
LAST_LOGIN: 'last_login',
|
||||
LOGIN_COUNT: 'login_count',
|
||||
DAYS_SINCE_REGISTRATION: 'days_since_registration',
|
||||
LIFETIME_VALUE: 'lifetime_value',
|
||||
|
||||
// Preferences
|
||||
PREFERRED_LANGUAGE: 'preferred_language',
|
||||
THEME_PREFERENCE: 'theme_preference', // 'light', 'dark'
|
||||
NOTIFICATION_ENABLED: 'notification_enabled',
|
||||
|
||||
// Attribution
|
||||
UTM_SOURCE: 'utm_source',
|
||||
UTM_MEDIUM: 'utm_medium',
|
||||
UTM_CAMPAIGN: 'utm_campaign',
|
||||
REFERRER: 'referrer',
|
||||
|
||||
// Behavioral
|
||||
FAVORITE_FEATURES: 'favorite_features',
|
||||
MOST_VISITED_PAGES: 'most_visited_pages',
|
||||
TOTAL_SESSIONS: 'total_sessions',
|
||||
AVERAGE_SESSION_DURATION: 'average_session_duration',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION TIERS (订阅等级)
|
||||
// ============================================================================
|
||||
export const SUBSCRIPTION_TIERS = {
|
||||
FREE: 'free',
|
||||
PRO: 'pro',
|
||||
ENTERPRISE: 'enterprise',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAGE TYPES (页面类型)
|
||||
// ============================================================================
|
||||
export const PAGE_TYPES = {
|
||||
LANDING: 'landing',
|
||||
DASHBOARD: 'dashboard',
|
||||
FEATURE: 'feature',
|
||||
DETAIL: 'detail',
|
||||
AUTH: 'auth',
|
||||
SETTINGS: 'settings',
|
||||
PAYMENT: 'payment',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT TYPES (内容类型)
|
||||
// ============================================================================
|
||||
export const CONTENT_TYPES = {
|
||||
NEWS: 'news',
|
||||
STOCK: 'stock',
|
||||
CONCEPT: 'concept',
|
||||
ANALYSIS: 'analysis',
|
||||
EVENT: 'event',
|
||||
COMPANY: 'company',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARE CHANNELS (分享渠道)
|
||||
// ============================================================================
|
||||
export const SHARE_CHANNELS = {
|
||||
WECHAT: 'wechat',
|
||||
LINK: 'link',
|
||||
QRCODE: 'qrcode',
|
||||
EMAIL: 'email',
|
||||
COPY: 'copy',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LOGIN METHODS (登录方式)
|
||||
// ============================================================================
|
||||
export const LOGIN_METHODS = {
|
||||
WECHAT: 'wechat',
|
||||
EMAIL: 'email',
|
||||
PHONE: 'phone',
|
||||
USERNAME: 'username',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAYMENT METHODS (支付方式)
|
||||
// ============================================================================
|
||||
export const PAYMENT_METHODS = {
|
||||
WECHAT_PAY: 'wechat_pay',
|
||||
ALIPAY: 'alipay',
|
||||
CREDIT_CARD: 'credit_card',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to get all events
|
||||
// ============================================================================
|
||||
export const getAllEvents = () => {
|
||||
return {
|
||||
...ACQUISITION_EVENTS,
|
||||
...ACTIVATION_EVENTS,
|
||||
...RETENTION_EVENTS,
|
||||
...REFERRAL_EVENTS,
|
||||
...REVENUE_EVENTS,
|
||||
...SPECIAL_EVENTS,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to validate event name
|
||||
// ============================================================================
|
||||
export const isValidEvent = (eventName) => {
|
||||
const allEvents = getAllEvents();
|
||||
return Object.values(allEvents).includes(eventName);
|
||||
};
|
||||
271
src/lib/posthog.js
Normal file
271
src/lib/posthog.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// src/lib/posthog.js
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* Initialize PostHog SDK
|
||||
* Should be called once when the app starts
|
||||
*/
|
||||
export const initPostHog = () => {
|
||||
// Only run in browser environment
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const apiKey = process.env.REACT_APP_POSTHOG_KEY;
|
||||
const apiHost = process.env.REACT_APP_POSTHOG_HOST || 'https://app.posthog.com';
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('⚠️ PostHog API key not found. Analytics will be disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
|
||||
// Session Recording Configuration
|
||||
session_recording: {
|
||||
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
|
||||
// Privacy: Mask sensitive input fields
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
'data-sensitive': true, // Custom attribute for sensitive fields
|
||||
},
|
||||
|
||||
// Record canvas for charts/graphs
|
||||
recordCanvas: true,
|
||||
|
||||
// Network payload capture (useful for debugging API issues)
|
||||
networkPayloadCapture: {
|
||||
recordHeaders: true,
|
||||
recordBody: true,
|
||||
// Don't record sensitive endpoints
|
||||
urlBlocklist: [
|
||||
'/api/auth/session',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/payment',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Performance optimization
|
||||
batch_size: 10, // Send events in batches of 10
|
||||
batch_interval_ms: 3000, // Or every 3 seconds
|
||||
|
||||
// Privacy settings
|
||||
respect_dnt: true, // Respect Do Not Track browser setting
|
||||
persistence: 'localStorage+cookie', // Use both for reliability
|
||||
|
||||
// Feature flags (for A/B testing)
|
||||
bootstrap: {
|
||||
featureFlags: {},
|
||||
},
|
||||
|
||||
// Autocapture settings
|
||||
autocapture: {
|
||||
// Automatically capture clicks on buttons, links, etc.
|
||||
dom_event_allowlist: ['click', 'submit', 'change'],
|
||||
|
||||
// Capture additional element properties
|
||||
capture_copied_text: false, // Don't capture copied text (privacy)
|
||||
},
|
||||
|
||||
// Development debugging
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
posthogInstance.debug(); // Enable debug mode in development
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📊 PostHog Analytics initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ PostHog initialization failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get PostHog instance
|
||||
* @returns {object} PostHog instance
|
||||
*/
|
||||
export const getPostHog = () => {
|
||||
return posthog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identify user with PostHog
|
||||
* Call this after successful login/registration
|
||||
*
|
||||
* @param {string} userId - Unique user identifier
|
||||
* @param {object} userProperties - User properties (email, name, subscription_tier, etc.)
|
||||
*/
|
||||
export const identifyUser = (userId, userProperties = {}) => {
|
||||
if (!userId) {
|
||||
console.warn('⚠️ Cannot identify user: userId is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthog.identify(userId, {
|
||||
email: userProperties.email,
|
||||
username: userProperties.username,
|
||||
subscription_tier: userProperties.subscription_tier || 'free',
|
||||
role: userProperties.role,
|
||||
registration_date: userProperties.registration_date,
|
||||
last_login: new Date().toISOString(),
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
console.log('👤 User identified:', userId);
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user properties
|
||||
* Use this to update user attributes without re-identifying
|
||||
*
|
||||
* @param {object} properties - Properties to update
|
||||
*/
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
console.log('📝 User properties updated');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
*
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {object} properties - Event properties
|
||||
*/
|
||||
export const trackEvent = (eventName, properties = {}) => {
|
||||
try {
|
||||
posthog.capture(eventName, {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📍 Event tracked:', eventName, properties);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
* @param {string} pagePath - Current page path
|
||||
* @param {object} properties - Additional properties
|
||||
*/
|
||||
export const trackPageView = (pagePath, properties = {}) => {
|
||||
try {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
page_path: pagePath,
|
||||
page_title: document.title,
|
||||
referrer: document.referrer,
|
||||
...properties,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📄 Page view tracked:', pagePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset user session
|
||||
* Call this on logout
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
console.log('🔄 User session reset');
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User opt-out from tracking
|
||||
*/
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
console.log('🚫 User opted out of tracking');
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User opt-in to tracking
|
||||
*/
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
console.log('✅ User opted in to tracking');
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has opted out
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasOptedOut = () => {
|
||||
try {
|
||||
return posthog.has_opted_out_capturing();
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check opt-out status:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get feature flag value
|
||||
* @param {string} flagKey - Feature flag key
|
||||
* @param {any} defaultValue - Default value if flag not found
|
||||
* @returns {any} Feature flag value
|
||||
*/
|
||||
export const getFeatureFlag = (flagKey, defaultValue = false) => {
|
||||
try {
|
||||
return posthog.getFeatureFlag(flagKey) || defaultValue;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get feature flag:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if feature flag is enabled
|
||||
* @param {string} flagKey - Feature flag key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isFeatureEnabled = (flagKey) => {
|
||||
try {
|
||||
return posthog.isFeatureEnabled(flagKey);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check feature flag:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default posthog;
|
||||
@@ -19,7 +19,10 @@ export async function startMockServiceWorker() {
|
||||
|
||||
try {
|
||||
await worker.start({
|
||||
// 不显示未拦截的请求警告(可选)
|
||||
// 🎯 智能穿透模式(关键配置)
|
||||
// 'bypass': 未定义 Mock 的请求自动转发到真实后端
|
||||
// 'warn': 未定义的请求会显示警告(调试用)
|
||||
// 'error': 未定义的请求会抛出错误(严格模式)
|
||||
onUnhandledRequest: 'bypass',
|
||||
|
||||
// 自定义 Service Worker URL(如果需要)
|
||||
@@ -27,7 +30,7 @@ export async function startMockServiceWorker() {
|
||||
url: '/mockServiceWorker.js',
|
||||
},
|
||||
|
||||
// 静默模式(不在控制台打印启动消息)
|
||||
// 是否在控制台显示启动日志和拦截日志 静默模式(不在控制台打印启动消息)
|
||||
quiet: false,
|
||||
});
|
||||
|
||||
@@ -36,11 +39,11 @@ export async function startMockServiceWorker() {
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(
|
||||
'%c提示: 所有 API 请求将使用本地 Mock 数据',
|
||||
'%c智能穿透模式:已定义 Mock → 返回假数据 | 未定义 Mock → 转发到 ' + (process.env.REACT_APP_API_URL || '真实后端'),
|
||||
'color: #FF9800; font-size: 12px;'
|
||||
);
|
||||
console.log(
|
||||
'%c要禁用 Mock,请设置 REACT_APP_ENABLE_MOCK=false',
|
||||
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
|
||||
'color: #2196F3; font-size: 12px;'
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
743
src/mocks/data/account.js
Normal file
743
src/mocks/data/account.js
Normal 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'
|
||||
}));
|
||||
}
|
||||
535
src/mocks/data/company.js
Normal file
535
src/mocks/data/company.js
Normal file
@@ -0,0 +1,535 @@
|
||||
// src/mocks/data/company.js
|
||||
// 公司相关的 Mock 数据
|
||||
|
||||
// 平安银行 (000001) 的完整数据
|
||||
export const PINGAN_BANK_DATA = {
|
||||
stockCode: '000001',
|
||||
stockName: '平安银行',
|
||||
|
||||
// 基本信息
|
||||
basicInfo: {
|
||||
code: '000001',
|
||||
name: '平安银行',
|
||||
english_name: 'Ping An Bank Co., Ltd.',
|
||||
registered_capital: 1940642.3, // 万元
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '谢永林',
|
||||
general_manager: '谢永林',
|
||||
secretary: '周强',
|
||||
registered_address: '深圳市深南东路5047号',
|
||||
office_address: '深圳市深南东路5047号',
|
||||
zipcode: '518001',
|
||||
phone: '0755-82080387',
|
||||
fax: '0755-82080386',
|
||||
email: 'ir@bank.pingan.com',
|
||||
website: 'http://bank.pingan.com',
|
||||
business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。',
|
||||
employees: 36542,
|
||||
introduction: '平安银行股份有限公司是中国平安保险(集团)股份有限公司控股的一家跨区域经营的股份制商业银行,为中国大陆12家全国性股份制商业银行之一。注册资本为人民币51.2335亿元,总资产近1.37万亿元,总部位于深圳。平安银行拥有全国性银行经营资质,主要经营商业银行业务。',
|
||||
list_date: '1991-04-03',
|
||||
establish_date: '1987-12-22',
|
||||
province: '广东省',
|
||||
city: '深圳市',
|
||||
industry: '银行',
|
||||
main_business: '商业银行业务',
|
||||
},
|
||||
|
||||
// 实际控制人信息
|
||||
actualControl: {
|
||||
controller_name: '中国平安保险(集团)股份有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 52.38,
|
||||
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
|
||||
is_listed: true,
|
||||
change_date: '2023-12-31',
|
||||
remark: '中国平安通过直接和间接方式控股平安银行',
|
||||
},
|
||||
|
||||
// 股权集中度
|
||||
concentration: {
|
||||
top1_ratio: 52.38,
|
||||
top3_ratio: 58.42,
|
||||
top5_ratio: 60.15,
|
||||
top10_ratio: 63.28,
|
||||
update_date: '2024-09-30',
|
||||
concentration_level: '高度集中',
|
||||
herfindahl_index: 0.2845,
|
||||
},
|
||||
|
||||
// 高管信息
|
||||
management: [
|
||||
{
|
||||
name: '谢永林',
|
||||
position: '董事长、执行董事、行长',
|
||||
gender: '男',
|
||||
age: 56,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-01-01',
|
||||
annual_compensation: 723.8,
|
||||
shareholding: 0,
|
||||
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官'
|
||||
},
|
||||
{
|
||||
name: '周强',
|
||||
position: '执行董事、副行长、董事会秘书',
|
||||
gender: '男',
|
||||
age: 54,
|
||||
education: '硕士',
|
||||
appointment_date: '2016-06-01',
|
||||
annual_compensation: 542.3,
|
||||
shareholding: 0.002,
|
||||
background: '历任平安银行深圳分行行长'
|
||||
},
|
||||
{
|
||||
name: '郭世邦',
|
||||
position: '执行董事、副行长、首席财务官',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
education: '博士',
|
||||
appointment_date: '2018-03-01',
|
||||
annual_compensation: 498.6,
|
||||
shareholding: 0.001,
|
||||
background: '历任中国平安集团财务负责人'
|
||||
},
|
||||
{
|
||||
name: '蔡新发',
|
||||
position: '副行长、首席风险官',
|
||||
gender: '男',
|
||||
age: 51,
|
||||
education: '硕士',
|
||||
appointment_date: '2017-05-01',
|
||||
annual_compensation: 467.2,
|
||||
shareholding: 0.0008,
|
||||
background: '历任平安银行风险管理部总经理'
|
||||
},
|
||||
{
|
||||
name: '项有志',
|
||||
position: '副行长、首席信息官',
|
||||
gender: '男',
|
||||
age: 49,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-09-01',
|
||||
annual_compensation: 425.1,
|
||||
shareholding: 0,
|
||||
background: '历任中国平安科技公司总经理'
|
||||
}
|
||||
],
|
||||
|
||||
// 十大流通股东
|
||||
topCirculationShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
|
||||
],
|
||||
|
||||
// 十大股东(与流通股东相同,因为平安银行全流通)
|
||||
topShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
|
||||
],
|
||||
|
||||
// 分支机构
|
||||
branches: [
|
||||
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
|
||||
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
|
||||
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
|
||||
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
|
||||
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
|
||||
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
|
||||
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
|
||||
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
|
||||
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
|
||||
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
|
||||
],
|
||||
|
||||
// 公告列表
|
||||
announcements: [
|
||||
{
|
||||
title: '平安银行股份有限公司2024年第三季度报告',
|
||||
publish_date: '2024-10-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年前三季度实现营业收入1245.6亿元,同比增长8.2%;净利润402.3亿元,同比增长12.5%',
|
||||
url: '/announcement/detail/ann_20241028_001'
|
||||
},
|
||||
{
|
||||
title: '关于召开2024年第一次临时股东大会的通知',
|
||||
publish_date: '2024-10-15',
|
||||
type: '临时公告',
|
||||
summary: '定于2024年11月5日召开2024年第一次临时股东大会,审议关于调整董事会成员等议案',
|
||||
url: '/announcement/detail/ann_20241015_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
|
||||
publish_date: '2024-09-20',
|
||||
type: '临时公告',
|
||||
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
|
||||
url: '/announcement/detail/ann_20240920_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司2024年半年度报告',
|
||||
publish_date: '2024-08-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年上半年实现营业收入828.5亿元,同比增长7.8%;净利润265.4亿元,同比增长11.2%',
|
||||
url: '/announcement/detail/ann_20240828_001'
|
||||
},
|
||||
{
|
||||
title: '关于2024年上半年利润分配预案的公告',
|
||||
publish_date: '2024-08-20',
|
||||
type: '分配方案',
|
||||
summary: '拟以总股本194.06亿股为基数,向全体股东每10股派发现金红利2.8元(含税)',
|
||||
url: '/announcement/detail/ann_20240820_001'
|
||||
}
|
||||
],
|
||||
|
||||
// 披露时间表
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
|
||||
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
|
||||
],
|
||||
|
||||
// 综合分析
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: '平安银行股份有限公司',
|
||||
stock_code: '000001',
|
||||
industry: '银行',
|
||||
established_date: '1987-12-22',
|
||||
listing_date: '1991-04-03',
|
||||
total_assets: 50245.6, // 亿元
|
||||
net_assets: 3256.8,
|
||||
registered_capital: 194.06,
|
||||
employee_count: 36542
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: 1623.5,
|
||||
revenue_growth: 8.5,
|
||||
net_profit: 528.6,
|
||||
profit_growth: 12.3,
|
||||
roe: 16.23,
|
||||
roa: 1.05,
|
||||
asset_quality_ratio: 1.02,
|
||||
capital_adequacy_ratio: 13.45,
|
||||
core_tier1_ratio: 10.82
|
||||
},
|
||||
business_structure: [
|
||||
{ business: '对公业务', revenue: 685.4, ratio: 42.2, growth: 6.8 },
|
||||
{ business: '零售业务', revenue: 812.3, ratio: 50.1, growth: 11.2 },
|
||||
{ business: '金融市场业务', revenue: 125.8, ratio: 7.7, growth: 3.5 }
|
||||
],
|
||||
competitive_advantages: [
|
||||
'背靠中国平安集团,综合金融优势明显',
|
||||
'零售业务转型成效显著,客户基础雄厚',
|
||||
'金融科技创新能力强,数字化银行建设领先',
|
||||
'风险管理体系完善,资产质量稳定',
|
||||
'管理团队经验丰富,执行力强'
|
||||
],
|
||||
risk_factors: [
|
||||
'宏观经济下行压力影响信贷质量',
|
||||
'利率市场化导致息差收窄',
|
||||
'金融监管趋严,合规成本上升',
|
||||
'同业竞争激烈,市场份额面临挑战',
|
||||
'金融科技发展带来的技术和运营风险'
|
||||
],
|
||||
development_strategy: '坚持"科技引领、零售突破、对公做精"战略,加快数字化转型,提升综合金融服务能力',
|
||||
analyst_rating: {
|
||||
buy: 18,
|
||||
hold: 12,
|
||||
sell: 2,
|
||||
target_price: 15.8,
|
||||
current_price: 13.2
|
||||
}
|
||||
},
|
||||
|
||||
// 价值链分析
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '央行及监管机构', relationship: '政策与监管', importance: '高', description: '接受货币政策调控和监管指导' },
|
||||
{ name: '同业资金市场', relationship: '资金来源', importance: '高', description: '开展同业拆借、债券回购等业务' },
|
||||
{ name: '金融科技公司', relationship: '技术支持', importance: '中', description: '提供金融科技解决方案和技术服务' }
|
||||
],
|
||||
core_business: {
|
||||
deposit_business: { scale: 33256.8, market_share: 2.8, growth_rate: 9.2 },
|
||||
loan_business: { scale: 28945.3, market_share: 2.5, growth_rate: 12.5 },
|
||||
intermediary_business: { scale: 425.6, market_share: 3.2, growth_rate: 15.8 },
|
||||
digital_banking: { user_count: 11256, app_mau: 4235, growth_rate: 28.5 }
|
||||
},
|
||||
downstream: [
|
||||
{ name: '个人客户', scale: '1.12亿户', contribution: '50.1%', description: '零售银行业务主体' },
|
||||
{ name: '企业客户', scale: '85.6万户', contribution: '42.2%', description: '对公业务主体' },
|
||||
{ name: '政府机构', scale: '2.3万户', contribution: '7.7%', description: '公共事业及政府业务' }
|
||||
],
|
||||
ecosystem_partners: [
|
||||
{ name: '中国平安集团', type: '关联方', cooperation: '综合金融服务、客户共享' },
|
||||
{ name: '平安科技', type: '科技支持', cooperation: '金融科技研发、系统建设' },
|
||||
{ name: '平安普惠', type: '业务协同', cooperation: '普惠金融、小微贷款' },
|
||||
{ name: '平安证券', type: '业务协同', cooperation: '投资银行、资产管理' }
|
||||
]
|
||||
},
|
||||
|
||||
// 关键因素时间线
|
||||
keyFactorsTimeline: [
|
||||
{
|
||||
date: '2024-10-28',
|
||||
event: '发布2024年三季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '前三季度净利润同比增长12.5%,超市场预期',
|
||||
change: '+5.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-09-15',
|
||||
event: '推出AI智能客服系统',
|
||||
type: '科技创新',
|
||||
importance: 'medium',
|
||||
impact: '提升客户服务效率,降低运营成本',
|
||||
change: '+2.1%'
|
||||
},
|
||||
{
|
||||
date: '2024-08-28',
|
||||
event: '发布2024年中报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '上半年净利润增长11.2%,资产质量保持稳定',
|
||||
change: '+3.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-07-20',
|
||||
event: '获批设立理财子公司',
|
||||
type: '业务拓展',
|
||||
importance: 'high',
|
||||
impact: '完善财富管理业务布局,拓展收入来源',
|
||||
change: '+4.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-06-10',
|
||||
event: '完成300亿元二级资本债发行',
|
||||
type: '融资事件',
|
||||
importance: 'medium',
|
||||
impact: '补充资本实力,支持业务扩张',
|
||||
change: '+1.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-04-30',
|
||||
event: '发布2024年一季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '一季度净利润增长10.8%,开门红表现优异',
|
||||
change: '+4.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-03-15',
|
||||
event: '零售客户突破1.1亿户',
|
||||
type: '业务里程碑',
|
||||
importance: 'medium',
|
||||
impact: '零售转型成效显著,客户基础进一步夯实',
|
||||
change: '+2.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-01-20',
|
||||
event: '获评"2023年度最佳零售银行"',
|
||||
type: '荣誉奖项',
|
||||
importance: 'low',
|
||||
impact: '品牌影响力提升',
|
||||
change: '+0.8%'
|
||||
}
|
||||
],
|
||||
|
||||
// 盈利预测报告
|
||||
forecastReport: {
|
||||
// 营收与利润趋势
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], // 营业总收入(百万元)
|
||||
profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] // 归母净利润(百万元)
|
||||
},
|
||||
// 增长率分析
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] // 营收增长率(%)
|
||||
},
|
||||
// EPS趋势
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] // EPS(稀释,元/股)
|
||||
},
|
||||
// PE与PEG分析
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], // PE(倍)
|
||||
peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] // PEG
|
||||
},
|
||||
// 详细数据表格
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 116524, '2021': 134632, '2022': 148956, '2023': 162350, '2024E': 175280, '2025E': 189450, '2026E': 204120 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 15.5, '2022': 10.6, '2023': 8.9, '2024E': 8.0, '2025E': 8.1, '2026E': 7.7 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 34562, '2021': 39845, '2022': 43218, '2023': 52860, '2024E': 58420, '2025E': 64680, '2026E': 71250 },
|
||||
{ '指标': '净利润增长率(%)', '2020': '-', '2021': 15.3, '2022': 8.5, '2023': 22.3, '2024E': 10.5, '2025E': 10.7, '2026E': 10.2 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 1.78, '2021': 2.05, '2022': 2.23, '2023': 2.72, '2024E': 3.01, '2025E': 3.33, '2026E': 3.67 },
|
||||
{ '指标': 'ROE(%)', '2020': 14.2, '2021': 15.8, '2022': 15.5, '2023': 16.2, '2024E': 16.5, '2025E': 16.8, '2026E': 17.0 },
|
||||
{ '指标': '总资产(百万元)', '2020': 4512360, '2021': 4856230, '2022': 4923150, '2023': 5024560, '2024E': 5230480, '2025E': 5445200, '2026E': 5668340 },
|
||||
{ '指标': '净资产(百万元)', '2020': 293540, '2021': 312680, '2022': 318920, '2023': 325680, '2024E': 338560, '2025E': 352480, '2026E': 367820 },
|
||||
{ '指标': '资产负债率(%)', '2020': 93.5, '2021': 93.6, '2022': 93.5, '2023': 93.5, '2024E': 93.5, '2025E': 93.5, '2026E': 93.5 },
|
||||
{ '指标': 'PE(倍)', '2020': 7.4, '2021': 6.9, '2022': 7.2, '2023': 4.9, '2024E': 4.4, '2025E': 4.0, '2026E': 3.6 },
|
||||
{ '指标': 'PB(倍)', '2020': 1.05, '2021': 1.09, '2022': 1.12, '2023': 0.79, '2024E': 0.72, '2025E': 0.67, '2026E': 0.61 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 生成通用公司数据的工具函数
|
||||
export const generateCompanyData = (stockCode, stockName) => {
|
||||
// 如果是平安银行,直接返回详细数据
|
||||
if (stockCode === '000001') {
|
||||
return PINGAN_BANK_DATA;
|
||||
}
|
||||
|
||||
// 否则生成通用数据
|
||||
return {
|
||||
stockCode,
|
||||
stockName,
|
||||
basicInfo: {
|
||||
code: stockCode,
|
||||
name: stockName,
|
||||
registered_capital: Math.floor(Math.random() * 500000) + 10000,
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '张三',
|
||||
general_manager: '李四',
|
||||
secretary: '王五',
|
||||
registered_address: '中国某省某市某区某路123号',
|
||||
office_address: '中国某省某市某区某路123号',
|
||||
phone: '021-12345678',
|
||||
email: 'ir@company.com',
|
||||
website: 'http://www.company.com',
|
||||
employees: Math.floor(Math.random() * 10000) + 1000,
|
||||
list_date: '2010-01-01',
|
||||
industry: '制造业',
|
||||
},
|
||||
actualControl: {
|
||||
controller_name: '某控股集团有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 35.5,
|
||||
control_chain: '某控股集团有限公司 -> ' + stockName,
|
||||
},
|
||||
concentration: {
|
||||
top1_ratio: 35.5,
|
||||
top3_ratio: 52.3,
|
||||
top5_ratio: 61.8,
|
||||
top10_ratio: 72.5,
|
||||
concentration_level: '适度集中',
|
||||
},
|
||||
management: [
|
||||
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5 },
|
||||
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3 },
|
||||
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2 },
|
||||
],
|
||||
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业'
|
||||
})),
|
||||
topShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业',
|
||||
is_restricted: false
|
||||
})),
|
||||
branches: [
|
||||
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司' },
|
||||
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司' },
|
||||
],
|
||||
announcements: [
|
||||
{ title: stockName + '2024年第三季度报告', publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长' },
|
||||
{ title: stockName + '2024年半年度报告', publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好' },
|
||||
],
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
],
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: stockName,
|
||||
stock_code: stockCode,
|
||||
industry: '制造业',
|
||||
total_assets: Math.floor(Math.random() * 10000) + 100,
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: Math.floor(Math.random() * 1000) + 50,
|
||||
revenue_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
net_profit: Math.floor(Math.random() * 100) + 10,
|
||||
profit_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
},
|
||||
competitive_advantages: ['技术领先', '品牌优势', '管理团队优秀'],
|
||||
risk_factors: ['市场竞争激烈', '原材料价格波动'],
|
||||
},
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '原材料供应商A', relationship: '供应商', importance: '高' },
|
||||
{ name: '原材料供应商B', relationship: '供应商', importance: '中' },
|
||||
],
|
||||
downstream: [
|
||||
{ name: '经销商网络', scale: '1000家', contribution: '60%' },
|
||||
{ name: '直营渠道', scale: '100家', contribution: '40%' },
|
||||
],
|
||||
},
|
||||
keyFactorsTimeline: [
|
||||
{ date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期' },
|
||||
{ date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长' },
|
||||
],
|
||||
// 通用预测报告数据
|
||||
forecastReport: {
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [5000, 5800, 6500, 7200, 7900, 8600, 9400],
|
||||
profit: [450, 520, 580, 650, 720, 800, 890]
|
||||
},
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [16.0, 12.1, 10.8, 9.7, 8.9, 9.3]
|
||||
},
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [0.45, 0.52, 0.58, 0.65, 0.72, 0.80, 0.89]
|
||||
},
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [22.2, 19.2, 17.2, 15.4, 13.9, 12.5, 11.2],
|
||||
peg: [1.39, 1.59, 1.59, 1.42, 1.43, 1.40, 1.20]
|
||||
},
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 5000, '2021': 5800, '2022': 6500, '2023': 7200, '2024E': 7900, '2025E': 8600, '2026E': 9400 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 16.0, '2022': 12.1, '2023': 10.8, '2024E': 9.7, '2025E': 8.9, '2026E': 9.3 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 450, '2021': 520, '2022': 580, '2023': 650, '2024E': 720, '2025E': 800, '2026E': 890 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.58, '2023': 0.65, '2024E': 0.72, '2025E': 0.80, '2026E': 0.89 },
|
||||
{ '指标': 'ROE(%)', '2020': 12.5, '2021': 13.2, '2022': 13.8, '2023': 14.2, '2024E': 14.5, '2025E': 14.8, '2026E': 15.0 },
|
||||
{ '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 17.2, '2023': 15.4, '2024E': 13.9, '2025E': 12.5, '2026E': 11.2 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -545,3 +545,274 @@ 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 = '',
|
||||
stock_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%
|
||||
|
||||
// 为每个事件随机选择2-5个相关股票
|
||||
const relatedStockCount = 2 + (i % 4); // 2-5个股票
|
||||
const relatedStocks = [];
|
||||
const industryStocks = stockPool.filter(s => s.industry === industry);
|
||||
|
||||
// 优先选择同行业股票
|
||||
if (industryStocks.length > 0) {
|
||||
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
|
||||
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果同行业股票不够,从整个 stockPool 中补充
|
||||
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
|
||||
const randomStock = stockPool[relatedStocks.length % stockPool.length];
|
||||
if (!relatedStocks.includes(randomStock.stock_code)) {
|
||||
relatedStocks.push(randomStock.stock_code);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
related_stocks: relatedStocks, // 添加相关股票列表
|
||||
});
|
||||
}
|
||||
|
||||
// 筛选
|
||||
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 (stock_code) {
|
||||
// 移除可能的后缀 (.SH, .SZ)
|
||||
const cleanStockCode = stock_code.replace(/\.(SH|SZ)$/, '');
|
||||
filteredEvents = filteredEvents.filter(e => {
|
||||
if (!e.related_stocks || e.related_stocks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// 检查事件的 related_stocks 中是否包含该股票代码
|
||||
return e.related_stocks.some(code => {
|
||||
const cleanCode = code.replace(/\.(SH|SZ)$/, '');
|
||||
return cleanCode === cleanStockCode || code === stock_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',
|
||||
}));
|
||||
}
|
||||
|
||||
139
src/mocks/data/financial.js
Normal file
139
src/mocks/data/financial.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// src/mocks/data/financial.js
|
||||
// 财务数据相关的 Mock 数据
|
||||
|
||||
// 生成财务数据
|
||||
export const generateFinancialData = (stockCode) => {
|
||||
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
balanceSheet: periods.map((period, i) => ({
|
||||
period,
|
||||
total_assets: 5024560 - i * 50000, // 百万元
|
||||
total_liabilities: 4698880 - i * 48000,
|
||||
shareholders_equity: 325680 - i * 2000,
|
||||
current_assets: 2512300 - i * 25000,
|
||||
non_current_assets: 2512260 - i * 25000,
|
||||
current_liabilities: 3456780 - i * 35000,
|
||||
non_current_liabilities: 1242100 - i * 13000
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
incomeStatement: periods.map((period, i) => ({
|
||||
period,
|
||||
revenue: 162350 - i * 4000, // 百万元
|
||||
operating_cost: 45620 - i * 1200,
|
||||
gross_profit: 116730 - i * 2800,
|
||||
operating_profit: 68450 - i * 1500,
|
||||
net_profit: 52860 - i * 1200,
|
||||
eps: 2.72 - i * 0.06
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
cashflow: periods.map((period, i) => ({
|
||||
period,
|
||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
||||
investing_cashflow: -45300 - i * 1000,
|
||||
financing_cashflow: -38200 + i * 500,
|
||||
net_cashflow: 42100 - i * 1500,
|
||||
cash_ending: 456780 - i * 10000
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
financialMetrics: periods.map((period, i) => ({
|
||||
period,
|
||||
roe: 16.23 - i * 0.3, // %
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_margin: 32.56 - i * 0.3,
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
debt_ratio: 93.52 + i * 0.05,
|
||||
asset_turnover: 0.41 - i * 0.01,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
receivable_turnover: 0 // 银行特殊
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
mainBusiness: {
|
||||
by_product: [
|
||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
||||
],
|
||||
by_region: [
|
||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
||||
]
|
||||
},
|
||||
|
||||
// 业绩预告
|
||||
forecast: {
|
||||
period: '2024',
|
||||
forecast_net_profit_min: 580000, // 百万元
|
||||
forecast_net_profit_max: 620000,
|
||||
yoy_growth_min: 10.0, // %
|
||||
yoy_growth_max: 17.0,
|
||||
forecast_type: '预增',
|
||||
reason: '受益于零售业务快速增长及资产质量改善,预计全年业绩保持稳定增长',
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
|
||||
// 行业排名
|
||||
industryRank: {
|
||||
industry: '银行',
|
||||
total_companies: 42,
|
||||
rankings: [
|
||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
||||
]
|
||||
},
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
||||
metrics: [
|
||||
{
|
||||
name: '营业收入',
|
||||
unit: '百万元',
|
||||
values: [41500, 40800, 40200, 40850],
|
||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
unit: '百万元',
|
||||
values: [13420, 13180, 13050, 13210],
|
||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
||||
},
|
||||
{
|
||||
name: 'ROE',
|
||||
unit: '%',
|
||||
values: [16.23, 15.98, 15.75, 16.02],
|
||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
name: 'EPS',
|
||||
unit: '元',
|
||||
values: [0.69, 0.68, 0.67, 0.68],
|
||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user