Compare commits
10 Commits
feature_20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f20e3049 | ||
|
|
7cca5e73c0 | ||
|
|
f9ed6c19de | ||
|
|
112fbbd42d | ||
| d4f813d58e | |||
| 05063374c0 | |||
| dac966e2d8 | |||
|
|
3a4dade8ec | ||
|
|
6f81259f8c | ||
|
|
864844a52b |
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(xargs ls:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(npm start)",
|
||||||
|
"Bash(python3:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 部署配置文件
|
|
||||||
# 首次使用请复制此文件为 .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 run start:dev
|
# 使用方式: npm start
|
||||||
|
|
||||||
# React 构建优化配置
|
# React 构建优化配置
|
||||||
GENERATE_SOURCEMAP=false
|
GENERATE_SOURCEMAP=false
|
||||||
@@ -18,10 +18,3 @@ REACT_APP_ENABLE_MOCK=false
|
|||||||
|
|
||||||
# 开发环境标识
|
# 开发环境标识
|
||||||
REACT_APP_ENV=development
|
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,20 +1,5 @@
|
|||||||
# ========================================
|
|
||||||
# Mock 测试环境配置
|
# Mock 测试环境配置
|
||||||
# ========================================
|
|
||||||
# 使用方式: npm run start: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 构建优化配置
|
# React 构建优化配置
|
||||||
GENERATE_SOURCEMAP=false
|
GENERATE_SOURCEMAP=false
|
||||||
@@ -25,24 +10,11 @@ IMAGE_INLINE_SIZE_LIMIT=10000
|
|||||||
NODE_OPTIONS=--max_old_space_size=4096
|
NODE_OPTIONS=--max_old_space_size=4096
|
||||||
|
|
||||||
# API 配置
|
# API 配置
|
||||||
# Mock 模式下使用空字符串,让请求使用相对路径
|
# Mock 模式下不需要真实的后端地址
|
||||||
# MSW 会在浏览器层拦截这些请求,不需要真实的后端地址
|
REACT_APP_API_URL=http://localhost:3000
|
||||||
REACT_APP_API_URL=
|
|
||||||
|
|
||||||
# 启用 Mock 数据(核心配置)
|
# 启用 Mock 数据(核心配置)
|
||||||
# 此配置会触发 src/index.js 中的 MSW 初始化
|
|
||||||
REACT_APP_ENABLE_MOCK=true
|
REACT_APP_ENABLE_MOCK=true
|
||||||
|
|
||||||
# Mock 环境标识
|
# Mock 环境标识
|
||||||
REACT_APP_ENV=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
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# ========================================
|
|
||||||
# 生产环境配置
|
|
||||||
# ========================================
|
|
||||||
# 使用方式: npm run build
|
|
||||||
#
|
|
||||||
# 工作原理:
|
|
||||||
# 1. 此文件专门用于生产环境构建
|
|
||||||
# 2. 构建时会将环境变量嵌入到打包文件中
|
|
||||||
# 3. 确保 PostHog 等服务使用正确的生产配置
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
# 环境标识
|
|
||||||
REACT_APP_ENV=production
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# Mock 配置(生产环境禁用 Mock)
|
|
||||||
REACT_APP_ENABLE_MOCK=false
|
|
||||||
|
|
||||||
# 后端 API 地址(生产环境)
|
|
||||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
|
||||||
|
|
||||||
# PostHog 分析配置(生产环境)
|
|
||||||
# PostHog API Key(从 PostHog 项目设置中获取)
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
|
||||||
# PostHog API Host(使用 PostHog Cloud)
|
|
||||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
|
||||||
# 启用会话录制(Session Recording)用于回放用户操作、排查问题
|
|
||||||
REACT_APP_ENABLE_SESSION_RECORDING=true
|
|
||||||
|
|
||||||
# React 构建优化配置
|
|
||||||
# 禁用 source map 生成(生产环境不需要,提升打包速度和安全性)
|
|
||||||
GENERATE_SOURCEMAP=false
|
|
||||||
# 跳过预检查(加快启动速度)
|
|
||||||
SKIP_PREFLIGHT_CHECK=true
|
|
||||||
# 禁用 ESLint 检查(生产构建时不需要)
|
|
||||||
DISABLE_ESLINT_PLUGIN=true
|
|
||||||
# TypeScript 编译错误时继续
|
|
||||||
TSC_COMPILE_ON_ERROR=true
|
|
||||||
# 图片内联大小限制
|
|
||||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
|
||||||
# Node.js 内存限制(适用于大型项目)
|
|
||||||
NODE_OPTIONS=--max_old_space_size=4096
|
|
||||||
42
.env.test
42
.env.test
@@ -1,42 +0,0 @@
|
|||||||
# ========================================
|
|
||||||
# 本地测试环境(前后端都在本地)
|
|
||||||
# ========================================
|
|
||||||
# 使用方式: 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
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -35,19 +35,8 @@ pnpm-debug.log*
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Claude Code 配置
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
Thumbs.db
|
Thumbs.dbsrc/assets/img/original-backup/
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
|
||||||
!README.md
|
|
||||||
!CLAUDE.md
|
|
||||||
!docs/**/*.md
|
|
||||||
|
|
||||||
src/assets/img/original-backup/
|
|
||||||
|
|||||||
197
README.md
197
README.md
@@ -1,198 +1,3 @@
|
|||||||
# vf_react
|
# vf_react
|
||||||
|
|
||||||
前端
|
前端
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 重构记录
|
|
||||||
|
|
||||||
### 2025-10-30: EventList.js 组件化重构
|
|
||||||
|
|
||||||
#### 🎯 重构目标
|
|
||||||
将 Community 社区页面的 `EventList.js` 组件(1095行)拆分为多个可复用的子组件,提高代码可维护性和复用性。
|
|
||||||
|
|
||||||
#### 📊 重构成果
|
|
||||||
- **重构前**: 1095 行
|
|
||||||
- **重构后**: 497 行
|
|
||||||
- **减少**: 598 行 (-54.6%)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📁 新增目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/views/Community/components/EventCard/
|
|
||||||
├── index.js (60行) - EventCard 统一入口,智能路由紧凑/详细模式
|
|
||||||
│
|
|
||||||
├── ──────────────────────────────────────────────────────────
|
|
||||||
│ 原子组件 (Atoms) - 7个基础UI组件
|
|
||||||
├── ──────────────────────────────────────────────────────────
|
|
||||||
│
|
|
||||||
├── EventTimeline.js (60行) - 时间轴显示组件
|
|
||||||
│ └── Props: createdAt, timelineStyle, borderColor, minHeight
|
|
||||||
│
|
|
||||||
├── EventImportanceBadge.js (100行) - 重要性等级标签 (S/A/B/C/D)
|
|
||||||
│ └── Props: importance, showTooltip, showIcon, size
|
|
||||||
│
|
|
||||||
├── EventStats.js (60行) - 统计信息 (浏览/帖子/关注)
|
|
||||||
│ └── Props: viewCount, postCount, followerCount, size, spacing
|
|
||||||
│
|
|
||||||
├── EventFollowButton.js (40行) - 关注按钮
|
|
||||||
│ └── Props: isFollowing, followerCount, onToggle, size, showCount
|
|
||||||
│
|
|
||||||
├── EventPriceDisplay.js (130行) - 价格变动显示 (平均/最大/周)
|
|
||||||
│ └── Props: avgChange, maxChange, weekChange, compact, inline
|
|
||||||
│
|
|
||||||
├── EventDescription.js (60行) - 描述文本 (支持展开/收起)
|
|
||||||
│ └── Props: description, textColor, minLength, noOfLines
|
|
||||||
│
|
|
||||||
├── EventHeader.js (100行) - 事件标题头部
|
|
||||||
│ └── Props: title, importance, onTitleClick, linkColor, compact
|
|
||||||
│
|
|
||||||
├── ──────────────────────────────────────────────────────────
|
|
||||||
│ 组合组件 (Molecules) - 2个卡片组件
|
|
||||||
├── ──────────────────────────────────────────────────────────
|
|
||||||
│
|
|
||||||
├── CompactEventCard.js (160行) - 紧凑模式事件卡片
|
|
||||||
│ ├── 使用: EventTimeline, EventHeader, EventStats, EventFollowButton
|
|
||||||
│ └── Props: event, index, isFollowing, followerCount, callbacks...
|
|
||||||
│
|
|
||||||
└── DetailedEventCard.js (170行) - 详细模式事件卡片
|
|
||||||
├── 使用: EventTimeline, EventHeader, EventStats, EventFollowButton,
|
|
||||||
│ EventPriceDisplay, EventDescription
|
|
||||||
└── Props: event, isFollowing, followerCount, callbacks...
|
|
||||||
```
|
|
||||||
|
|
||||||
**总计**: 10个文件,940行代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔧 重构的文件
|
|
||||||
|
|
||||||
#### `src/views/Community/components/EventList.js`
|
|
||||||
|
|
||||||
**移除的内容**:
|
|
||||||
- ❌ `renderPriceChange` 函数 (~60行)
|
|
||||||
- ❌ `renderCompactEvent` 函数 (~200行)
|
|
||||||
- ❌ `renderDetailedEvent` 函数 (~300行)
|
|
||||||
- ❌ `expandedDescriptions` state(展开状态管理移至子组件)
|
|
||||||
- ❌ 冗余的 Chakra UI 导入
|
|
||||||
|
|
||||||
**保留的功能**:
|
|
||||||
- ✅ WebSocket 实时推送
|
|
||||||
- ✅ 浏览器原生通知
|
|
||||||
- ✅ 关注状态管理 (followingMap, followCountMap)
|
|
||||||
- ✅ 分页控制
|
|
||||||
- ✅ 视图模式切换(紧凑/详细)
|
|
||||||
- ✅ 推送权限管理
|
|
||||||
|
|
||||||
**新增引入**:
|
|
||||||
```javascript
|
|
||||||
import EventCard from './EventCard';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🏗️ 架构改进
|
|
||||||
|
|
||||||
#### 重构前(单体架构)
|
|
||||||
```
|
|
||||||
EventList.js (1095行)
|
|
||||||
├── 业务逻辑 (WebSocket, 关注, 通知)
|
|
||||||
├── renderCompactEvent (200行)
|
|
||||||
│ └── 所有UI代码内联
|
|
||||||
├── renderDetailedEvent (300行)
|
|
||||||
│ └── 所有UI代码内联
|
|
||||||
└── renderPriceChange (60行)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 重构后(组件化架构)
|
|
||||||
```
|
|
||||||
EventList.js (497行) - 容器组件
|
|
||||||
├── 业务逻辑 (WebSocket, 关注, 通知)
|
|
||||||
└── 渲染逻辑
|
|
||||||
└── EventCard (智能路由)
|
|
||||||
├── CompactEventCard (紧凑模式)
|
|
||||||
│ ├── EventTimeline
|
|
||||||
│ ├── EventHeader (compact)
|
|
||||||
│ ├── EventStats
|
|
||||||
│ └── EventFollowButton
|
|
||||||
└── DetailedEventCard (详细模式)
|
|
||||||
├── EventTimeline
|
|
||||||
├── EventHeader (detailed)
|
|
||||||
├── EventStats
|
|
||||||
├── EventFollowButton
|
|
||||||
├── EventPriceDisplay
|
|
||||||
└── EventDescription
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✨ 优势
|
|
||||||
|
|
||||||
1. **可维护性** ⬆️
|
|
||||||
- 每个组件职责单一(单一职责原则)
|
|
||||||
- 代码行数减少 54.6%
|
|
||||||
- 组件边界清晰,易于理解
|
|
||||||
|
|
||||||
2. **可复用性** ⬆️
|
|
||||||
- 原子组件可在其他页面复用
|
|
||||||
- 例如:EventImportanceBadge 可用于任何需要显示事件等级的地方
|
|
||||||
|
|
||||||
3. **可测试性** ⬆️
|
|
||||||
- 小组件更容易编写单元测试
|
|
||||||
- 可独立测试每个组件的渲染和交互
|
|
||||||
|
|
||||||
4. **性能优化** ⬆️
|
|
||||||
- React 可以更精确地追踪变化
|
|
||||||
- 减少不必要的重渲染
|
|
||||||
- 每个子组件可独立优化(useMemo, React.memo)
|
|
||||||
|
|
||||||
5. **开发效率** ⬆️
|
|
||||||
- 新增功能时只需修改对应的子组件
|
|
||||||
- 代码审查更高效
|
|
||||||
- 降低了代码冲突的概率
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📦 依赖工具函数
|
|
||||||
|
|
||||||
本次重构使用了之前提取的工具函数:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/utils/priceFormatters.js (105行)
|
|
||||||
├── getPriceChangeColor(value) - 获取价格变化文字颜色
|
|
||||||
├── getPriceChangeBg(value) - 获取价格变化背景颜色
|
|
||||||
├── getPriceChangeBorderColor(value) - 获取价格变化边框颜色
|
|
||||||
├── formatPriceChange(value) - 格式化价格为字符串
|
|
||||||
└── PriceArrow({ value }) - 价格涨跌箭头组件
|
|
||||||
|
|
||||||
src/constants/animations.js (72行)
|
|
||||||
├── pulseAnimation - 脉冲动画(S/A级标签)
|
|
||||||
├── fadeIn - 渐入动画
|
|
||||||
├── slideInUp - 从下往上滑入
|
|
||||||
├── scaleIn - 缩放进入
|
|
||||||
└── spin - 旋转动画(Loading)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🚀 下一步优化计划
|
|
||||||
|
|
||||||
Phase 1 已完成,后续可继续优化:
|
|
||||||
|
|
||||||
- **Phase 2**: 拆分 StockDetailPanel.js (1067行 → ~250行)
|
|
||||||
- **Phase 3**: 拆分 InvestmentCalendar.js (827行 → ~200行)
|
|
||||||
- **Phase 4**: 拆分 MidjourneyHeroSection.js (813行 → ~200行)
|
|
||||||
- **Phase 5**: 拆分 UnifiedSearchBox.js (679行 → ~180行)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔗 相关提交
|
|
||||||
|
|
||||||
- `feat: 拆分 EventList.js/提取价格相关工具函数到 utils/priceFormatters.js`
|
|
||||||
- `feat(EventList): 创建事件卡片原子组件`
|
|
||||||
- `feat(EventList): 创建事件卡片组合组件`
|
|
||||||
- `refactor(EventList): 使用组件化架构替换内联渲染函数`
|
|
||||||
|
|
||||||
---
|
|
||||||
Binary file not shown.
Binary file not shown.
45
app/__init__.py
Normal file
45
app/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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)
|
||||||
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/models.cpython-311.pyc
Normal file
BIN
app/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
30
app/extensions.py
Normal file
30
app/extensions.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 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
Normal file
504
app/models.py
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
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
app/routes/__init__.py
Normal file
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 路由包初始化文件
|
||||||
BIN
app/routes/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/calendar.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/calendar.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/events.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/events.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/industries.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/industries.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/limitanalyse.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/limitanalyse.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/stocks.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/stocks.cpython-311.pyc
Normal file
Binary file not shown.
121
app/routes/calendar.py
Normal file
121
app/routes/calendar.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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 []
|
||||||
385
app/routes/events.py
Normal file
385
app/routes/events.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
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
|
||||||
511
app/routes/industries.py
Normal file
511
app/routes/industries.py
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
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
|
||||||
469
app/routes/limitanalyse.py
Normal file
469
app/routes/limitanalyse.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
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
|
||||||
241
app/routes/stocks.py
Normal file
241
app/routes/stocks.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
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
|
||||||
107
concept_api.py
107
concept_api.py
@@ -22,15 +22,15 @@ openai_client = None
|
|||||||
mysql_pool = None
|
mysql_pool = None
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
ES_HOST = 'http://127.0.0.1:9200'
|
ES_HOST = 'http://192.168.1.58:9200'
|
||||||
OPENAI_BASE_URL = "http://127.0.0.1:8000/v1"
|
OPENAI_BASE_URL = "http://192.168.1.58:8000/v1"
|
||||||
OPENAI_API_KEY = "dummy"
|
OPENAI_API_KEY = "dummy"
|
||||||
EMBEDDING_MODEL = "qwen3-embedding-8b"
|
EMBEDDING_MODEL = "qwen3-embedding-8b"
|
||||||
INDEX_NAME = 'concept_library'
|
INDEX_NAME = 'concept_library'
|
||||||
|
|
||||||
# MySQL配置
|
# MySQL配置
|
||||||
MYSQL_CONFIG = {
|
MYSQL_CONFIG = {
|
||||||
'host': '192.168.1.8',
|
'host': '192.168.1.14',
|
||||||
'user': 'root',
|
'user': 'root',
|
||||||
'password': 'Zzl5588161!',
|
'password': 'Zzl5588161!',
|
||||||
'db': 'stock',
|
'db': 'stock',
|
||||||
@@ -490,7 +490,7 @@ def build_hybrid_knn_query(
|
|||||||
"field": "description_embedding",
|
"field": "description_embedding",
|
||||||
"query_vector": embedding,
|
"query_vector": embedding,
|
||||||
"k": k,
|
"k": k,
|
||||||
"num_candidates": max(k + 50, min(k * 2, 10000)), # 确保 num_candidates > k,最大 10000
|
"num_candidates": min(k * 2, 500),
|
||||||
"boost": semantic_weight
|
"boost": semantic_weight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,7 +591,7 @@ async def search_concepts(request: SearchRequest):
|
|||||||
"field": "description_embedding",
|
"field": "description_embedding",
|
||||||
"query_vector": embedding,
|
"query_vector": embedding,
|
||||||
"k": effective_search_size, # 使用有效搜索大小
|
"k": effective_search_size, # 使用有效搜索大小
|
||||||
"num_candidates": max(effective_search_size + 50, min(effective_search_size * 2, 10000)) # 确保 num_candidates > k
|
"num_candidates": min(effective_search_size * 2, 1000)
|
||||||
},
|
},
|
||||||
"size": effective_search_size
|
"size": effective_search_size
|
||||||
}
|
}
|
||||||
@@ -1045,16 +1045,7 @@ async def get_concept_price_timeseries(
|
|||||||
):
|
):
|
||||||
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
|
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
|
||||||
if not mysql_pool:
|
if not mysql_pool:
|
||||||
logger.warning(f"[PriceTimeseries] MySQL 连接不可用,返回空时间序列数据")
|
raise HTTPException(status_code=503, detail="数据库连接不可用")
|
||||||
# 返回空时间序列而不是 503 错误
|
|
||||||
return PriceTimeSeriesResponse(
|
|
||||||
concept_id=concept_id,
|
|
||||||
concept_name=concept_id, # 无法查询名称,使用 ID
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
data_points=0,
|
|
||||||
timeseries=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
if start_date > end_date:
|
if start_date > end_date:
|
||||||
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
|
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
|
||||||
@@ -1159,93 +1150,11 @@ async def get_concept_statistics(
|
|||||||
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
|
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
|
||||||
):
|
):
|
||||||
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
|
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
# 如果 MySQL 不可用,直接返回示例数据(而不是返回 503)
|
|
||||||
if not mysql_pool:
|
if not mysql_pool:
|
||||||
logger.warning("[Statistics] MySQL 连接不可用,使用示例数据")
|
raise HTTPException(status_code=503, detail="数据库连接不可用")
|
||||||
|
|
||||||
# 计算日期范围
|
|
||||||
if days is not None and (start_date is not None or end_date is not None):
|
|
||||||
pass # 参数冲突,但仍使用 days
|
|
||||||
|
|
||||||
if start_date is not None and end_date is not None:
|
|
||||||
pass # 使用提供的日期
|
|
||||||
elif days is not None:
|
|
||||||
end_date = datetime.now().date()
|
|
||||||
start_date = end_date - timedelta(days=days)
|
|
||||||
elif start_date is not None:
|
|
||||||
end_date = datetime.now().date()
|
|
||||||
elif end_date is not None:
|
|
||||||
start_date = end_date - timedelta(days=7)
|
|
||||||
else:
|
|
||||||
end_date = datetime.now().date()
|
|
||||||
start_date = end_date - timedelta(days=7)
|
|
||||||
|
|
||||||
# 返回示例数据(与 except 块中相同)
|
|
||||||
fallback_statistics = ConceptStatistics(
|
|
||||||
hot_concepts=[
|
|
||||||
ConceptStatItem(name="小米大模型", change_pct=12.45, stock_count=24, news_count=18),
|
|
||||||
ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12),
|
|
||||||
ConceptStatItem(name="新能源汽车", change_pct=6.54, stock_count=38, news_count=8),
|
|
||||||
ConceptStatItem(name="芯片概念", change_pct=5.43, stock_count=52, news_count=15),
|
|
||||||
ConceptStatItem(name="生物医药", change_pct=4.21, stock_count=28, news_count=6),
|
|
||||||
],
|
|
||||||
cold_concepts=[
|
|
||||||
ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5),
|
|
||||||
ConceptStatItem(name="煤炭开采", change_pct=-4.32, stock_count=25, news_count=3),
|
|
||||||
ConceptStatItem(name="钢铁冶炼", change_pct=-3.21, stock_count=28, news_count=4),
|
|
||||||
ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2),
|
|
||||||
ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2),
|
|
||||||
],
|
|
||||||
active_concepts=[
|
|
||||||
ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60),
|
|
||||||
ConceptStatItem(name="芯片概念", news_count=42, report_count=12, total_mentions=54),
|
|
||||||
ConceptStatItem(name="新能源汽车", news_count=38, report_count=8, total_mentions=46),
|
|
||||||
ConceptStatItem(name="生物医药", news_count=28, report_count=6, total_mentions=34),
|
|
||||||
ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30),
|
|
||||||
],
|
|
||||||
volatile_concepts=[
|
|
||||||
ConceptStatItem(name="区块链", volatility=25.6, avg_change=2.1, max_change=15.2),
|
|
||||||
ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9),
|
|
||||||
ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1),
|
|
||||||
ConceptStatItem(name="游戏概念", volatility=19.7, avg_change=3.2, max_change=12.8),
|
|
||||||
ConceptStatItem(name="在线教育", volatility=18.3, avg_change=-1.1, max_change=8.1),
|
|
||||||
],
|
|
||||||
momentum_concepts=[
|
|
||||||
ConceptStatItem(name="数字经济", consecutive_days=6, total_change=19.2, avg_daily=3.2),
|
|
||||||
ConceptStatItem(name="云计算", consecutive_days=5, total_change=16.8, avg_daily=3.36),
|
|
||||||
ConceptStatItem(name="物联网", consecutive_days=4, total_change=13.1, avg_daily=3.28),
|
|
||||||
ConceptStatItem(name="大数据", consecutive_days=4, total_change=12.4, avg_daily=3.1),
|
|
||||||
ConceptStatItem(name="工业互联网", consecutive_days=3, total_change=9.6, avg_daily=3.2),
|
|
||||||
],
|
|
||||||
summary={
|
|
||||||
'total_concepts': 500,
|
|
||||||
'positive_count': 320,
|
|
||||||
'negative_count': 180,
|
|
||||||
'avg_change': 1.8,
|
|
||||||
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
'date_range': f"{start_date} 至 {end_date}",
|
|
||||||
'days': (end_date - start_date).days + 1,
|
|
||||||
'start_date': str(start_date),
|
|
||||||
'end_date': str(end_date)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return ConceptStatisticsResponse(
|
|
||||||
success=True,
|
|
||||||
data=fallback_statistics,
|
|
||||||
params={
|
|
||||||
'days': (end_date - start_date).days + 1,
|
|
||||||
'min_stock_count': min_stock_count,
|
|
||||||
'start_date': str(start_date),
|
|
||||||
'end_date': str(end_date)
|
|
||||||
},
|
|
||||||
note="MySQL 连接不可用,使用示例数据"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# 参数验证和日期范围计算
|
# 参数验证和日期范围计算
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ const path = require('path');
|
|||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const { BundleAnalyzerPlugin } = process.env.ANALYZE ? require('webpack-bundle-analyzer') : { BundleAnalyzerPlugin: null };
|
const { BundleAnalyzerPlugin } = process.env.ANALYZE ? require('webpack-bundle-analyzer') : { BundleAnalyzerPlugin: null };
|
||||||
|
|
||||||
// 检查是否为 Mock 模式(与 src/utils/apiConfig.js 保持一致)
|
|
||||||
const isMockMode = () => process.env.REACT_APP_ENABLE_MOCK === 'true';
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
webpack: {
|
webpack: {
|
||||||
configure: (webpackConfig, { env, paths }) => {
|
configure: (webpackConfig, { env, paths }) => {
|
||||||
@@ -30,7 +27,7 @@ module.exports = {
|
|||||||
chunks: 'all',
|
chunks: 'all',
|
||||||
maxInitialRequests: 30,
|
maxInitialRequests: 30,
|
||||||
minSize: 20000,
|
minSize: 20000,
|
||||||
maxSize: 512000, // 限制单个 chunk 最大大小(512KB,与 performance.maxAssetSize 一致)
|
maxSize: 244000, // 限制单个 chunk 最大大小(约 244KB)
|
||||||
cacheGroups: {
|
cacheGroups: {
|
||||||
// React 核心库单独分离
|
// React 核心库单独分离
|
||||||
react: {
|
react: {
|
||||||
@@ -50,7 +47,7 @@ module.exports = {
|
|||||||
chakraUI: {
|
chakraUI: {
|
||||||
test: /[\\/]node_modules[\\/](@chakra-ui|@emotion)[\\/]/,
|
test: /[\\/]node_modules[\\/](@chakra-ui|@emotion)[\\/]/,
|
||||||
name: 'chakra-ui',
|
name: 'chakra-ui',
|
||||||
priority: 23, // 从 22 改为 23,避免与 antd 优先级冲突
|
priority: 22,
|
||||||
reuseExistingChunk: true,
|
reuseExistingChunk: true,
|
||||||
},
|
},
|
||||||
// Ant Design
|
// Ant Design
|
||||||
@@ -110,28 +107,11 @@ module.exports = {
|
|||||||
...webpackConfig.resolve,
|
...webpackConfig.resolve,
|
||||||
alias: {
|
alias: {
|
||||||
...webpackConfig.resolve.alias,
|
...webpackConfig.resolve.alias,
|
||||||
// 根目录别名
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|
||||||
// 功能模块别名(按字母顺序)
|
|
||||||
'@assets': path.resolve(__dirname, 'src/assets'),
|
|
||||||
'@components': path.resolve(__dirname, 'src/components'),
|
'@components': path.resolve(__dirname, 'src/components'),
|
||||||
'@constants': path.resolve(__dirname, 'src/constants'),
|
|
||||||
'@contexts': path.resolve(__dirname, 'src/contexts'),
|
|
||||||
'@data': path.resolve(__dirname, 'src/data'),
|
|
||||||
'@hooks': path.resolve(__dirname, 'src/hooks'),
|
|
||||||
'@layouts': path.resolve(__dirname, 'src/layouts'),
|
|
||||||
'@lib': path.resolve(__dirname, 'src/lib'),
|
|
||||||
'@mocks': path.resolve(__dirname, 'src/mocks'),
|
|
||||||
'@providers': path.resolve(__dirname, 'src/providers'),
|
|
||||||
'@routes': path.resolve(__dirname, 'src/routes'),
|
|
||||||
'@services': path.resolve(__dirname, 'src/services'),
|
|
||||||
'@store': path.resolve(__dirname, 'src/store'),
|
|
||||||
'@styles': path.resolve(__dirname, 'src/styles'),
|
|
||||||
'@theme': path.resolve(__dirname, 'src/theme'),
|
|
||||||
'@utils': path.resolve(__dirname, 'src/utils'),
|
|
||||||
'@variables': path.resolve(__dirname, 'src/variables'),
|
|
||||||
'@views': path.resolve(__dirname, 'src/views'),
|
'@views': path.resolve(__dirname, 'src/views'),
|
||||||
|
'@assets': path.resolve(__dirname, 'src/assets'),
|
||||||
|
'@contexts': path.resolve(__dirname, 'src/contexts'),
|
||||||
},
|
},
|
||||||
// 减少文件扩展名搜索
|
// 减少文件扩展名搜索
|
||||||
extensions: ['.js', '.jsx', '.json'],
|
extensions: ['.js', '.jsx', '.json'],
|
||||||
@@ -239,38 +219,14 @@ module.exports = {
|
|||||||
devMiddleware: {
|
devMiddleware: {
|
||||||
writeToDisk: false,
|
writeToDisk: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 调试日志
|
|
||||||
onListening: (devServer) => {
|
|
||||||
console.log(`[CRACO] Mock Mode: ${isMockMode() ? 'Enabled ✅' : 'Disabled ❌'}`);
|
|
||||||
console.log(`[CRACO] Proxy: ${isMockMode() ? 'Disabled (MSW intercepts)' : 'Enabled (forwarding to backend)'}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 代理配置:将 /api 请求代理到后端服务器
|
// 代理配置:将 /api 请求代理到后端服务器
|
||||||
// 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求
|
proxy: {
|
||||||
...(isMockMode() ? {} : {
|
'/api': {
|
||||||
proxy: {
|
target: 'http://49.232.185.254:5001',
|
||||||
'/api': {
|
changeOrigin: true,
|
||||||
target: 'http://49.232.185.254:5001',
|
secure: false,
|
||||||
changeOrigin: true,
|
logLevel: 'debug',
|
||||||
secure: false,
|
|
||||||
logLevel: 'debug',
|
|
||||||
},
|
|
||||||
'/concept-api': {
|
|
||||||
target: 'http://49.232.185.254:6801',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
logLevel: 'debug',
|
|
||||||
pathRewrite: { '^/concept-api': '' },
|
|
||||||
},
|
|
||||||
'/bytedesk-api': {
|
|
||||||
target: 'http://43.143.189.195',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
logLevel: 'debug',
|
|
||||||
pathRewrite: { '^/bytedesk-api': '' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
-- 数据库迁移:添加 pricing_options 字段,支持灵活的计费周期
|
|
||||||
-- 执行时间:2025-01-XX
|
|
||||||
-- 说明:支持用户选择"包N个月"或"包N年"的套餐
|
|
||||||
|
|
||||||
-- 1. 添加 pricing_options 字段
|
|
||||||
ALTER TABLE subscription_plans
|
|
||||||
ADD COLUMN pricing_options TEXT NULL COMMENT 'JSON格式的计费周期选项';
|
|
||||||
|
|
||||||
-- 2. 为Pro套餐配置多种计费周期(基于现有价格:198元/月,2000元/年)
|
|
||||||
UPDATE subscription_plans
|
|
||||||
SET pricing_options = '[
|
|
||||||
{"months": 1, "price": 198, "label": "月付", "cycle_key": "monthly"},
|
|
||||||
{"months": 3, "price": 534, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
|
|
||||||
{"months": 6, "price": 950, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
|
|
||||||
{"months": 12, "price": 2000, "label": "1年", "cycle_key": "yearly", "discount_percent": 16},
|
|
||||||
{"months": 24, "price": 3600, "label": "2年", "cycle_key": "2years", "discount_percent": 24},
|
|
||||||
{"months": 36, "price": 5040, "label": "3年", "cycle_key": "3years", "discount_percent": 29}
|
|
||||||
]'
|
|
||||||
WHERE name = 'pro';
|
|
||||||
|
|
||||||
-- 3. 为Max套餐配置多种计费周期(基于现有价格:998元/月,10000元/年)
|
|
||||||
UPDATE subscription_plans
|
|
||||||
SET pricing_options = '[
|
|
||||||
{"months": 1, "price": 998, "label": "月付", "cycle_key": "monthly"},
|
|
||||||
{"months": 3, "price": 2695, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
|
|
||||||
{"months": 6, "price": 4790, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
|
|
||||||
{"months": 12, "price": 10000, "label": "1年", "cycle_key": "yearly", "discount_percent": 17},
|
|
||||||
{"months": 24, "price": 18000, "label": "2年", "cycle_key": "2years", "discount_percent": 25},
|
|
||||||
{"months": 36, "price": 25200, "label": "3年", "cycle_key": "3years", "discount_percent": 30}
|
|
||||||
]'
|
|
||||||
WHERE name = 'max';
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 价格计算说明
|
|
||||||
-- ========================================
|
|
||||||
-- Pro版(198元/月,2000元/年):
|
|
||||||
-- - 月付:198元
|
|
||||||
-- - 3个月:198×3×0.9 = 534元(打9折,省10%)
|
|
||||||
-- - 半年:198×6×0.8 = 950元(打8折,省20%)
|
|
||||||
-- - 1年:2000元(已有年付价格,约省16%)
|
|
||||||
-- - 2年:198×24×0.76 = 3600元(省24%)
|
|
||||||
-- - 3年:198×36×0.7 = 5040元(省30%)
|
|
||||||
|
|
||||||
-- Max版(998元/月,10000元/年):
|
|
||||||
-- - 月付:998元
|
|
||||||
-- - 3个月:998×3×0.9 = 2695元(打9折,省10%)
|
|
||||||
-- - 半年:998×6×0.8 = 4790元(打8折,省20%)
|
|
||||||
-- - 1年:10000元(已有年付价格,约省17%)
|
|
||||||
-- - 2年:998×24×0.75 = 18000元(省25%)
|
|
||||||
-- - 3年:998×36×0.7 = 25200元(省30%)
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 注意事项
|
|
||||||
-- ========================================
|
|
||||||
-- 1. 上述价格仅为示例,请根据实际营销策略调整
|
|
||||||
-- 2. 折扣力度建议:时间越长,优惠越大
|
|
||||||
-- 3. 如果不想提供某个周期,直接从数组中删除即可
|
|
||||||
-- 4. 前端会自动渲染所有可用的计费周期选项
|
|
||||||
-- 5. 用户可以通过优惠码获得额外折扣
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
# AI Agent 系统部署指南
|
|
||||||
|
|
||||||
## 🎯 系统架构
|
|
||||||
|
|
||||||
### 三阶段流程
|
|
||||||
|
|
||||||
```
|
|
||||||
用户输入
|
|
||||||
↓
|
|
||||||
[阶段1: 计划制定 Planning]
|
|
||||||
- LLM 分析用户需求
|
|
||||||
- 确定需要哪些工具
|
|
||||||
- 制定执行计划(steps)
|
|
||||||
↓
|
|
||||||
[阶段2: 工具执行 Execution]
|
|
||||||
- 按计划顺序调用 MCP 工具
|
|
||||||
- 收集数据
|
|
||||||
- 异常处理和重试
|
|
||||||
↓
|
|
||||||
[阶段3: 结果总结 Summarization]
|
|
||||||
- LLM 综合分析所有数据
|
|
||||||
- 生成自然语言报告
|
|
||||||
↓
|
|
||||||
输出给用户
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 文件清单
|
|
||||||
|
|
||||||
### 后端文件
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp_server.py # MCP 工具服务器(已有)
|
|
||||||
mcp_agent_system.py # Agent 系统核心逻辑(新增)
|
|
||||||
mcp_config.py # 配置文件(已有)
|
|
||||||
mcp_database.py # 数据库操作(已有)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端文件
|
|
||||||
|
|
||||||
```
|
|
||||||
src/components/ChatBot/
|
|
||||||
├── ChatInterfaceV2.js # 新版聊天界面(漂亮)
|
|
||||||
├── PlanCard.js # 执行计划卡片
|
|
||||||
├── StepResultCard.js # 步骤结果卡片(可折叠)
|
|
||||||
├── ChatInterface.js # 旧版聊天界面(保留)
|
|
||||||
├── MessageBubble.js # 消息气泡组件(保留)
|
|
||||||
└── index.js # 统一导出
|
|
||||||
|
|
||||||
src/views/AgentChat/
|
|
||||||
└── index.js # Agent 聊天页面
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 部署步骤
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入项目目录
|
|
||||||
cd /home/ubuntu/vf_react
|
|
||||||
|
|
||||||
# 安装 OpenAI SDK(支持多个LLM提供商)
|
|
||||||
pip install openai
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 获取 LLM API Key
|
|
||||||
|
|
||||||
**推荐:通义千问(便宜且中文能力强)**
|
|
||||||
|
|
||||||
1. 访问 https://dashscope.console.aliyun.com/
|
|
||||||
2. 注册/登录阿里云账号
|
|
||||||
3. 开通 DashScope 服务
|
|
||||||
4. 创建 API Key
|
|
||||||
5. 复制 API Key(格式:`sk-xxx...`)
|
|
||||||
|
|
||||||
**其他选择**:
|
|
||||||
- DeepSeek: https://platform.deepseek.com/ (最便宜)
|
|
||||||
- OpenAI: https://platform.openai.com/ (需要翻墙)
|
|
||||||
|
|
||||||
### 3. 配置环境变量
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 编辑环境变量
|
|
||||||
sudo nano /etc/environment
|
|
||||||
|
|
||||||
# 添加以下内容(选择一个)
|
|
||||||
# 方式1: 通义千问(推荐)
|
|
||||||
DASHSCOPE_API_KEY="sk-your-key-here"
|
|
||||||
|
|
||||||
# 方式2: DeepSeek(更便宜)
|
|
||||||
DEEPSEEK_API_KEY="sk-your-key-here"
|
|
||||||
|
|
||||||
# 方式3: OpenAI
|
|
||||||
OPENAI_API_KEY="sk-your-key-here"
|
|
||||||
|
|
||||||
# 保存并退出,然后重新加载
|
|
||||||
source /etc/environment
|
|
||||||
|
|
||||||
# 验证环境变量
|
|
||||||
echo $DASHSCOPE_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 修改 mcp_server.py
|
|
||||||
|
|
||||||
在文件末尾(`if __name__ == "__main__":` 之前)添加:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ==================== Agent 端点 ====================
|
|
||||||
|
|
||||||
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
|
|
||||||
|
|
||||||
# 创建 Agent 实例
|
|
||||||
agent = MCPAgent(provider="qwen") # 或 "deepseek", "openai"
|
|
||||||
|
|
||||||
@app.post("/agent/chat", response_model=AgentResponse)
|
|
||||||
async def agent_chat(request: ChatRequest):
|
|
||||||
"""智能代理对话端点"""
|
|
||||||
logger.info(f"Agent chat: {request.message}")
|
|
||||||
|
|
||||||
# 获取工具列表和处理器
|
|
||||||
tools = [tool.dict() for tool in TOOLS]
|
|
||||||
|
|
||||||
# 处理查询
|
|
||||||
response = await agent.process_query(
|
|
||||||
user_query=request.message,
|
|
||||||
tools=tools,
|
|
||||||
tool_handlers=TOOL_HANDLERS,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 重启 MCP 服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 如果使用 systemd
|
|
||||||
sudo systemctl restart mcp-server
|
|
||||||
|
|
||||||
# 或者手动重启
|
|
||||||
pkill -f mcp_server
|
|
||||||
nohup uvicorn mcp_server:app --host 0.0.0.0 --port 8900 > mcp_server.log 2>&1 &
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
tail -f mcp_server.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 测试 Agent API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测试 Agent 端点
|
|
||||||
curl -X POST http://localhost:8900/agent/chat \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"message": "全面分析贵州茅台这只股票",
|
|
||||||
"conversation_history": []
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 应该返回类似这样的JSON:
|
|
||||||
# {
|
|
||||||
# "success": true,
|
|
||||||
# "message": "根据分析,贵州茅台...",
|
|
||||||
# "plan": {
|
|
||||||
# "goal": "全面分析贵州茅台",
|
|
||||||
# "steps": [...]
|
|
||||||
# },
|
|
||||||
# "step_results": [...],
|
|
||||||
# "metadata": {...}
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. 部署前端
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 在本地构建
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 上传到服务器
|
|
||||||
scp -r build/* ubuntu@your-server:/var/www/valuefrontier.cn/
|
|
||||||
|
|
||||||
# 或者在服务器上构建
|
|
||||||
cd /home/ubuntu/vf_react
|
|
||||||
npm run build
|
|
||||||
sudo cp -r build/* /var/www/valuefrontier.cn/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. 重启 Nginx
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ 验证部署
|
|
||||||
|
|
||||||
### 1. 测试后端 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测试工具列表
|
|
||||||
curl https://valuefrontier.cn/mcp/tools
|
|
||||||
|
|
||||||
# 测试 Agent
|
|
||||||
curl -X POST https://valuefrontier.cn/mcp/agent/chat \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"message": "今日涨停股票有哪些",
|
|
||||||
"conversation_history": []
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 测试前端
|
|
||||||
|
|
||||||
1. 访问 https://valuefrontier.cn/agent-chat
|
|
||||||
2. 输入问题:"全面分析贵州茅台这只股票"
|
|
||||||
3. 观察:
|
|
||||||
- ✓ 是否显示执行计划卡片
|
|
||||||
- ✓ 是否显示步骤执行过程
|
|
||||||
- ✓ 是否显示最终总结
|
|
||||||
- ✓ 步骤结果卡片是否可折叠
|
|
||||||
|
|
||||||
### 3. 测试用例
|
|
||||||
|
|
||||||
```
|
|
||||||
测试1: 简单查询
|
|
||||||
输入:查询贵州茅台的股票信息
|
|
||||||
预期:调用 get_stock_basic_info,返回基本信息
|
|
||||||
|
|
||||||
测试2: 深度分析(推荐)
|
|
||||||
输入:全面分析贵州茅台这只股票
|
|
||||||
预期:
|
|
||||||
- 步骤1: get_stock_basic_info
|
|
||||||
- 步骤2: get_stock_financial_index
|
|
||||||
- 步骤3: get_stock_trade_data
|
|
||||||
- 步骤4: search_china_news
|
|
||||||
- 步骤5: summarize_with_llm
|
|
||||||
|
|
||||||
测试3: 市场热点
|
|
||||||
输入:今日涨停股票有哪些亮点
|
|
||||||
预期:
|
|
||||||
- 步骤1: search_limit_up_stocks
|
|
||||||
- 步骤2: get_concept_statistics
|
|
||||||
- 步骤3: summarize_with_llm
|
|
||||||
|
|
||||||
测试4: 概念分析
|
|
||||||
输入:新能源概念板块的投资机会
|
|
||||||
预期:
|
|
||||||
- 步骤1: search_concepts(新能源)
|
|
||||||
- 步骤2: search_china_news(新能源)
|
|
||||||
- 步骤3: summarize_with_llm
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 故障排查
|
|
||||||
|
|
||||||
### 问题1: Agent 返回 "Provider not configured"
|
|
||||||
|
|
||||||
**原因**: 环境变量未设置
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# 检查环境变量
|
|
||||||
echo $DASHSCOPE_API_KEY
|
|
||||||
|
|
||||||
# 如果为空,重新设置
|
|
||||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
sudo systemctl restart mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题2: Agent 返回 JSON 解析错误
|
|
||||||
|
|
||||||
**原因**: LLM 没有返回正确的 JSON 格式
|
|
||||||
|
|
||||||
**解决**: 在 `mcp_agent_system.py` 中已经处理了代码块标记清理,如果还有问题:
|
|
||||||
1. 检查 LLM 的 temperature 参数(建议 0.3)
|
|
||||||
2. 检查 prompt 是否清晰
|
|
||||||
3. 尝试不同的 LLM 提供商
|
|
||||||
|
|
||||||
### 问题3: 前端显示 "查询失败"
|
|
||||||
|
|
||||||
**原因**: 后端 API 未正确配置或 Nginx 代理问题
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# 1. 检查 MCP 服务是否运行
|
|
||||||
ps aux | grep mcp_server
|
|
||||||
|
|
||||||
# 2. 检查 Nginx 配置
|
|
||||||
sudo nginx -t
|
|
||||||
|
|
||||||
# 3. 查看错误日志
|
|
||||||
sudo tail -f /var/log/nginx/error.log
|
|
||||||
tail -f /home/ubuntu/vf_react/mcp_server.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题4: 执行步骤失败
|
|
||||||
|
|
||||||
**原因**: 某个 MCP 工具调用失败
|
|
||||||
|
|
||||||
**解决**: 查看步骤结果卡片中的错误信息,通常是:
|
|
||||||
- API 超时:增加 timeout
|
|
||||||
- 参数错误:检查工具定义
|
|
||||||
- 数据库连接失败:检查数据库连接
|
|
||||||
|
|
||||||
## 💰 成本估算
|
|
||||||
|
|
||||||
### 使用通义千问(qwen-plus)
|
|
||||||
|
|
||||||
**价格**: ¥0.004/1000 tokens
|
|
||||||
|
|
||||||
**典型对话消耗**:
|
|
||||||
- 简单查询(1步): ~500 tokens = ¥0.002
|
|
||||||
- 深度分析(5步): ~3000 tokens = ¥0.012
|
|
||||||
- 平均每次对话: ¥0.005
|
|
||||||
|
|
||||||
**月度成本**(1000次深度分析):
|
|
||||||
- 1000次 × ¥0.012 = ¥12
|
|
||||||
|
|
||||||
**结论**: 非常便宜!1000次深度分析只需要12元。
|
|
||||||
|
|
||||||
### 使用 DeepSeek(更便宜)
|
|
||||||
|
|
||||||
**价格**: ¥0.001/1000 tokens(比通义千问便宜4倍)
|
|
||||||
|
|
||||||
**月度成本**(1000次深度分析):
|
|
||||||
- 1000次 × ¥0.003 = ¥3
|
|
||||||
|
|
||||||
## 📊 监控和优化
|
|
||||||
|
|
||||||
### 1. 添加日志监控
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 实时查看 Agent 日志
|
|
||||||
tail -f mcp_server.log | grep -E "\[Agent\]|\[Planning\]|\[Execution\]|\[Summary\]"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 性能优化建议
|
|
||||||
|
|
||||||
1. **缓存计划**: 相似的问题可以复用执行计划
|
|
||||||
2. **并行执行**: 独立的工具调用可以并行执行
|
|
||||||
3. **流式输出**: 使用 Server-Sent Events 实时返回进度
|
|
||||||
4. **结果缓存**: 相同的工具调用结果可以缓存
|
|
||||||
|
|
||||||
### 3. 添加统计分析
|
|
||||||
|
|
||||||
在 `mcp_server.py` 中添加:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
# 记录每次 Agent 调用
|
|
||||||
@app.post("/agent/chat")
|
|
||||||
async def agent_chat(request: ChatRequest):
|
|
||||||
start_time = datetime.now()
|
|
||||||
|
|
||||||
response = await agent.process_query(...)
|
|
||||||
|
|
||||||
duration = (datetime.now() - start_time).total_seconds()
|
|
||||||
|
|
||||||
# 记录到日志
|
|
||||||
logger.info(f"Agent query completed in {duration:.2f}s", extra={
|
|
||||||
"query": request.message,
|
|
||||||
"steps": len(response.plan.steps) if response.plan else 0,
|
|
||||||
"success": response.success,
|
|
||||||
"duration": duration,
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎉 完成!
|
|
||||||
|
|
||||||
现在你的 AI Agent 系统已经部署完成!
|
|
||||||
|
|
||||||
访问 https://valuefrontier.cn/agent-chat 开始使用。
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- ✅ 三阶段智能分析(计划-执行-总结)
|
|
||||||
- ✅ 漂亮的UI界面(卡片式展示)
|
|
||||||
- ✅ 步骤结果可折叠查看
|
|
||||||
- ✅ 实时进度反馈
|
|
||||||
- ✅ 异常处理和重试
|
|
||||||
- ✅ 成本低廉(¥3-12/月)
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
# 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
|
|
||||||
File diff suppressed because it is too large
Load Diff
1197
docs/Community.md
1197
docs/Community.md
File diff suppressed because it is too large
Load Diff
@@ -1,307 +0,0 @@
|
|||||||
# 🌙 暗色模式适配 - 测试指南
|
|
||||||
|
|
||||||
## ✅ 完成的修改
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**测试完成后,请反馈效果!** 🎉
|
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
# VF React 自动化部署指南
|
|
||||||
|
|
||||||
## 📋 目录
|
|
||||||
|
|
||||||
- [概述](#概述)
|
|
||||||
- [快速开始](#快速开始)
|
|
||||||
- [详细使用说明](#详细使用说明)
|
|
||||||
- [配置说明](#配置说明)
|
|
||||||
- [故障排查](#故障排查)
|
|
||||||
- [FAQ](#faq)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本项目提供了完整的自动化部署方案,让您可以在本地电脑一键部署到生产环境,无需登录服务器。
|
|
||||||
|
|
||||||
### 核心特性
|
|
||||||
|
|
||||||
- ✅ **本地一键部署** - 运行 `npm run deploy` 即可完成部署
|
|
||||||
- ✅ **智能备份** - 每次部署前自动备份,保留最近 5 个版本
|
|
||||||
- ✅ **快速回滚** - 10 秒内回滚到任意历史版本
|
|
||||||
- ✅ **企业微信通知** - 部署成功/失败实时推送消息
|
|
||||||
- ✅ **安全可靠** - 部署前确认,失败自动回滚
|
|
||||||
- ✅ **详细日志** - 完整记录每次部署过程
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 首次配置(5 分钟)
|
|
||||||
|
|
||||||
运行配置向导,按提示输入配置信息:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
配置向导会询问:
|
|
||||||
- 服务器地址和 SSH 信息
|
|
||||||
- 部署路径配置
|
|
||||||
- 企业微信通知配置(可选)
|
|
||||||
|
|
||||||
配置完成后会自动初始化服务器环境。
|
|
||||||
|
|
||||||
### 2. 日常部署(2-3 分钟)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
执行后会:
|
|
||||||
1. 检查本地代码状态
|
|
||||||
2. 显示部署预览,需要输入 `yes` 确认
|
|
||||||
3. 自动连接服务器
|
|
||||||
4. 拉取代码、构建、部署
|
|
||||||
5. 发送企业微信通知
|
|
||||||
|
|
||||||
### 3. 回滚版本(10 秒)
|
|
||||||
|
|
||||||
回滚到上一个版本:
|
|
||||||
```bash
|
|
||||||
npm run rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
回滚到指定版本:
|
|
||||||
```bash
|
|
||||||
npm run rollback -- 2 # 回滚到前 2 个版本
|
|
||||||
```
|
|
||||||
|
|
||||||
查看可回滚的版本列表:
|
|
||||||
```bash
|
|
||||||
npm run rollback -- list
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 详细使用说明
|
|
||||||
|
|
||||||
### 首次配置
|
|
||||||
|
|
||||||
#### 运行配置向导
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 配置过程
|
|
||||||
|
|
||||||
**1. 服务器配置**
|
|
||||||
```
|
|
||||||
请输入服务器 IP 或域名: your-server.com
|
|
||||||
请输入 SSH 用户名 [ubuntu]: ubuntu
|
|
||||||
请输入 SSH 端口 [22]: 22
|
|
||||||
检测到 SSH 密钥: ~/.ssh/id_rsa
|
|
||||||
是否使用该密钥? (y/n) [y]: y
|
|
||||||
|
|
||||||
正在测试 SSH 连接...
|
|
||||||
✓ SSH 连接测试成功
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 部署路径配置**
|
|
||||||
```
|
|
||||||
Git 仓库路径 [/home/ubuntu/vf_react]:
|
|
||||||
生产环境路径 [/var/www/valuefrontier.cn]:
|
|
||||||
备份目录 [/home/ubuntu/deployments]:
|
|
||||||
日志目录 [/home/ubuntu/deploy-logs]:
|
|
||||||
部署分支 [feature]:
|
|
||||||
保留备份数量 [5]:
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. 企业微信通知配置**
|
|
||||||
```
|
|
||||||
是否启用企业微信通知? (y/n) [n]: y
|
|
||||||
请输入企业微信 Webhook URL: https://qyapi.weixin.qq.com/...
|
|
||||||
|
|
||||||
正在测试企业微信通知...
|
|
||||||
✓ 企业微信通知测试成功
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. 初始化服务器**
|
|
||||||
```
|
|
||||||
正在创建服务器目录...
|
|
||||||
✓ 服务器目录创建完成
|
|
||||||
设置脚本执行权限...
|
|
||||||
✓ 服务器环境初始化完成
|
|
||||||
```
|
|
||||||
|
|
||||||
### 部署到生产环境
|
|
||||||
|
|
||||||
#### 执行部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 部署流程
|
|
||||||
|
|
||||||
**步骤 1: 检查本地代码**
|
|
||||||
```
|
|
||||||
[1/8] 检查本地代码
|
|
||||||
当前分支: feature
|
|
||||||
最新提交: c93f689 - feat: 添加消息推送能力
|
|
||||||
提交作者: qiye
|
|
||||||
✓ 本地代码检查完成
|
|
||||||
```
|
|
||||||
|
|
||||||
**步骤 2: 显示部署预览**
|
|
||||||
```
|
|
||||||
[2/8] 部署预览
|
|
||||||
|
|
||||||
╔════════════════════════════════════════════════════════════════╗
|
|
||||||
║ 部署预览 ║
|
|
||||||
╚════════════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
项目信息:
|
|
||||||
项目名称: vf_react
|
|
||||||
部署环境: 生产环境
|
|
||||||
目标服务器: ubuntu@your-server.com
|
|
||||||
|
|
||||||
代码信息:
|
|
||||||
当前分支: feature
|
|
||||||
提交版本: c93f689
|
|
||||||
提交信息: feat: 添加消息推送能力
|
|
||||||
提交作者: qiye
|
|
||||||
|
|
||||||
部署路径:
|
|
||||||
Git 仓库: /home/ubuntu/vf_react
|
|
||||||
生产目录: /var/www/valuefrontier.cn
|
|
||||||
|
|
||||||
════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
确认部署到生产环境? (yes/no): yes
|
|
||||||
```
|
|
||||||
|
|
||||||
**步骤 3-7: 自动执行部署**
|
|
||||||
```
|
|
||||||
[3/8] 测试 SSH 连接
|
|
||||||
✓ SSH 连接成功
|
|
||||||
|
|
||||||
[4/8] 上传部署脚本
|
|
||||||
✓ 部署脚本上传完成
|
|
||||||
|
|
||||||
[5/8] 执行远程部署
|
|
||||||
|
|
||||||
========================================
|
|
||||||
服务器端部署脚本
|
|
||||||
========================================
|
|
||||||
|
|
||||||
[INFO] 创建必要的目录...
|
|
||||||
[SUCCESS] 目录创建完成
|
|
||||||
[INFO] 检查 Git 仓库...
|
|
||||||
[SUCCESS] Git 仓库检查通过
|
|
||||||
[INFO] 切换到 feature 分支...
|
|
||||||
[SUCCESS] 已在 feature 分支
|
|
||||||
[INFO] 拉取最新代码...
|
|
||||||
[SUCCESS] 代码更新完成
|
|
||||||
[INFO] 安装依赖...
|
|
||||||
[SUCCESS] 依赖检查完成
|
|
||||||
[INFO] 构建项目...
|
|
||||||
[SUCCESS] 构建完成
|
|
||||||
[INFO] 备份当前版本...
|
|
||||||
[SUCCESS] 备份完成: /home/ubuntu/deployments/backup-20250121-143020
|
|
||||||
[INFO] 部署到生产环境...
|
|
||||||
[SUCCESS] 部署完成
|
|
||||||
[INFO] 清理旧备份...
|
|
||||||
[SUCCESS] 旧备份清理完成
|
|
||||||
|
|
||||||
========================================
|
|
||||||
部署成功!
|
|
||||||
========================================
|
|
||||||
提交: c93f689 - feat: 添加消息推送能力
|
|
||||||
备份: /home/ubuntu/deployments/backup-20250121-143020
|
|
||||||
耗时: 2分15秒
|
|
||||||
|
|
||||||
✓ 远程部署完成
|
|
||||||
|
|
||||||
[6/8] 发送部署通知
|
|
||||||
✓ 企业微信通知已发送
|
|
||||||
|
|
||||||
[7/8] 清理临时文件
|
|
||||||
✓ 清理完成
|
|
||||||
|
|
||||||
[8/8] 部署完成
|
|
||||||
|
|
||||||
╔════════════════════════════════════════════════════════════════╗
|
|
||||||
║ 🎉 部署成功! ║
|
|
||||||
╚════════════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
部署信息:
|
|
||||||
版本: c93f689
|
|
||||||
分支: feature
|
|
||||||
提交: feat: 添加消息推送能力
|
|
||||||
作者: qiye
|
|
||||||
时间: 2025-01-21 14:33:45
|
|
||||||
耗时: 2分15秒
|
|
||||||
|
|
||||||
访问地址:
|
|
||||||
https://valuefrontier.cn
|
|
||||||
```
|
|
||||||
|
|
||||||
### 版本回滚
|
|
||||||
|
|
||||||
#### 查看可回滚的版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rollback -- list
|
|
||||||
```
|
|
||||||
|
|
||||||
输出:
|
|
||||||
```
|
|
||||||
可用的备份版本:
|
|
||||||
|
|
||||||
1. backup-20250121-153045 (2025-01-21 15:30:45) [当前版本]
|
|
||||||
2. backup-20250121-150030 (2025-01-21 15:00:30)
|
|
||||||
3. backup-20250121-143020 (2025-01-21 14:30:20)
|
|
||||||
4. backup-20250121-140010 (2025-01-21 14:00:10)
|
|
||||||
5. backup-20250121-133000 (2025-01-21 13:30:00)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 回滚到上一个版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
或指定版本:
|
|
||||||
```bash
|
|
||||||
npm run rollback -- 2 # 回滚到第 2 个版本
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 回滚流程
|
|
||||||
|
|
||||||
```
|
|
||||||
╔════════════════════════════════════════════════════════════════╗
|
|
||||||
║ 版本回滚工具 ║
|
|
||||||
╚════════════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
可用的备份版本:
|
|
||||||
1. backup-20250121-153045 (2025-01-21 15:30:45) [当前版本]
|
|
||||||
2. backup-20250121-150030 (2025-01-21 15:00:30)
|
|
||||||
3. backup-20250121-143020 (2025-01-21 14:30:20)
|
|
||||||
|
|
||||||
确认回滚到版本 #2? (yes/no): yes
|
|
||||||
|
|
||||||
[INFO] 正在执行回滚...
|
|
||||||
|
|
||||||
========================================
|
|
||||||
服务器端回滚脚本
|
|
||||||
========================================
|
|
||||||
|
|
||||||
[INFO] 开始回滚到版本 #2...
|
|
||||||
[INFO] 目标版本: backup-20250121-150030
|
|
||||||
[INFO] 清空生产目录: /var/www/valuefrontier.cn
|
|
||||||
[INFO] 恢复备份文件...
|
|
||||||
[SUCCESS] 回滚完成
|
|
||||||
|
|
||||||
========================================
|
|
||||||
回滚成功!
|
|
||||||
========================================
|
|
||||||
目标版本: backup-20250121-150030
|
|
||||||
|
|
||||||
╔════════════════════════════════════════════════════════════════╗
|
|
||||||
║ 🎉 回滚成功! ║
|
|
||||||
╚════════════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
回滚信息:
|
|
||||||
目标版本: backup-20250121-150030
|
|
||||||
回滚时间: 2025-01-21 15:35:20
|
|
||||||
|
|
||||||
访问地址:
|
|
||||||
https://valuefrontier.cn
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
### 配置文件位置
|
|
||||||
|
|
||||||
```
|
|
||||||
.env.deploy # 部署配置文件(不提交到 Git)
|
|
||||||
.env.deploy.example # 配置文件示例
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置项说明
|
|
||||||
|
|
||||||
#### 服务器配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 服务器 IP 或域名
|
|
||||||
SERVER_HOST=your-server.com
|
|
||||||
|
|
||||||
# SSH 用户名
|
|
||||||
SERVER_USER=ubuntu
|
|
||||||
|
|
||||||
# SSH 端口(默认 22)
|
|
||||||
SERVER_PORT=22
|
|
||||||
|
|
||||||
# SSH 密钥路径(留空使用默认 ~/.ssh/id_rsa)
|
|
||||||
SSH_KEY_PATH=
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 路径配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 服务器上的 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 配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 部署分支
|
|
||||||
DEPLOY_BRANCH=feature
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 备份配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 保留备份数量(超过会自动删除最旧的)
|
|
||||||
KEEP_BACKUPS=5
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 企业微信通知配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 是否启用企业微信通知
|
|
||||||
ENABLE_WECHAT_NOTIFY=true
|
|
||||||
|
|
||||||
# 企业微信机器人 Webhook URL
|
|
||||||
WECHAT_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxx
|
|
||||||
|
|
||||||
# 通知提及的用户(@all 或手机号/userid,逗号分隔)
|
|
||||||
WECHAT_MENTIONED_LIST=@all
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 部署配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 是否在部署前运行 npm install
|
|
||||||
RUN_NPM_INSTALL=true
|
|
||||||
|
|
||||||
# 是否在部署前运行 npm test
|
|
||||||
RUN_NPM_TEST=false
|
|
||||||
|
|
||||||
# 构建命令
|
|
||||||
BUILD_COMMAND=npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改配置
|
|
||||||
|
|
||||||
编辑配置文件:
|
|
||||||
```bash
|
|
||||||
vim .env.deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
或使用编辑器打开 `.env.deploy` 文件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 企业微信通知
|
|
||||||
|
|
||||||
### 配置企业微信机器人
|
|
||||||
|
|
||||||
1. **打开企业微信群聊**
|
|
||||||
2. **添加群机器人**
|
|
||||||
- 点击群设置(右上角 ···)
|
|
||||||
- 选择"群机器人"
|
|
||||||
- 点击"添加机器人"
|
|
||||||
3. **设置机器人信息**
|
|
||||||
- 输入机器人名称(如:部署通知机器人)
|
|
||||||
- 复制 Webhook URL
|
|
||||||
4. **配置到项目**
|
|
||||||
- 将 Webhook URL 粘贴到 `.env.deploy` 文件的 `WECHAT_WEBHOOK_URL` 字段
|
|
||||||
|
|
||||||
### 通知内容
|
|
||||||
|
|
||||||
#### 部署成功通知
|
|
||||||
```
|
|
||||||
【生产环境部署成功】
|
|
||||||
项目:vf_react
|
|
||||||
环境:生产环境
|
|
||||||
分支:feature
|
|
||||||
版本:c93f689
|
|
||||||
提交信息:feat: 添加消息推送能力
|
|
||||||
部署时间:2025-01-21 14:33:45
|
|
||||||
部署耗时:2分15秒
|
|
||||||
操作人:qiye
|
|
||||||
访问地址:https://valuefrontier.cn
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 部署失败通知
|
|
||||||
```
|
|
||||||
【⚠️ 生产环境部署失败】
|
|
||||||
项目:vf_react
|
|
||||||
环境:生产环境
|
|
||||||
分支:feature
|
|
||||||
失败原因:构建失败
|
|
||||||
失败时间:2025-01-21 14:35:20
|
|
||||||
操作人:qiye
|
|
||||||
已自动回滚到上一版本
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 回滚成功通知
|
|
||||||
```
|
|
||||||
【版本回滚成功】
|
|
||||||
项目:vf_react
|
|
||||||
环境:生产环境
|
|
||||||
回滚版本:backup-20250121-150030
|
|
||||||
回滚时间:2025-01-21 15:35:20
|
|
||||||
操作人:qiye
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 常见问题
|
|
||||||
|
|
||||||
#### 1. SSH 连接失败
|
|
||||||
|
|
||||||
**错误信息**:
|
|
||||||
```
|
|
||||||
[✗] SSH 连接失败
|
|
||||||
```
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- 服务器地址、用户名或端口配置错误
|
|
||||||
- SSH 密钥未配置或路径错误
|
|
||||||
- 服务器防火墙阻止连接
|
|
||||||
|
|
||||||
**解决方法**:
|
|
||||||
1. 检查配置文件 `.env.deploy` 中的服务器信息
|
|
||||||
2. 测试 SSH 连接:
|
|
||||||
```bash
|
|
||||||
ssh ubuntu@your-server.com
|
|
||||||
```
|
|
||||||
3. 确认 SSH 密钥已添加到服务器:
|
|
||||||
```bash
|
|
||||||
ssh-copy-id ubuntu@your-server.com
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 构建失败
|
|
||||||
|
|
||||||
**错误信息**:
|
|
||||||
```
|
|
||||||
[ERROR] 构建失败
|
|
||||||
npm run build exited with code 1
|
|
||||||
```
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- 代码存在语法错误
|
|
||||||
- 依赖包版本不兼容
|
|
||||||
- Node.js 版本不匹配
|
|
||||||
|
|
||||||
**解决方法**:
|
|
||||||
1. 在本地先运行构建测试:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
2. 检查并修复错误
|
|
||||||
3. 确认服务器 Node.js 版本:
|
|
||||||
```bash
|
|
||||||
ssh ubuntu@your-server.com "node -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 权限不足
|
|
||||||
|
|
||||||
**错误信息**:
|
|
||||||
```
|
|
||||||
[ERROR] 复制文件失败
|
|
||||||
Permission denied
|
|
||||||
```
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- 对生产目录没有写权限
|
|
||||||
- 需要 sudo 权限
|
|
||||||
|
|
||||||
**解决方法**:
|
|
||||||
1. 检查生产目录权限:
|
|
||||||
```bash
|
|
||||||
ssh ubuntu@your-server.com "ls -ld /var/www/valuefrontier.cn"
|
|
||||||
```
|
|
||||||
2. 修改目录所有者:
|
|
||||||
```bash
|
|
||||||
ssh ubuntu@your-server.com "sudo chown -R ubuntu:ubuntu /var/www/valuefrontier.cn"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 企业微信通知发送失败
|
|
||||||
|
|
||||||
**错误信息**:
|
|
||||||
```
|
|
||||||
[⚠] 企业微信通知发送失败
|
|
||||||
```
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- Webhook URL 错误
|
|
||||||
- 网络问题
|
|
||||||
|
|
||||||
**解决方法**:
|
|
||||||
1. 检查 Webhook URL 是否正确
|
|
||||||
2. 手动测试通知:
|
|
||||||
```bash
|
|
||||||
bash scripts/notify-wechat.sh test
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
### Q1: 部署会影响正在访问网站的用户吗?
|
|
||||||
|
|
||||||
A: 部署过程中会有短暂的服务中断(约 1-2 秒),建议在流量较低时进行部署。
|
|
||||||
|
|
||||||
### Q2: 如果部署过程中网络断开怎么办?
|
|
||||||
|
|
||||||
A: 脚本会自动检测错误并停止部署。由于有自动备份,可以安全地重新运行部署或执行回滚。
|
|
||||||
|
|
||||||
### Q3: 可以同时部署多个项目吗?
|
|
||||||
|
|
||||||
A: 不建议。请等待当前部署完成后再部署其他项目。
|
|
||||||
|
|
||||||
### Q4: 备份文件占用空间过大怎么办?
|
|
||||||
|
|
||||||
A: 可以修改 `.env.deploy` 中的 `KEEP_BACKUPS` 配置,减少保留的备份数量。
|
|
||||||
|
|
||||||
### Q5: 如何查看详细的部署日志?
|
|
||||||
|
|
||||||
A: 部署日志保存在服务器上:
|
|
||||||
```bash
|
|
||||||
ssh ubuntu@your-server.com "cat /home/ubuntu/deploy-logs/deploy-YYYYMMDD-HHMMSS.log"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q6: 可以在 Windows 上使用吗?
|
|
||||||
|
|
||||||
A: 可以。脚本使用标准的 Bash 命令,在 Git Bash 或 WSL 中都可以正常运行。
|
|
||||||
|
|
||||||
### Q7: 如何禁用企业微信通知?
|
|
||||||
|
|
||||||
A: 编辑 `.env.deploy` 文件,将 `ENABLE_WECHAT_NOTIFY` 设置为 `false`。
|
|
||||||
|
|
||||||
### Q8: 部署失败后是否需要手动回滚?
|
|
||||||
|
|
||||||
A: 不需要。如果构建失败,脚本会自动回滚到上一个版本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
vf_react/
|
|
||||||
├── scripts/ # 部署脚本目录
|
|
||||||
│ ├── setup-deployment.sh # 配置向导
|
|
||||||
│ ├── deploy-from-local.sh # 本地部署脚本
|
|
||||||
│ ├── deploy-on-server.sh # 服务器部署脚本
|
|
||||||
│ ├── rollback-from-local.sh # 本地回滚脚本
|
|
||||||
│ ├── rollback-on-server.sh # 服务器回滚脚本
|
|
||||||
│ └── notify-wechat.sh # 企业微信通知脚本
|
|
||||||
├── .env.deploy.example # 配置文件示例
|
|
||||||
├── .env.deploy # 配置文件(不提交到 Git)
|
|
||||||
├── DEPLOYMENT.md # 本文档
|
|
||||||
└── package.json # 包含部署命令
|
|
||||||
```
|
|
||||||
|
|
||||||
**服务器目录结构**:
|
|
||||||
```
|
|
||||||
/home/ubuntu/
|
|
||||||
├── vf_react/ # Git 仓库
|
|
||||||
│ └── build/ # 构建产物
|
|
||||||
├── deployments/ # 版本备份
|
|
||||||
│ ├── backup-20250121-143020/
|
|
||||||
│ ├── backup-20250121-150030/
|
|
||||||
│ └── current -> backup-20250121-150030
|
|
||||||
└── deploy-logs/ # 部署日志
|
|
||||||
└── deploy-20250121-143020.log
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 命令速查表
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `npm run deploy:setup` | 首次配置部署环境 |
|
|
||||||
| `npm run deploy` | 部署到生产环境 |
|
|
||||||
| `npm run rollback` | 回滚到上一个版本 |
|
|
||||||
| `npm run rollback -- 2` | 回滚到前 2 个版本 |
|
|
||||||
| `npm run rollback -- list` | 查看可回滚的版本列表 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 支持
|
|
||||||
|
|
||||||
如有问题,请联系开发团队或提交 Issue。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**祝部署顺利!** 🎉
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 🚀 部署快速上手指南
|
|
||||||
|
|
||||||
## 首次使用(5 分钟)
|
|
||||||
|
|
||||||
### 步骤 1: 运行配置向导
|
|
||||||
```bash
|
|
||||||
npm run deploy:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
按提示输入以下信息:
|
|
||||||
- 服务器地址:`你的服务器IP或域名`
|
|
||||||
- SSH 用户名:`ubuntu`
|
|
||||||
- SSH 端口:`22`
|
|
||||||
- SSH 密钥:按 `y` 使用默认密钥
|
|
||||||
- 企业微信通知:按 `y` 启用(或按 `n` 跳过)
|
|
||||||
|
|
||||||
配置完成!✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 日常部署(2 分钟)
|
|
||||||
|
|
||||||
### 步骤 1: 部署到生产环境
|
|
||||||
```bash
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 2: 确认部署
|
|
||||||
看到部署预览后,输入 `yes` 确认
|
|
||||||
|
|
||||||
等待 2-3 分钟,部署完成!🎉
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 如果出问题了
|
|
||||||
|
|
||||||
### 立即回滚
|
|
||||||
```bash
|
|
||||||
npm run rollback
|
|
||||||
```
|
|
||||||
|
|
||||||
输入 `yes` 确认,10 秒内恢复!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 部署
|
|
||||||
npm run deploy
|
|
||||||
|
|
||||||
# 回滚
|
|
||||||
npm run rollback
|
|
||||||
|
|
||||||
# 查看可回滚的版本
|
|
||||||
npm run rollback -- list
|
|
||||||
|
|
||||||
# 重新配置
|
|
||||||
npm run deploy:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 需要帮助?
|
|
||||||
|
|
||||||
查看完整文档:[DEPLOYMENT.md](./DEPLOYMENT.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**就这么简单!** ✨
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
# 通知系统增强功能 - 使用指南
|
|
||||||
|
|
||||||
## 📋 概述
|
|
||||||
|
|
||||||
本指南介绍通知系统的三大增强功能:
|
|
||||||
1. **智能桌面通知** - 自动请求权限,系统级通知
|
|
||||||
2. **性能监控** - 追踪推送效果,数据驱动优化
|
|
||||||
3. **历史记录** - 持久化存储,随时查询
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 功能 1:智能桌面通知
|
|
||||||
|
|
||||||
### 功能说明
|
|
||||||
|
|
||||||
首次收到重要/紧急通知时,自动请求浏览器通知权限,确保用户不错过关键信息。
|
|
||||||
|
|
||||||
### 工作原理
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 在 NotificationContext 中的逻辑
|
|
||||||
if (priority === URGENT || priority === IMPORTANT) {
|
|
||||||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
|
||||||
// 首次遇到重要通知,自动请求权限
|
|
||||||
await requestBrowserPermission();
|
|
||||||
setHasRequestedPermission(true); // 避免重复请求
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 权限状态
|
|
||||||
|
|
||||||
- **granted**: 已授权,可以发送桌面通知
|
|
||||||
- **denied**: 已拒绝,无法发送桌面通知
|
|
||||||
- **default**: 未请求,首次重要通知时会自动请求
|
|
||||||
|
|
||||||
### 使用示例
|
|
||||||
|
|
||||||
**自动触发**(推荐)
|
|
||||||
```javascript
|
|
||||||
// 无需任何代码,系统自动处理
|
|
||||||
// 首次收到重要/紧急通知时会自动弹出权限请求
|
|
||||||
```
|
|
||||||
|
|
||||||
**手动请求**
|
|
||||||
```javascript
|
|
||||||
import { useNotification } from 'contexts/NotificationContext';
|
|
||||||
|
|
||||||
function SettingsPage() {
|
|
||||||
const { requestBrowserPermission, browserPermission } = useNotification();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>当前状态: {browserPermission}</p>
|
|
||||||
<button onClick={requestBrowserPermission}>
|
|
||||||
开启桌面通知
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通知分发策略
|
|
||||||
|
|
||||||
| 优先级 | 页面在前台 | 页面在后台 |
|
|
||||||
|-------|----------|----------|
|
|
||||||
| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
|
|
||||||
| 重要 | 网页通知 | 桌面通知 |
|
|
||||||
| 普通 | 网页通知 | 网页通知 |
|
|
||||||
|
|
||||||
### 测试步骤
|
|
||||||
|
|
||||||
1. **清除已保存的权限状态**
|
|
||||||
```javascript
|
|
||||||
localStorage.removeItem('browser_notification_requested');
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **刷新页面**
|
|
||||||
|
|
||||||
3. **触发一个重要/紧急通知**
|
|
||||||
- Mock 模式:等待自动推送
|
|
||||||
- Real 模式:创建测试事件
|
|
||||||
|
|
||||||
4. **观察权限请求弹窗**
|
|
||||||
- 浏览器会弹出通知权限请求
|
|
||||||
- 点击"允许"授权
|
|
||||||
|
|
||||||
5. **验证桌面通知**
|
|
||||||
- 切换到其他标签页
|
|
||||||
- 收到重要通知时应该看到桌面通知
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 功能 2:性能监控
|
|
||||||
|
|
||||||
### 功能说明
|
|
||||||
|
|
||||||
追踪通知推送的各项指标,包括:
|
|
||||||
- **到达率**: 发送 vs 接收
|
|
||||||
- **点击率**: 点击 vs 接收
|
|
||||||
- **响应时间**: 收到通知到点击的平均时间
|
|
||||||
- **类型分布**: 各类型通知的数量和效果
|
|
||||||
- **时段分布**: 每小时推送量
|
|
||||||
|
|
||||||
### API 参考
|
|
||||||
|
|
||||||
#### 获取汇总统计
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
|
||||||
|
|
||||||
const summary = notificationMetricsService.getSummary();
|
|
||||||
console.log(summary);
|
|
||||||
/* 输出:
|
|
||||||
{
|
|
||||||
totalSent: 100,
|
|
||||||
totalReceived: 98,
|
|
||||||
totalClicked: 45,
|
|
||||||
totalDismissed: 53,
|
|
||||||
avgResponseTime: 5200, // 毫秒
|
|
||||||
clickRate: '45.92', // 百分比
|
|
||||||
deliveryRate: '98.00' // 百分比
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取按类型统计
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const byType = notificationMetricsService.getByType();
|
|
||||||
console.log(byType);
|
|
||||||
/* 输出:
|
|
||||||
{
|
|
||||||
announcement: { sent: 20, received: 20, clicked: 15, dismissed: 5, clickRate: '75.00' },
|
|
||||||
stock_alert: { sent: 30, received: 30, clicked: 20, dismissed: 10, clickRate: '66.67' },
|
|
||||||
event_alert: { sent: 40, received: 38, clicked: 10, dismissed: 28, clickRate: '26.32' },
|
|
||||||
analysis_report: { sent: 10, received: 10, clicked: 0, dismissed: 10, clickRate: '0.00' }
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取按优先级统计
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const byPriority = notificationMetricsService.getByPriority();
|
|
||||||
console.log(byPriority);
|
|
||||||
/* 输出:
|
|
||||||
{
|
|
||||||
urgent: { sent: 10, received: 10, clicked: 9, dismissed: 1, clickRate: '90.00' },
|
|
||||||
important: { sent: 40, received: 39, clicked: 25, dismissed: 14, clickRate: '64.10' },
|
|
||||||
normal: { sent: 50, received: 49, clicked: 11, dismissed: 38, clickRate: '22.45' }
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取每日数据
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const dailyData = notificationMetricsService.getDailyData(7); // 最近 7 天
|
|
||||||
console.log(dailyData);
|
|
||||||
/* 输出:
|
|
||||||
[
|
|
||||||
{ date: '2025-01-15', sent: 15, received: 14, clicked: 6, dismissed: 8, clickRate: '42.86' },
|
|
||||||
{ date: '2025-01-16', sent: 20, received: 20, clicked: 10, dismissed: 10, clickRate: '50.00' },
|
|
||||||
...
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取完整指标
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const allMetrics = notificationMetricsService.getAllMetrics();
|
|
||||||
console.log(allMetrics);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 导出数据
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 导出为 JSON
|
|
||||||
const json = notificationMetricsService.exportToJSON();
|
|
||||||
console.log(json);
|
|
||||||
|
|
||||||
// 导出为 CSV
|
|
||||||
const csv = notificationMetricsService.exportToCSV();
|
|
||||||
console.log(csv);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 重置指标
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
notificationMetricsService.reset();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 在控制台查看实时指标
|
|
||||||
|
|
||||||
打开浏览器控制台,执行:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 引入服务
|
|
||||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
|
||||||
|
|
||||||
// 查看汇总
|
|
||||||
console.table(notificationMetricsService.getSummary());
|
|
||||||
|
|
||||||
// 查看按类型分布
|
|
||||||
console.table(notificationMetricsService.getByType());
|
|
||||||
|
|
||||||
// 查看最近 7 天数据
|
|
||||||
console.table(notificationMetricsService.getDailyData(7));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 监控埋点(自动)
|
|
||||||
|
|
||||||
监控服务已自动集成到 `NotificationContext`,无需手动调用:
|
|
||||||
|
|
||||||
- **trackReceived**: 收到通知时自动调用
|
|
||||||
- **trackClicked**: 点击通知时自动调用
|
|
||||||
- **trackDismissed**: 关闭通知时自动调用
|
|
||||||
|
|
||||||
### 可视化展示(可选)
|
|
||||||
|
|
||||||
你可以基于监控数据创建仪表板:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
|
||||||
import { PieChart, LineChart } from 'recharts';
|
|
||||||
|
|
||||||
function MetricsDashboard() {
|
|
||||||
const summary = notificationMetricsService.getSummary();
|
|
||||||
const dailyData = notificationMetricsService.getDailyData(7);
|
|
||||||
const byType = notificationMetricsService.getByType();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 汇总卡片 */}
|
|
||||||
<StatsCard title="总推送数" value={summary.totalSent} />
|
|
||||||
<StatsCard title="点击率" value={`${summary.clickRate}%`} />
|
|
||||||
<StatsCard title="平均响应时间" value={`${summary.avgResponseTime}ms`} />
|
|
||||||
|
|
||||||
{/* 类型分布饼图 */}
|
|
||||||
<PieChart data={Object.entries(byType).map(([type, data]) => ({
|
|
||||||
name: type,
|
|
||||||
value: data.received
|
|
||||||
}))} />
|
|
||||||
|
|
||||||
{/* 每日趋势折线图 */}
|
|
||||||
<LineChart data={dailyData} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 功能 3:历史记录
|
|
||||||
|
|
||||||
### 功能说明
|
|
||||||
|
|
||||||
持久化存储所有接收到的通知,支持:
|
|
||||||
- 查询和筛选
|
|
||||||
- 搜索关键词
|
|
||||||
- 标记已读/已点击
|
|
||||||
- 批量删除
|
|
||||||
- 导出(JSON/CSV)
|
|
||||||
|
|
||||||
### API 参考
|
|
||||||
|
|
||||||
#### 获取历史记录(支持筛选和分页)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
|
||||||
|
|
||||||
const result = notificationHistoryService.getHistory({
|
|
||||||
type: 'event_alert', // 可选:筛选类型
|
|
||||||
priority: 'urgent', // 可选:筛选优先级
|
|
||||||
readStatus: 'unread', // 可选:'read' | 'unread' | 'all'
|
|
||||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000, // 可选:开始日期
|
|
||||||
endDate: Date.now(), // 可选:结束日期
|
|
||||||
page: 1, // 页码
|
|
||||||
pageSize: 20, // 每页数量
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(result);
|
|
||||||
/* 输出:
|
|
||||||
{
|
|
||||||
records: [...], // 当前页的记录
|
|
||||||
total: 150, // 总记录数
|
|
||||||
page: 1, // 当前页
|
|
||||||
pageSize: 20, // 每页数量
|
|
||||||
totalPages: 8 // 总页数
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 搜索历史记录
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const results = notificationHistoryService.searchHistory('降准');
|
|
||||||
console.log(results); // 返回标题/内容中包含"降准"的所有记录
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 标记已读/已点击
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 标记已读
|
|
||||||
notificationHistoryService.markAsRead('notification_id');
|
|
||||||
|
|
||||||
// 标记已点击
|
|
||||||
notificationHistoryService.markAsClicked('notification_id');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 删除记录
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 删除单条
|
|
||||||
notificationHistoryService.deleteRecord('notification_id');
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
notificationHistoryService.deleteRecords(['id1', 'id2', 'id3']);
|
|
||||||
|
|
||||||
// 清空所有
|
|
||||||
notificationHistoryService.clearHistory();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取统计数据
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const stats = notificationHistoryService.getStats();
|
|
||||||
console.log(stats);
|
|
||||||
/* 输出:
|
|
||||||
{
|
|
||||||
total: 500, // 总记录数
|
|
||||||
read: 320, // 已读数
|
|
||||||
unread: 180, // 未读数
|
|
||||||
clicked: 150, // 已点击数
|
|
||||||
clickRate: '30.00', // 点击率
|
|
||||||
byType: { // 按类型统计
|
|
||||||
announcement: 100,
|
|
||||||
stock_alert: 150,
|
|
||||||
event_alert: 200,
|
|
||||||
analysis_report: 50
|
|
||||||
},
|
|
||||||
byPriority: { // 按优先级统计
|
|
||||||
urgent: 50,
|
|
||||||
important: 200,
|
|
||||||
normal: 250
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 导出历史记录
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 导出为 JSON 字符串
|
|
||||||
const json = notificationHistoryService.exportToJSON({
|
|
||||||
type: 'event_alert' // 可选:只导出特定类型
|
|
||||||
});
|
|
||||||
|
|
||||||
// 导出为 CSV 字符串
|
|
||||||
const csv = notificationHistoryService.exportToCSV();
|
|
||||||
|
|
||||||
// 直接下载 JSON 文件
|
|
||||||
notificationHistoryService.downloadJSON();
|
|
||||||
|
|
||||||
// 直接下载 CSV 文件
|
|
||||||
notificationHistoryService.downloadCSV();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 在控制台使用
|
|
||||||
|
|
||||||
打开浏览器控制台,执行:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 引入服务
|
|
||||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
|
||||||
|
|
||||||
// 查看所有历史
|
|
||||||
console.table(notificationHistoryService.getHistory().records);
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const results = notificationHistoryService.searchHistory('央行');
|
|
||||||
console.table(results);
|
|
||||||
|
|
||||||
// 查看统计
|
|
||||||
console.table(notificationHistoryService.getStats());
|
|
||||||
|
|
||||||
// 导出并下载
|
|
||||||
notificationHistoryService.downloadJSON();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据结构
|
|
||||||
|
|
||||||
每条历史记录包含:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
id: 'notif_123', // 通知 ID
|
|
||||||
notification: { // 完整通知对象
|
|
||||||
type: 'event_alert',
|
|
||||||
priority: 'urgent',
|
|
||||||
title: '...',
|
|
||||||
content: '...',
|
|
||||||
...
|
|
||||||
},
|
|
||||||
receivedAt: 1737459600000, // 接收时间戳
|
|
||||||
readAt: 1737459650000, // 已读时间戳(null 表示未读)
|
|
||||||
clickedAt: null, // 已点击时间戳(null 表示未点击)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 存储限制
|
|
||||||
|
|
||||||
- **最大数量**: 500 条(超过后自动删除最旧的)
|
|
||||||
- **存储位置**: localStorage
|
|
||||||
- **容量估算**: 约 2-5MB(取决于通知内容长度)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 技术细节
|
|
||||||
|
|
||||||
### 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── services/
|
|
||||||
│ ├── browserNotificationService.js [已存在] 浏览器通知服务
|
|
||||||
│ ├── notificationMetricsService.js [新建] 性能监控服务
|
|
||||||
│ └── notificationHistoryService.js [新建] 历史记录服务
|
|
||||||
├── contexts/
|
|
||||||
│ └── NotificationContext.js [修改] 集成所有功能
|
|
||||||
└── components/
|
|
||||||
└── NotificationContainer/
|
|
||||||
└── index.js [修改] 添加点击追踪
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改清单
|
|
||||||
|
|
||||||
| 文件 | 修改内容 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `NotificationContext.js` | 添加智能权限请求、监控埋点、历史保存 | ✅ 已完成 |
|
|
||||||
| `NotificationContainer/index.js` | 添加点击追踪 | ✅ 已完成 |
|
|
||||||
| `notificationMetricsService.js` | 性能监控服务 | ✅ 已创建 |
|
|
||||||
| `notificationHistoryService.js` | 历史记录服务 | ✅ 已创建 |
|
|
||||||
|
|
||||||
### 数据流
|
|
||||||
|
|
||||||
```
|
|
||||||
用户收到通知
|
|
||||||
↓
|
|
||||||
NotificationContext.addWebNotification()
|
|
||||||
├─ notificationMetricsService.trackReceived() [监控埋点]
|
|
||||||
├─ notificationHistoryService.saveNotification() [历史保存]
|
|
||||||
├─ 首次重要通知 → requestBrowserPermission() [智能权限]
|
|
||||||
└─ 显示网页通知或桌面通知
|
|
||||||
|
|
||||||
用户点击通知
|
|
||||||
↓
|
|
||||||
NotificationContainer.handleClick()
|
|
||||||
├─ notificationMetricsService.trackClicked() [监控埋点]
|
|
||||||
├─ notificationHistoryService.markAsClicked() [历史标记]
|
|
||||||
└─ 跳转到目标页面
|
|
||||||
|
|
||||||
用户关闭通知
|
|
||||||
↓
|
|
||||||
NotificationContext.removeNotification()
|
|
||||||
└─ notificationMetricsService.trackDismissed() [监控埋点]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 测试步骤
|
|
||||||
|
|
||||||
### 1. 测试智能桌面通知
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 清除已保存的权限状态
|
|
||||||
localStorage.removeItem('browser_notification_requested');
|
|
||||||
|
|
||||||
# 2. 刷新页面
|
|
||||||
|
|
||||||
# 3. 等待或触发一个重要/紧急通知
|
|
||||||
|
|
||||||
# 4. 观察浏览器弹出权限请求
|
|
||||||
|
|
||||||
# 5. 授权后验证桌面通知功能
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 测试性能监控
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 在控制台执行
|
|
||||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
|
||||||
|
|
||||||
// 查看实时统计
|
|
||||||
console.table(notificationMetricsService.getSummary());
|
|
||||||
|
|
||||||
// 模拟推送几条通知,再次查看
|
|
||||||
console.table(notificationMetricsService.getAllMetrics());
|
|
||||||
|
|
||||||
// 导出数据
|
|
||||||
console.log(notificationMetricsService.exportToJSON());
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 测试历史记录
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 在控制台执行
|
|
||||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
|
||||||
|
|
||||||
// 查看历史
|
|
||||||
console.table(notificationHistoryService.getHistory().records);
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
console.table(notificationHistoryService.searchHistory('降准'));
|
|
||||||
|
|
||||||
// 查看统计
|
|
||||||
console.table(notificationHistoryService.getStats());
|
|
||||||
|
|
||||||
// 导出
|
|
||||||
notificationHistoryService.downloadJSON();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 数据导出示例
|
|
||||||
|
|
||||||
### 导出性能监控数据
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
|
||||||
|
|
||||||
// 导出 JSON
|
|
||||||
const json = notificationMetricsService.exportToJSON();
|
|
||||||
// 复制到剪贴板或保存
|
|
||||||
|
|
||||||
// 导出 CSV
|
|
||||||
const csv = notificationMetricsService.exportToCSV();
|
|
||||||
// 可以在 Excel 中打开
|
|
||||||
```
|
|
||||||
|
|
||||||
### 导出历史记录
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
|
||||||
|
|
||||||
// 导出最近 7 天的事件动向通知
|
|
||||||
const json = notificationHistoryService.exportToJSON({
|
|
||||||
type: 'event_alert',
|
|
||||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000
|
|
||||||
});
|
|
||||||
|
|
||||||
// 直接下载为文件
|
|
||||||
notificationHistoryService.downloadJSON({
|
|
||||||
type: 'event_alert'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 1. localStorage 容量限制
|
|
||||||
|
|
||||||
- 大多数浏览器限制为 5-10MB
|
|
||||||
- 建议定期清理历史记录和监控数据
|
|
||||||
- 使用导出功能备份数据
|
|
||||||
|
|
||||||
### 2. 浏览器兼容性
|
|
||||||
|
|
||||||
- **桌面通知**: 需要 HTTPS 或 localhost
|
|
||||||
- **localStorage**: 所有现代浏览器支持
|
|
||||||
- **权限请求**: 需要用户交互(不能自动授权)
|
|
||||||
|
|
||||||
### 3. 隐私和数据安全
|
|
||||||
|
|
||||||
- 所有数据存储在本地(localStorage)
|
|
||||||
- 不会上传到服务器
|
|
||||||
- 用户可以随时清空数据
|
|
||||||
|
|
||||||
### 4. 性能影响
|
|
||||||
|
|
||||||
- 监控埋点非常轻量,几乎无性能影响
|
|
||||||
- 历史记录保存异步进行,不阻塞 UI
|
|
||||||
- 数据查询在客户端完成,不增加服务器负担
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 总结
|
|
||||||
|
|
||||||
### 已实现的功能
|
|
||||||
|
|
||||||
✅ **智能桌面通知**
|
|
||||||
- 首次重要通知时自动请求权限
|
|
||||||
- 智能分发策略(前台/后台)
|
|
||||||
- localStorage 持久化权限状态
|
|
||||||
|
|
||||||
✅ **性能监控**
|
|
||||||
- 到达率、点击率、响应时间追踪
|
|
||||||
- 按类型、优先级、时段统计
|
|
||||||
- 数据导出(JSON/CSV)
|
|
||||||
|
|
||||||
✅ **历史记录**
|
|
||||||
- 持久化存储(最多 500 条)
|
|
||||||
- 筛选、搜索、分页
|
|
||||||
- 已读/已点击标记
|
|
||||||
- 数据导出(JSON/CSV)
|
|
||||||
|
|
||||||
### 未实现的功能(备份,待上线)
|
|
||||||
|
|
||||||
⏸️ 历史记录页面 UI(代码已备份,随时可上线)
|
|
||||||
⏸️ 监控仪表板 UI(可选,暂未实现)
|
|
||||||
|
|
||||||
### 下一步建议
|
|
||||||
|
|
||||||
1. **用户设置页面**: 允许用户自定义通知偏好
|
|
||||||
2. **声音提示**: 为紧急通知添加音效
|
|
||||||
3. **数据同步**: 将历史和监控数据同步到服务器
|
|
||||||
4. **高级筛选**: 添加更多筛选维度(如关键词、股票代码等)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档版本**: v1.0
|
|
||||||
**最后更新**: 2025-01-21
|
|
||||||
**维护者**: Claude Code
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
# 环境配置指南
|
|
||||||
|
|
||||||
本文档详细说明项目的环境配置和启动方式。
|
|
||||||
|
|
||||||
## 📊 环境模式总览
|
|
||||||
|
|
||||||
| 模式 | 命令 | Mock | 后端位置 | PostHog | 适用场景 |
|
|
||||||
|------|------|------|---------|---------|---------|
|
|
||||||
| **本地混合** | `npm start` | ✅ 智能穿透 | 远程 | 可选双模式 | 日常前端开发(推荐) |
|
|
||||||
| **本地全栈** | `npm run start:test` | ❌ | 本地 | 可选双模式 | 后端调试、性能测试 |
|
|
||||||
| **远程开发** | `npm run start:dev` | ❌ | 远程 | 可选双模式 | 联调真实后端 |
|
|
||||||
| **纯 Mock** | `npm run start:mock` | ✅ 完全拦截 | 无 | 可选双模式 | 前端完全独立开发 |
|
|
||||||
| **生产构建** | `npm run build` | ❌ | 生产服务器 | ✅ 仅上报 | 部署上线 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1️⃣ 本地混合模式(推荐)
|
|
||||||
|
|
||||||
### 启动命令
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
# 或
|
|
||||||
npm run start:local
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
`.env.local`
|
|
||||||
|
|
||||||
### 特点
|
|
||||||
- 🎯 **MSW 智能拦截**:
|
|
||||||
- 已定义 Mock 的接口 → 返回 Mock 数据
|
|
||||||
- 未定义 Mock 的接口 → 自动转发到远程后端
|
|
||||||
- 💡 **最佳效率**:前端独立开发,部分依赖真实数据
|
|
||||||
- 🚀 **快速迭代**:无需等待后端,无需本地运行后端
|
|
||||||
- 🔄 **自动端口清理**:启动前自动清理 3000 端口
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
- ✅ 日常前端 UI 开发
|
|
||||||
- ✅ 页面布局调整
|
|
||||||
- ✅ 组件开发测试
|
|
||||||
- ✅ 样式优化
|
|
||||||
|
|
||||||
### 工作流程
|
|
||||||
```bash
|
|
||||||
# 1. 启动项目
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 2. 观察控制台
|
|
||||||
# ✅ MSW 启动成功
|
|
||||||
# ✅ PostHog 初始化
|
|
||||||
# ✅ 拦截日志显示
|
|
||||||
|
|
||||||
# 3. 开发测试
|
|
||||||
# - Mock 接口:立即返回假数据
|
|
||||||
# - 真实接口:请求远程后端
|
|
||||||
```
|
|
||||||
|
|
||||||
### PostHog 配置
|
|
||||||
编辑 `.env.local`:
|
|
||||||
```env
|
|
||||||
# 仅控制台 debug(初期开发)
|
|
||||||
REACT_APP_POSTHOG_KEY=
|
|
||||||
|
|
||||||
# 控制台 + PostHog Cloud(完整测试)
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_your_test_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2️⃣ 本地全栈模式
|
|
||||||
|
|
||||||
### 启动命令
|
|
||||||
```bash
|
|
||||||
npm run start:test
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
`.env.test`
|
|
||||||
|
|
||||||
### 特点
|
|
||||||
- 🖥️ **前后端都在本地**:
|
|
||||||
- 前端:localhost:3000
|
|
||||||
- 后端:localhost:5001
|
|
||||||
- 🗄️ **本地数据库**:数据隔离,不影响团队
|
|
||||||
- 🔍 **完整调试**:可以打断点调试后端代码
|
|
||||||
- 📊 **性能分析**:测试数据库查询、接口性能
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
- ✅ 调试后端 Python 代码
|
|
||||||
- ✅ 测试数据库查询优化
|
|
||||||
- ✅ 性能测试和压力测试
|
|
||||||
- ✅ 离线开发(无网络)
|
|
||||||
- ✅ 数据迁移脚本测试
|
|
||||||
|
|
||||||
### 工作流程
|
|
||||||
```bash
|
|
||||||
# 1. 启动全栈(自动启动前后端)
|
|
||||||
npm run start:test
|
|
||||||
|
|
||||||
# 观察日志:
|
|
||||||
# [backend] Flask 服务器启动在 5001 端口
|
|
||||||
# [frontend] React 启动在 3000 端口
|
|
||||||
|
|
||||||
# 2. 或手动分别启动
|
|
||||||
# 终端 1
|
|
||||||
python app_2.py
|
|
||||||
|
|
||||||
# 终端 2
|
|
||||||
npm run frontend:test
|
|
||||||
```
|
|
||||||
|
|
||||||
### 注意事项
|
|
||||||
- ⚠️ 确保本地安装了 Python 环境
|
|
||||||
- ⚠️ 确保安装了 requirements.txt 中的依赖
|
|
||||||
- ⚠️ 确保本地数据库已配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3️⃣ 远程开发模式
|
|
||||||
|
|
||||||
### 启动命令
|
|
||||||
```bash
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
`.env.development`
|
|
||||||
|
|
||||||
### 特点
|
|
||||||
- 🌐 **连接远程后端**:http://49.232.185.254:5001
|
|
||||||
- 📡 **真实数据**:远程开发数据库
|
|
||||||
- 🤝 **团队协作**:与后端团队联调
|
|
||||||
- ⚡ **无需本地后端**:专注前端开发
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
- ✅ 联调后端最新代码
|
|
||||||
- ✅ 测试真实数据表现
|
|
||||||
- ✅ 验证接口文档
|
|
||||||
- ✅ 跨服务功能测试
|
|
||||||
|
|
||||||
### 工作流程
|
|
||||||
```bash
|
|
||||||
# 1. 启动前端(连接远程后端)
|
|
||||||
npm run start:dev
|
|
||||||
|
|
||||||
# 2. 观察控制台
|
|
||||||
# ✅ 所有请求发送到远程服务器
|
|
||||||
# ✅ 无 MSW 拦截
|
|
||||||
|
|
||||||
# 3. 联调测试
|
|
||||||
# - 测试最新后端接口
|
|
||||||
# - 反馈问题给后端团队
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4️⃣ 纯 Mock 模式
|
|
||||||
|
|
||||||
### 启动命令
|
|
||||||
```bash
|
|
||||||
npm run start:mock
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
`.env.mock`
|
|
||||||
|
|
||||||
### 特点
|
|
||||||
- 📦 **完全 Mock**:所有请求都被 MSW 拦截
|
|
||||||
- ⚡ **完全离线**:无需任何后端服务
|
|
||||||
- 🎨 **纯前端**:专注 UI/UX 开发
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
- ✅ 后端接口未开发完成
|
|
||||||
- ✅ 完全独立的前端开发
|
|
||||||
- ✅ UI 原型展示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 PostHog 配置说明
|
|
||||||
|
|
||||||
### 双模式运行
|
|
||||||
|
|
||||||
PostHog 支持两种模式:
|
|
||||||
|
|
||||||
#### 模式 1:仅控制台 Debug(推荐初期)
|
|
||||||
```env
|
|
||||||
REACT_APP_POSTHOG_KEY= # 留空
|
|
||||||
```
|
|
||||||
|
|
||||||
**效果:**
|
|
||||||
- ✅ 控制台打印所有事件日志
|
|
||||||
- ✅ 验证事件触发逻辑
|
|
||||||
- ✅ 检查事件属性
|
|
||||||
- ❌ 不实际发送到 PostHog 服务器
|
|
||||||
|
|
||||||
**控制台输出示例:**
|
|
||||||
```javascript
|
|
||||||
✅ PostHog initialized successfully
|
|
||||||
📊 PostHog Analytics initialized
|
|
||||||
📍 Event tracked: community_page_viewed { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 模式 2:控制台 + PostHog Cloud(完整测试)
|
|
||||||
```env
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_your_test_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
**效果:**
|
|
||||||
- ✅ 控制台打印所有事件日志
|
|
||||||
- ✅ 同时发送到 PostHog Cloud
|
|
||||||
- ✅ 在 PostHog Dashboard 查看 Live Events
|
|
||||||
- ✅ 测试完整的分析功能
|
|
||||||
|
|
||||||
### 获取 PostHog API Key
|
|
||||||
|
|
||||||
1. 登录 PostHog:https://app.posthog.com
|
|
||||||
2. 创建项目(建议创建独立的测试项目)
|
|
||||||
3. 进入项目设置 → Project API Key
|
|
||||||
4. 复制 API Key(格式:`phc_xxxxxxxxxxxxxx`)
|
|
||||||
5. 填入对应环境的 `.env` 文件
|
|
||||||
|
|
||||||
### 推荐配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 本地开发(.env.local)
|
|
||||||
REACT_APP_POSTHOG_KEY= # 留空,仅控制台
|
|
||||||
|
|
||||||
# 测试环境(.env.test)
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_test_key # 测试项目 Key
|
|
||||||
|
|
||||||
# 开发环境(.env.development)
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_dev_key # 开发项目 Key
|
|
||||||
|
|
||||||
# 生产环境(.env)
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_prod_key # 生产项目 Key
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 端口管理
|
|
||||||
|
|
||||||
### 自动清理 3000 端口
|
|
||||||
|
|
||||||
所有 `npm start` 命令会自动执行 `prestart` 钩子,清理 3000 端口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 自动执行
|
|
||||||
npm start
|
|
||||||
# → 先执行 kill-port 3000
|
|
||||||
# → 再执行 craco start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手动清理端口
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run kill-port
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 环境变量文件说明
|
|
||||||
|
|
||||||
| 文件 | 提交Git | 用途 | 优先级 |
|
|
||||||
|------|--------|------|--------|
|
|
||||||
| `.env` | ✅ | 生产环境 | 低 |
|
|
||||||
| `.env.local` | ✅ | 本地混合模式 | 高 |
|
|
||||||
| `.env.test` | ✅ | 本地测试环境 | 高 |
|
|
||||||
| `.env.development` | ✅ | 远程开发环境 | 中 |
|
|
||||||
| `.env.mock` | ✅ | 纯 Mock 模式 | 中 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 常见问题
|
|
||||||
|
|
||||||
### Q1: 端口 3000 被占用
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
```bash
|
|
||||||
# 方案 1:自动清理(推荐)
|
|
||||||
npm start # 会自动清理
|
|
||||||
|
|
||||||
# 方案 2:手动清理
|
|
||||||
npm run kill-port
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q2: PostHog 事件没有上报
|
|
||||||
|
|
||||||
**检查清单:**
|
|
||||||
1. 检查 `REACT_APP_POSTHOG_KEY` 是否填写
|
|
||||||
2. 打开浏览器控制台,查看是否有初始化日志
|
|
||||||
3. 检查网络面板,是否有请求发送到 PostHog
|
|
||||||
4. 登录 PostHog Dashboard → Live Events 查看
|
|
||||||
|
|
||||||
### Q3: Mock 数据没有生效
|
|
||||||
|
|
||||||
**检查清单:**
|
|
||||||
1. 确认 `REACT_APP_ENABLE_MOCK=true`
|
|
||||||
2. 检查控制台是否显示 "MSW enabled"
|
|
||||||
3. 检查 `src/mocks/handlers/` 中是否定义了对应接口
|
|
||||||
4. 查看浏览器控制台的 MSW 拦截日志
|
|
||||||
|
|
||||||
### Q4: 本地全栈模式启动失败
|
|
||||||
|
|
||||||
**可能原因:**
|
|
||||||
1. Python 环境未安装
|
|
||||||
2. 后端依赖未安装:`pip install -r requirements.txt`
|
|
||||||
3. 数据库未配置
|
|
||||||
4. 端口 5001 被占用:`lsof -ti:5001 | xargs kill -9`
|
|
||||||
|
|
||||||
### Q5: 环境变量不生效
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
1. 重启开发服务器(React 不会热更新环境变量)
|
|
||||||
2. 检查环境变量名称是否以 `REACT_APP_` 开头
|
|
||||||
3. 确认使用了正确的 `.env` 文件
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 新成员入职
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 克隆项目
|
|
||||||
git clone <repository>
|
|
||||||
cd vf_react
|
|
||||||
|
|
||||||
# 2. 安装依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 3. 启动项目(默认本地混合模式)
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 4. 浏览器访问
|
|
||||||
# http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 日常开发流程
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 早上启动
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 开发中...
|
|
||||||
# - 修改代码
|
|
||||||
# - 热更新自动生效
|
|
||||||
# - 查看控制台日志
|
|
||||||
|
|
||||||
# 需要调试后端时
|
|
||||||
npm run start:test
|
|
||||||
|
|
||||||
# 需要联调时
|
|
||||||
npm run start:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关文档
|
|
||||||
|
|
||||||
- [PostHog 集成文档](./POSTHOG_INTEGRATION.md)
|
|
||||||
- [PostHog 事件追踪文档](./POSTHOG_EVENT_TRACKING.md)
|
|
||||||
- [项目配置说明](./CLAUDE.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 团队协作建议
|
|
||||||
|
|
||||||
1. **统一环境**:团队成员使用相同的启动命令
|
|
||||||
2. **独立测试**:测试新功能时使用 `start:test` 隔离数据
|
|
||||||
3. **及时反馈**:发现接口问题及时在群里反馈
|
|
||||||
4. **代码审查**:提交前检查是否误提交 API Key
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**最后更新:** 2025-01-15
|
|
||||||
**维护者:** 前端团队
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
# MCP 架构说明
|
|
||||||
|
|
||||||
## 🎯 MCP 是什么?
|
|
||||||
|
|
||||||
**MCP (Model Context Protocol)** 是一个**工具调用协议**,它的核心职责是:
|
|
||||||
|
|
||||||
1. ✅ **定义工具接口**:告诉 LLM 有哪些工具可用,每个工具需要什么参数
|
|
||||||
2. ✅ **执行工具调用**:根据请求调用对应的后端 API
|
|
||||||
3. ✅ **返回结构化数据**:将 API 结果返回给调用方
|
|
||||||
|
|
||||||
**MCP 不负责**:
|
|
||||||
- ❌ 自然语言理解(NLU)
|
|
||||||
- ❌ 意图识别
|
|
||||||
- ❌ 结果总结
|
|
||||||
- ❌ 对话管理
|
|
||||||
|
|
||||||
## 📊 当前架构
|
|
||||||
|
|
||||||
### 方案 1:简单关键词匹配(已实现)
|
|
||||||
|
|
||||||
```
|
|
||||||
用户输入:"查询贵州茅台的股票信息"
|
|
||||||
↓
|
|
||||||
前端 ChatInterface (关键词匹配)
|
|
||||||
↓
|
|
||||||
MCP 工具层 (search_china_news)
|
|
||||||
↓
|
|
||||||
返回 JSON 数据
|
|
||||||
↓
|
|
||||||
前端显示原始数据
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- ✗ 只能识别简单关键词
|
|
||||||
- ✗ 无法理解复杂意图
|
|
||||||
- ✗ 返回的是原始 JSON,用户体验差
|
|
||||||
|
|
||||||
### 方案 2:集成 LLM(推荐)
|
|
||||||
|
|
||||||
```
|
|
||||||
用户输入:"查询贵州茅台的股票信息"
|
|
||||||
↓
|
|
||||||
LLM (Claude/GPT-4/通义千问)
|
|
||||||
↓ 理解意图:需要查询股票代码 600519 的基本信息
|
|
||||||
↓ 选择工具:get_stock_basic_info
|
|
||||||
↓ 提取参数:{"seccode": "600519"}
|
|
||||||
MCP 工具层
|
|
||||||
↓ 调用 API,获取数据
|
|
||||||
返回结构化数据
|
|
||||||
↓
|
|
||||||
LLM 总结结果
|
|
||||||
↓ "贵州茅台(600519)是中国知名的白酒生产企业,
|
|
||||||
当前股价 1650.00 元,市值 2.07 万亿..."
|
|
||||||
前端显示自然语言回复
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✓ 理解复杂意图
|
|
||||||
- ✓ 自动选择合适的工具
|
|
||||||
- ✓ 自然语言总结,用户体验好
|
|
||||||
- ✓ 支持多轮对话
|
|
||||||
|
|
||||||
## 🔧 实现方案
|
|
||||||
|
|
||||||
### 选项 A:前端集成 LLM(快速实现)
|
|
||||||
|
|
||||||
**适用场景**:快速原型、小规模应用
|
|
||||||
|
|
||||||
**优点**:
|
|
||||||
- 实现简单
|
|
||||||
- 无需修改后端
|
|
||||||
|
|
||||||
**缺点**:
|
|
||||||
- API Key 暴露在前端(安全风险)
|
|
||||||
- 每个用户都消耗 API 额度
|
|
||||||
- 无法统一管理和监控
|
|
||||||
|
|
||||||
**实现步骤**:
|
|
||||||
|
|
||||||
1. 修改 `src/components/ChatBot/ChatInterface.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { llmService } from '../../services/llmService';
|
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
// ...
|
|
||||||
|
|
||||||
// 使用 LLM 服务替代简单的 mcpService.chat
|
|
||||||
const response = await llmService.chat(inputValue, messages);
|
|
||||||
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 配置 API Key(在 `.env.local`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REACT_APP_OPENAI_API_KEY=sk-xxx...
|
|
||||||
# 或者使用通义千问(更便宜)
|
|
||||||
REACT_APP_DASHSCOPE_API_KEY=sk-xxx...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 选项 B:后端集成 LLM(生产推荐)⭐
|
|
||||||
|
|
||||||
**适用场景**:生产环境、需要安全和性能
|
|
||||||
|
|
||||||
**优点**:
|
|
||||||
- ✓ API Key 安全(不暴露给前端)
|
|
||||||
- ✓ 统一管理和监控
|
|
||||||
- ✓ 可以做缓存优化
|
|
||||||
- ✓ 可以做速率限制
|
|
||||||
|
|
||||||
**缺点**:
|
|
||||||
- 需要修改后端
|
|
||||||
- 增加服务器成本
|
|
||||||
|
|
||||||
**实现步骤**:
|
|
||||||
|
|
||||||
#### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install openai
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 修改 `mcp_server.py`,添加聊天端点
|
|
||||||
|
|
||||||
在文件末尾添加:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
|
|
||||||
|
|
||||||
# 创建聊天助手实例
|
|
||||||
chat_assistant = MCPChatAssistant(provider="qwen") # 推荐使用通义千问
|
|
||||||
|
|
||||||
@app.post("/chat", response_model=ChatResponse)
|
|
||||||
async def chat_endpoint(request: ChatRequest):
|
|
||||||
"""智能对话端点 - 使用LLM理解意图并调用工具"""
|
|
||||||
logger.info(f"Chat request: {request.message}")
|
|
||||||
|
|
||||||
# 获取可用工具列表
|
|
||||||
tools = [tool.dict() for tool in TOOLS]
|
|
||||||
|
|
||||||
# 调用聊天助手
|
|
||||||
response = await chat_assistant.chat(
|
|
||||||
user_message=request.message,
|
|
||||||
conversation_history=request.conversation_history,
|
|
||||||
tools=tools,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 配置环境变量
|
|
||||||
|
|
||||||
在服务器上设置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式1:使用通义千问(推荐,价格便宜)
|
|
||||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
|
||||||
|
|
||||||
# 方式2:使用 OpenAI
|
|
||||||
export OPENAI_API_KEY="sk-xxx..."
|
|
||||||
|
|
||||||
# 方式3:使用 DeepSeek(最便宜)
|
|
||||||
export DEEPSEEK_API_KEY="sk-xxx..."
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 修改前端 `mcpService.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
/**
|
|
||||||
* 智能对话 - 使用后端LLM处理
|
|
||||||
*/
|
|
||||||
async chat(userMessage, conversationHistory = []) {
|
|
||||||
try {
|
|
||||||
const response = await this.client.post('/chat', {
|
|
||||||
message: userMessage,
|
|
||||||
conversation_history: conversationHistory,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message || '对话处理失败',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. 修改前端 `ChatInterface.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
// ...
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 调用后端聊天API
|
|
||||||
const response = await mcpService.chat(inputValue, messages);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
const botMessage = {
|
|
||||||
id: Date.now() + 1,
|
|
||||||
content: response.data.message, // LLM总结的自然语言
|
|
||||||
isUser: false,
|
|
||||||
type: 'text',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
toolUsed: response.data.tool_used, // 可选:显示使用了哪个工具
|
|
||||||
rawData: response.data.raw_data, // 可选:原始数据(折叠显示)
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, botMessage]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💰 LLM 选择和成本
|
|
||||||
|
|
||||||
### 推荐:通义千问(阿里云)
|
|
||||||
|
|
||||||
**优点**:
|
|
||||||
- 价格便宜(1000次对话约 ¥1-2)
|
|
||||||
- 中文理解能力强
|
|
||||||
- 国内访问稳定
|
|
||||||
|
|
||||||
**价格**:
|
|
||||||
- qwen-plus: ¥0.004/1000 tokens(约 ¥0.001/次对话)
|
|
||||||
- qwen-turbo: ¥0.002/1000 tokens(更便宜)
|
|
||||||
|
|
||||||
**获取 API Key**:
|
|
||||||
1. 访问 https://dashscope.console.aliyun.com/
|
|
||||||
2. 创建 API Key
|
|
||||||
3. 设置环境变量 `DASHSCOPE_API_KEY`
|
|
||||||
|
|
||||||
### 其他选择
|
|
||||||
|
|
||||||
| 提供商 | 模型 | 价格 | 优点 | 缺点 |
|
|
||||||
|--------|------|------|------|------|
|
|
||||||
| **通义千问** | qwen-plus | ¥0.001/次 | 便宜、中文好 | - |
|
|
||||||
| **DeepSeek** | deepseek-chat | ¥0.0005/次 | 最便宜 | 新公司 |
|
|
||||||
| **OpenAI** | gpt-4o-mini | $0.15/1M tokens | 能力强 | 贵、需翻墙 |
|
|
||||||
| **Claude** | claude-3-haiku | $0.25/1M tokens | 理解力强 | 贵、需翻墙 |
|
|
||||||
|
|
||||||
## 🚀 部署步骤
|
|
||||||
|
|
||||||
### 1. 后端部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装依赖
|
|
||||||
pip install openai
|
|
||||||
|
|
||||||
# 设置 API Key
|
|
||||||
export DASHSCOPE_API_KEY="sk-xxx..."
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
sudo systemctl restart mcp-server
|
|
||||||
|
|
||||||
# 测试聊天端点
|
|
||||||
curl -X POST https://valuefrontier.cn/mcp/chat \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"message": "查询贵州茅台的股票信息"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 前端部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 部署
|
|
||||||
scp -r build/* user@server:/var/www/valuefrontier.cn/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 验证
|
|
||||||
|
|
||||||
访问 https://valuefrontier.cn/agent-chat,测试对话:
|
|
||||||
|
|
||||||
**测试用例**:
|
|
||||||
1. "查询贵州茅台的股票信息" → 应返回自然语言总结
|
|
||||||
2. "今日涨停的股票有哪些" → 应返回涨停股票列表并总结
|
|
||||||
3. "新能源概念板块表现如何" → 应搜索概念并分析
|
|
||||||
|
|
||||||
## 📊 对比总结
|
|
||||||
|
|
||||||
| 特性 | 简单匹配 | 前端LLM | 后端LLM ⭐ |
|
|
||||||
|------|---------|---------|-----------|
|
|
||||||
| 实现难度 | 简单 | 中等 | 中等 |
|
|
||||||
| 用户体验 | 差 | 好 | 好 |
|
|
||||||
| 安全性 | 高 | 低 | 高 |
|
|
||||||
| 成本 | 无 | 用户承担 | 服务器承担 |
|
|
||||||
| 可维护性 | 差 | 中 | 好 |
|
|
||||||
| **推荐指数** | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
|
||||||
|
|
||||||
## 🎯 最终推荐
|
|
||||||
|
|
||||||
**生产环境:后端集成 LLM (方案 B)**
|
|
||||||
- 使用通义千问(qwen-plus)
|
|
||||||
- 成本低(约 ¥50/月,10000次对话)
|
|
||||||
- 安全可靠
|
|
||||||
|
|
||||||
**快速原型:前端集成 LLM (方案 A)**
|
|
||||||
- 适合演示
|
|
||||||
- 快速验证可行性
|
|
||||||
- 后续再迁移到后端
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
# 消息推送系统整合 - 测试指南
|
|
||||||
|
|
||||||
## 📋 整合完成清单
|
|
||||||
|
|
||||||
✅ **统一事件名称**
|
|
||||||
- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
|
|
||||||
- 移除了 `trade_notification` 事件名
|
|
||||||
|
|
||||||
✅ **数据适配器**
|
|
||||||
- 创建了 `adaptEventToNotification` 函数
|
|
||||||
- 自动识别后端事件格式并转换为前端通知格式
|
|
||||||
- 重要性映射:S → urgent, A → important, B/C → normal
|
|
||||||
|
|
||||||
✅ **NotificationContext 升级**
|
|
||||||
- 监听 `new_event` 事件
|
|
||||||
- 自动使用适配器转换事件数据
|
|
||||||
- 支持 Mock 和 Real 模式无缝切换
|
|
||||||
|
|
||||||
✅ **EventList 实时推送**
|
|
||||||
- 集成 `useEventNotifications` Hook
|
|
||||||
- 实时更新事件列表
|
|
||||||
- Toast 通知提示
|
|
||||||
- WebSocket 连接状态指示器
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 测试步骤
|
|
||||||
|
|
||||||
### 1. 测试 Mock 模式(开发环境)
|
|
||||||
|
|
||||||
#### 1.1 配置环境变量
|
|
||||||
确保 `.env` 文件包含以下配置:
|
|
||||||
```bash
|
|
||||||
REACT_APP_USE_MOCK_SOCKET=true
|
|
||||||
# 或者
|
|
||||||
REACT_APP_ENABLE_MOCK=true
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 启动应用
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.3 验证功能
|
|
||||||
|
|
||||||
**a) 右下角通知卡片**
|
|
||||||
- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
|
|
||||||
- 每隔 60 秒会自动推送 1-2 条模拟消息
|
|
||||||
- 通知类型包括:
|
|
||||||
- 📢 公告通知(蓝色)
|
|
||||||
- 📈 股票动向(红/绿色,根据涨跌)
|
|
||||||
- 📰 事件动向(橙色)
|
|
||||||
- 📊 分析报告(紫色)
|
|
||||||
|
|
||||||
**b) 事件列表页面**
|
|
||||||
- 访问事件列表页面(Community/Events)
|
|
||||||
- 顶部应显示 "🟢 实时推送已开启"
|
|
||||||
- 收到新事件时:
|
|
||||||
- 右上角显示 Toast 通知
|
|
||||||
- 事件自动添加到列表顶部
|
|
||||||
- 无重复添加
|
|
||||||
|
|
||||||
**c) 控制台日志**
|
|
||||||
打开浏览器控制台,应该看到:
|
|
||||||
```
|
|
||||||
[Socket Service] Using MOCK Socket Service
|
|
||||||
NotificationContext: Socket connected
|
|
||||||
EventList: 收到新事件推送
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 测试 Real 模式(生产环境)
|
|
||||||
|
|
||||||
#### 2.1 配置环境变量
|
|
||||||
修改 `.env` 文件:
|
|
||||||
```bash
|
|
||||||
REACT_APP_USE_MOCK_SOCKET=false
|
|
||||||
# 或删除该配置项
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 启动后端 Flask 服务
|
|
||||||
```bash
|
|
||||||
python app_2.py
|
|
||||||
```
|
|
||||||
|
|
||||||
确保后端已启动 Socket.IO 服务并监听事件推送。
|
|
||||||
|
|
||||||
#### 2.3 启动前端应用
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.4 创建测试事件(后端)
|
|
||||||
使用后端提供的测试脚本:
|
|
||||||
```bash
|
|
||||||
python test_create_event.py
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.5 验证功能
|
|
||||||
|
|
||||||
**a) WebSocket 连接**
|
|
||||||
- 检查控制台:`[Socket Service] Using REAL Socket Service`
|
|
||||||
- 事件列表顶部显示 "🟢 实时推送已开启"
|
|
||||||
|
|
||||||
**b) 事件推送流程**
|
|
||||||
1. 运行 `test_create_event.py` 创建新事件
|
|
||||||
2. 后端轮询检测到新事件(最多等待 30 秒)
|
|
||||||
3. 后端通过 Socket.IO 推送 `new_event`
|
|
||||||
4. 前端接收事件并转换格式
|
|
||||||
5. 同时显示:
|
|
||||||
- 右下角通知卡片
|
|
||||||
- 事件列表 Toast 提示
|
|
||||||
- 事件添加到列表顶部
|
|
||||||
|
|
||||||
**c) 数据格式验证**
|
|
||||||
在控制台查看事件对象,应包含:
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
id: 123,
|
|
||||||
type: "event_alert", // 适配器转换后
|
|
||||||
priority: "urgent", // importance: S → urgent
|
|
||||||
title: "事件标题",
|
|
||||||
content: "事件描述",
|
|
||||||
clickable: true,
|
|
||||||
link: "/event-detail/123",
|
|
||||||
extra: {
|
|
||||||
eventType: "tech",
|
|
||||||
importance: "S",
|
|
||||||
// ... 更多后端字段
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 验证清单
|
|
||||||
|
|
||||||
### 功能验证
|
|
||||||
|
|
||||||
- [ ] Mock 模式下收到模拟通知
|
|
||||||
- [ ] Real 模式下收到真实后端推送
|
|
||||||
- [ ] 通知卡片正确显示(类型、颜色、内容)
|
|
||||||
- [ ] 事件列表实时更新
|
|
||||||
- [ ] Toast 通知正常弹出
|
|
||||||
- [ ] 连接状态指示器正确显示
|
|
||||||
- [ ] 点击通知可跳转到详情页
|
|
||||||
- [ ] 无重复事件添加
|
|
||||||
|
|
||||||
### 数据验证
|
|
||||||
|
|
||||||
- [ ] 后端事件格式正确转换
|
|
||||||
- [ ] 重要性映射正确(S/A/B/C → urgent/important/normal)
|
|
||||||
- [ ] 时间戳正确显示
|
|
||||||
- [ ] 链接路径正确生成
|
|
||||||
- [ ] 所有字段完整保留在 extra 中
|
|
||||||
|
|
||||||
### 性能验证
|
|
||||||
|
|
||||||
- [ ] 事件列表最多保留 100 条
|
|
||||||
- [ ] 通知自动关闭(紧急=不关闭,重要=30s,普通=15s)
|
|
||||||
- [ ] WebSocket 自动重连
|
|
||||||
- [ ] 无内存泄漏
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 常见问题排查
|
|
||||||
|
|
||||||
### Q1: Mock 模式下没有收到通知?
|
|
||||||
**A:** 检查:
|
|
||||||
1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
|
|
||||||
2. 控制台是否显示 "Using MOCK Socket Service"
|
|
||||||
3. 是否等待了 3 秒(首次通知延迟)
|
|
||||||
|
|
||||||
### Q2: Real 模式下无法连接?
|
|
||||||
**A:** 检查:
|
|
||||||
1. Flask 后端是否启动:`python app_2.py`
|
|
||||||
2. API_BASE_URL 是否正确配置
|
|
||||||
3. CORS 设置是否包含前端域名
|
|
||||||
4. 控制台是否有连接错误
|
|
||||||
|
|
||||||
### Q3: 收到重复通知?
|
|
||||||
**A:** 检查:
|
|
||||||
1. 是否多次渲染了 EventList 组件
|
|
||||||
2. 是否在多个地方调用了 `useEventNotifications`
|
|
||||||
3. 控制台日志中是否有 "事件已存在,跳过添加"
|
|
||||||
|
|
||||||
### Q4: 通知卡片样式异常?
|
|
||||||
**A:** 检查:
|
|
||||||
1. 事件的 `type` 字段是否正确
|
|
||||||
2. 是否缺少必要的字段(title, content)
|
|
||||||
3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
|
|
||||||
|
|
||||||
### Q5: 事件列表不更新?
|
|
||||||
**A:** 检查:
|
|
||||||
1. WebSocket 连接状态(顶部 Badge)
|
|
||||||
2. `onNewEvent` 回调是否触发(控制台日志)
|
|
||||||
3. `setLocalEvents` 是否正确执行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 测试数据示例
|
|
||||||
|
|
||||||
### Mock 模拟数据类型
|
|
||||||
|
|
||||||
**公告通知**
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
type: "announcement",
|
|
||||||
priority: "urgent",
|
|
||||||
title: "贵州茅台发布2024年度财报公告",
|
|
||||||
content: "2024年度营收同比增长15.2%..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**股票动向**
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
type: "stock_alert",
|
|
||||||
priority: "urgent",
|
|
||||||
title: "您关注的股票触发预警",
|
|
||||||
extra: {
|
|
||||||
stockCode: "300750",
|
|
||||||
priceChange: "+5.2%"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**事件动向**
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
type: "event_alert",
|
|
||||||
priority: "important",
|
|
||||||
title: "央行宣布降准0.5个百分点",
|
|
||||||
extra: {
|
|
||||||
eventId: "evt001",
|
|
||||||
sectors: ["银行", "地产", "基建"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**分析报告**
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
type: "analysis_report",
|
|
||||||
priority: "important",
|
|
||||||
title: "医药行业深度报告:创新药迎来政策拐点",
|
|
||||||
author: {
|
|
||||||
name: "李明",
|
|
||||||
organization: "中信证券"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 真实后端事件格式
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
id: 123,
|
|
||||||
title: "新能源汽车补贴政策延期",
|
|
||||||
description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
|
|
||||||
event_type: "policy",
|
|
||||||
importance: "S",
|
|
||||||
status: "active",
|
|
||||||
created_at: "2025-01-21T14:30:00",
|
|
||||||
hot_score: 95.5,
|
|
||||||
view_count: 1234,
|
|
||||||
related_avg_chg: 5.2,
|
|
||||||
related_max_chg: 15.8,
|
|
||||||
keywords: ["新能源", "补贴", "政策"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 下一步建议
|
|
||||||
|
|
||||||
### 1. 用户设置
|
|
||||||
允许用户控制通知偏好:
|
|
||||||
```jsx
|
|
||||||
<Switch
|
|
||||||
isChecked={enableNotifications}
|
|
||||||
onChange={handleToggle}
|
|
||||||
>
|
|
||||||
启用实时通知
|
|
||||||
</Switch>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 通知过滤
|
|
||||||
按重要性、类型过滤通知:
|
|
||||||
```javascript
|
|
||||||
useEventNotifications({
|
|
||||||
eventType: 'tech', // 只订阅科技类
|
|
||||||
importance: 'S', // 只订阅 S 级
|
|
||||||
enabled: true
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 声音提示
|
|
||||||
添加音效提醒:
|
|
||||||
```javascript
|
|
||||||
onNewEvent: (event) => {
|
|
||||||
if (event.priority === 'urgent') {
|
|
||||||
new Audio('/alert.mp3').play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 桌面通知
|
|
||||||
利用浏览器通知 API:
|
|
||||||
```javascript
|
|
||||||
if (Notification.permission === 'granted') {
|
|
||||||
new Notification(event.title, {
|
|
||||||
body: event.content,
|
|
||||||
icon: '/logo.png'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 技术说明
|
|
||||||
|
|
||||||
### 架构优势
|
|
||||||
|
|
||||||
1. **统一接口**:Mock 和 Real 完全相同的 API
|
|
||||||
2. **自动适配**:智能识别数据格式并转换
|
|
||||||
3. **解耦设计**:通知系统和事件列表独立工作
|
|
||||||
4. **向后兼容**:不影响现有功能
|
|
||||||
|
|
||||||
### 关键文件
|
|
||||||
|
|
||||||
- `src/services/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 模式
|
|
||||||
|
|
||||||
**祝测试顺利!🎉**
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
# Mock API 接口文档
|
|
||||||
|
|
||||||
本文档说明 Community 页面(`/community`)加载时请求的所有 Mock API 接口。
|
|
||||||
|
|
||||||
## 📊 接口总览
|
|
||||||
|
|
||||||
Community 页面加载时会并发请求以下接口:
|
|
||||||
|
|
||||||
| 序号 | 接口路径 | 调用时机 | 用途 | Mock 状态 |
|
|
||||||
|------|---------|---------|------|-----------|
|
|
||||||
| 1 | `/concept-api/search` | PopularKeywords 组件挂载 | 获取热门概念 | ✅ 已实现 |
|
|
||||||
| 2 | `/api/events/` | Community 组件挂载 | 获取事件列表 | ✅ 已实现 |
|
|
||||||
| 3-8 | `/api/index/{code}/kline` (6个) | MidjourneyHeroSection 组件挂载 | 获取三大指数K线数据 | ✅ 已实现 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 概念搜索接口
|
|
||||||
|
|
||||||
### `/concept-api/search`
|
|
||||||
|
|
||||||
**请求方式**: `POST`
|
|
||||||
|
|
||||||
**调用位置**: `src/views/Community/components/PopularKeywords.js:25`
|
|
||||||
|
|
||||||
**调用时机**: PopularKeywords 组件挂载时(`useEffect`, 空依赖数组)
|
|
||||||
|
|
||||||
**请求参数**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query": "", // 空字符串表示获取所有概念
|
|
||||||
"size": 20, // 获取数量
|
|
||||||
"page": 1, // 页码
|
|
||||||
"sort_by": "change_pct" // 排序方式:按涨跌幅排序
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应数据**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"concept": "人工智能",
|
|
||||||
"concept_id": "CONCEPT_1000",
|
|
||||||
"stock_count": 45,
|
|
||||||
"price_info": {
|
|
||||||
"avg_change_pct": 5.23,
|
|
||||||
"avg_price": "45.67",
|
|
||||||
"total_market_cap": "567.89"
|
|
||||||
},
|
|
||||||
"description": "人工智能相关概念股",
|
|
||||||
"hot_score": 89
|
|
||||||
}
|
|
||||||
// ... 更多概念数据
|
|
||||||
],
|
|
||||||
"total": 20,
|
|
||||||
"page": 1,
|
|
||||||
"size": 20,
|
|
||||||
"message": "搜索成功"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mock Handler**: `src/mocks/handlers/concept.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 事件列表接口
|
|
||||||
|
|
||||||
### `/api/events/`
|
|
||||||
|
|
||||||
**请求方式**: `GET`
|
|
||||||
|
|
||||||
**调用位置**: `src/views/Community/index.js:147` → `eventService.getEvents()`
|
|
||||||
|
|
||||||
**调用时机**: Community 页面加载时,由 `loadEvents()` 函数调用
|
|
||||||
|
|
||||||
**请求参数** (Query Parameters):
|
|
||||||
- `page`: 页码(默认: 1)
|
|
||||||
- `per_page`: 每页数量(默认: 10)
|
|
||||||
- `sort`: 排序方式(默认: "new")
|
|
||||||
- `importance`: 重要性(默认: "all")
|
|
||||||
- `search_type`: 搜索类型(默认: "topic")
|
|
||||||
- `q`: 搜索关键词(可选)
|
|
||||||
- `industry_code`: 行业代码(可选)
|
|
||||||
- `industry_classification`: 行业分类(可选)
|
|
||||||
|
|
||||||
**示例请求**:
|
|
||||||
```
|
|
||||||
GET /api/events/?sort=new&importance=all&search_type=topic&page=1&per_page=10
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应数据**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"event_id": "evt_001",
|
|
||||||
"title": "某公司发布新产品",
|
|
||||||
"content": "详细内容...",
|
|
||||||
"importance": "S",
|
|
||||||
"created_at": "2024-10-26T10:30:00Z",
|
|
||||||
"related_stocks": ["600519", "000858"]
|
|
||||||
}
|
|
||||||
// ... 更多事件
|
|
||||||
],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"per_page": 10,
|
|
||||||
"total": 100,
|
|
||||||
"total_pages": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"message": "获取成功"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mock Handler**: `src/mocks/handlers/event.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 指数K线数据接口
|
|
||||||
|
|
||||||
### `/api/index/:indexCode/kline`
|
|
||||||
|
|
||||||
**请求方式**: `GET`
|
|
||||||
|
|
||||||
**调用位置**: `src/views/Community/components/MidjourneyHeroSection.js:315-323`
|
|
||||||
|
|
||||||
**调用时机**: MidjourneyHeroSection 组件挂载时(`useEffect`, 空依赖数组)
|
|
||||||
|
|
||||||
### 3.1 分时数据 (timeline)
|
|
||||||
|
|
||||||
用于展示当日分钟级别的价格走势图。
|
|
||||||
|
|
||||||
**请求参数** (Query Parameters):
|
|
||||||
- `type`: "timeline"
|
|
||||||
- `event_time`: 可选,事件时间
|
|
||||||
|
|
||||||
**六个并发请求**:
|
|
||||||
1. `GET /api/index/000001.SH/kline?type=timeline` - 上证指数分时
|
|
||||||
2. `GET /api/index/399001.SZ/kline?type=timeline` - 深证成指分时
|
|
||||||
3. `GET /api/index/399006.SZ/kline?type=timeline` - 创业板指分时
|
|
||||||
4. `GET /api/index/000001.SH/kline?type=daily` - 上证指数日线
|
|
||||||
5. `GET /api/index/399001.SZ/kline?type=daily` - 深证成指日线
|
|
||||||
6. `GET /api/index/399006.SZ/kline?type=daily` - 创业板指日线
|
|
||||||
|
|
||||||
**timeline 响应数据**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"time": "09:30",
|
|
||||||
"price": 3215.67,
|
|
||||||
"close": 3215.67,
|
|
||||||
"volume": 235678900,
|
|
||||||
"prev_close": 3200.00
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"time": "09:31",
|
|
||||||
"price": 3216.23,
|
|
||||||
"close": 3216.23,
|
|
||||||
"volume": 245789000,
|
|
||||||
"prev_close": 3200.00
|
|
||||||
}
|
|
||||||
// ... 每分钟一条数据,从 09:30 到 15:00
|
|
||||||
],
|
|
||||||
"index_code": "000001.SH",
|
|
||||||
"type": "timeline",
|
|
||||||
"message": "获取成功"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 日线数据 (daily)
|
|
||||||
|
|
||||||
用于获取历史收盘价,计算涨跌幅百分比。
|
|
||||||
|
|
||||||
**daily 响应数据**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"date": "2024-10-01",
|
|
||||||
"time": "2024-10-01",
|
|
||||||
"open": 3198.45,
|
|
||||||
"close": 3205.67,
|
|
||||||
"high": 3212.34,
|
|
||||||
"low": 3195.12,
|
|
||||||
"volume": 45678900000,
|
|
||||||
"prev_close": 3195.23
|
|
||||||
}
|
|
||||||
// ... 最近30个交易日的数据
|
|
||||||
],
|
|
||||||
"index_code": "000001.SH",
|
|
||||||
"type": "daily",
|
|
||||||
"message": "获取成功"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mock Handler**: `src/mocks/handlers/stock.js`
|
|
||||||
**数据生成函数**: `src/mocks/data/kline.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 重复请求问题分析
|
|
||||||
|
|
||||||
### 问题原因
|
|
||||||
|
|
||||||
1. **PopularKeywords 组件重复渲染**
|
|
||||||
- `UnifiedSearchBox` 内部包含 `<PopularKeywords />` (line 276)
|
|
||||||
- `PopularKeywords` 组件自己会在 `useEffect` 中发起 `/concept-api/search` 请求
|
|
||||||
- Community 页面同时还通过 Redux `fetchPopularKeywords()` 获取数据(但未使用)
|
|
||||||
|
|
||||||
2. **React Strict Mode**
|
|
||||||
- 开发环境下,React 18 的 Strict Mode 会故意双倍调用 useEffect
|
|
||||||
- 这会导致所有组件挂载时的请求被执行两次
|
|
||||||
- 生产环境不受影响
|
|
||||||
|
|
||||||
3. **MidjourneyHeroSection 的 6 个K线请求**
|
|
||||||
- 这是设计行为,一次性并发请求 6 个接口
|
|
||||||
- 3 个分时数据 + 3 个日线数据
|
|
||||||
- 用于展示三大指数的实时行情图表
|
|
||||||
|
|
||||||
### 解决方案
|
|
||||||
|
|
||||||
**方案 1**: 移除冗余的数据获取
|
|
||||||
```javascript
|
|
||||||
// Community/index.js 中移除未使用的 fetchPopularKeywords
|
|
||||||
// 删除或注释掉 line 256
|
|
||||||
// dispatch(fetchPopularKeywords());
|
|
||||||
```
|
|
||||||
|
|
||||||
**方案 2**: 使用缓存机制
|
|
||||||
- 在 `PopularKeywords` 组件中添加数据缓存
|
|
||||||
- 短时间内(如 5 分钟)重复请求直接返回缓存数据
|
|
||||||
|
|
||||||
**方案 3**: 提升数据到父组件
|
|
||||||
- 在 Community 页面统一管理数据获取
|
|
||||||
- 通过 props 传递给 `PopularKeywords` 组件
|
|
||||||
- `PopularKeywords` 不再自己发起请求
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 其他接口
|
|
||||||
|
|
||||||
### `/api/conversations`
|
|
||||||
**状态**: ❌ 未在前端代码中找到
|
|
||||||
**可能来源**: 浏览器插件、其他应用、或外部系统
|
|
||||||
|
|
||||||
### `/api/parameters`
|
|
||||||
**状态**: ❌ 未在前端代码中找到
|
|
||||||
**可能来源**: 浏览器插件、其他应用、或外部系统
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Mock 服务启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动 Mock 开发服务器
|
|
||||||
npm run start:mock
|
|
||||||
```
|
|
||||||
|
|
||||||
Mock 服务使用 [MSW (Mock Service Worker)](https://mswjs.io/) 实现,会拦截所有匹配的 API 请求并返回模拟数据。
|
|
||||||
|
|
||||||
### Mock 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/mocks/
|
|
||||||
├── handlers/
|
|
||||||
│ ├── index.js # 汇总所有 handlers
|
|
||||||
│ ├── concept.js # 概念相关接口
|
|
||||||
│ ├── event.js # 事件相关接口
|
|
||||||
│ └── stock.js # 股票/指数K线接口
|
|
||||||
├── data/
|
|
||||||
│ ├── kline.js # K线数据生成函数
|
|
||||||
│ ├── events.js # 事件数据
|
|
||||||
│ └── industries.js # 行业数据
|
|
||||||
└── browser.js # MSW 浏览器配置
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 调试建议
|
|
||||||
|
|
||||||
### 1. 查看 Mock 请求日志
|
|
||||||
|
|
||||||
打开浏览器控制台,所有 Mock 请求都会输出日志:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Mock Concept] 搜索概念: {query: "", size: 20, page: 1, sort_by: "change_pct"}
|
|
||||||
[Mock Stock] 获取指数K线数据: {indexCode: "000001.SH", type: "timeline", eventTime: null}
|
|
||||||
[Mock] 获取事件列表: {page: 1, per_page: 10, sort: "new", ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 检查网络请求
|
|
||||||
|
|
||||||
在浏览器 Network 面板中:
|
|
||||||
- 筛选 XHR/Fetch 请求
|
|
||||||
- 查看请求的 URL、参数、响应数据
|
|
||||||
- Mock 请求的响应时间会比真实 API 更快(200-500ms)
|
|
||||||
|
|
||||||
### 3. 验证数据格式
|
|
||||||
|
|
||||||
确保 Mock 数据格式与前端期望的格式一致:
|
|
||||||
- 检查字段名称(如 `concept` vs `name`)
|
|
||||||
- 检查数据类型(字符串 vs 数字)
|
|
||||||
- 检查嵌套结构(如 `price_info.avg_change_pct`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关文档
|
|
||||||
|
|
||||||
- [MSW 官方文档](https://mswjs.io/)
|
|
||||||
- [React Query 缓存策略](https://tanstack.com/query/latest)
|
|
||||||
- [前端数据获取最佳实践](https://kentcdodds.com/blog/data-fetching)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**更新日期**: 2024-10-26
|
|
||||||
**维护者**: Claude Code Assistant
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
# 个人中心 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` - 登录弹窗改造计划
|
|
||||||
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
# 消息推送系统优化总结
|
|
||||||
|
|
||||||
## 优化目标
|
|
||||||
1. 简化通知信息密度,通过视觉层次(边框+背景色)表达优先级
|
|
||||||
2. 增强紧急通知的视觉冲击力(红色脉冲边框动画)
|
|
||||||
3. 采用智能显示策略,降低普通通知的视觉干扰
|
|
||||||
|
|
||||||
## 实施内容
|
|
||||||
|
|
||||||
### 1. 优先级配置更新 (src/constants/notificationTypes.js)
|
|
||||||
|
|
||||||
#### 新增配置项
|
|
||||||
- `borderWidth`: 边框宽度
|
|
||||||
- 紧急 (urgent): 6px
|
|
||||||
- 重要 (important): 4px
|
|
||||||
- 普通 (normal): 2px
|
|
||||||
|
|
||||||
- `bgOpacity`: 背景色透明度(亮色模式)
|
|
||||||
- 紧急: 0.25 (深色背景)
|
|
||||||
- 重要: 0.15 (中色背景)
|
|
||||||
- 普通: 0.08 (浅色背景)
|
|
||||||
|
|
||||||
- `darkBgOpacity`: 背景色透明度(暗色模式)
|
|
||||||
- 紧急: 0.30
|
|
||||||
- 重要: 0.20
|
|
||||||
- 普通: 0.12
|
|
||||||
|
|
||||||
#### 新增辅助函数
|
|
||||||
- `getPriorityBgOpacity(priority, isDark)`: 获取优先级对应的背景色透明度
|
|
||||||
- `getPriorityBorderWidth(priority)`: 获取优先级对应的边框宽度
|
|
||||||
|
|
||||||
### 2. 紧急通知脉冲动画 (src/components/NotificationContainer/index.js)
|
|
||||||
|
|
||||||
#### 动画效果
|
|
||||||
- 使用 `@emotion/react` 的 `keyframes` 创建脉冲动画
|
|
||||||
- 仅紧急通知 (urgent) 应用动画效果
|
|
||||||
- 动画特性:
|
|
||||||
- 边框颜色脉冲效果
|
|
||||||
- 阴影扩散效果(0 → 12px)
|
|
||||||
- 持续时间:2秒
|
|
||||||
- 缓动函数:ease-in-out
|
|
||||||
- 无限循环
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const pulseAnimation = keyframes`
|
|
||||||
0%, 100% {
|
|
||||||
border-left-color: currentColor;
|
|
||||||
box-shadow: 0 0 0 0 currentColor;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
border-left-color: currentColor;
|
|
||||||
box-shadow: -4px 0 12px 0 currentColor;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 背景色优先级优化
|
|
||||||
|
|
||||||
#### 亮色模式
|
|
||||||
- **紧急通知**:`${colorScheme}.200` - 深色背景 + 脉冲动画
|
|
||||||
- **重要通知**:`${colorScheme}.100` - 中色背景
|
|
||||||
- **普通通知**:`white` - 极淡背景(降低视觉干扰)
|
|
||||||
|
|
||||||
#### 暗色模式
|
|
||||||
- **紧急通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
|
||||||
- **重要通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
|
||||||
- **普通通知**:`gray.800` - 暗灰背景(降低视觉干扰)
|
|
||||||
|
|
||||||
### 4. 可点击性视觉提示
|
|
||||||
|
|
||||||
#### 问题
|
|
||||||
- 用户需要 hover 才能知道通知是否可点击
|
|
||||||
- cursor: pointer 不够直观
|
|
||||||
|
|
||||||
#### 解决方案
|
|
||||||
- **可点击的通知**:
|
|
||||||
- 添加完整边框(四周 1px solid)
|
|
||||||
- 保持左侧优先级边框宽度
|
|
||||||
- 使用更明显的阴影(md 级别)
|
|
||||||
- 产生微妙的悬浮感
|
|
||||||
|
|
||||||
- **不可点击的通知**:
|
|
||||||
- 仅左侧边框
|
|
||||||
- 使用较淡的阴影(sm 级别)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 可点击的通知添加完整边框
|
|
||||||
{...(isActuallyClickable && {
|
|
||||||
border: '1px solid',
|
|
||||||
borderLeftWidth: priorityBorderWidth, // 保持优先级
|
|
||||||
})}
|
|
||||||
|
|
||||||
// 可点击的通知使用更明显的阴影
|
|
||||||
boxShadow={isActuallyClickable
|
|
||||||
? (isNewest ? '2xl' : 'md')
|
|
||||||
: (isNewest ? 'xl' : 'sm')}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 通知组件简化 (src/components/NotificationContainer/index.js)
|
|
||||||
|
|
||||||
#### 显示元素分级
|
|
||||||
|
|
||||||
**LV1 - 必需元素(始终显示)**
|
|
||||||
- ✅ 标题 (title)
|
|
||||||
- ✅ 内容 (content, 最多3行)
|
|
||||||
- ✅ 时间 (publishTime/pushTime)
|
|
||||||
- ✅ 查看详情 (仅当 clickable=true 时)
|
|
||||||
- ✅ 关闭按钮
|
|
||||||
|
|
||||||
**LV2 - 可选元素(数据存在时显示)**
|
|
||||||
- ✅ 图标:仅在紧急/重要通知时显示
|
|
||||||
- ❌ 优先级标签:已移除,改用边框+背景色表示
|
|
||||||
- ✅ 状态提示:仅当 `extra?.statusHint` 存在时显示
|
|
||||||
|
|
||||||
**LV3 - 可选元素(数据存在时显示)**
|
|
||||||
- ✅ AI 标识:仅当 `isAIGenerated = true` 时显示
|
|
||||||
- ✅ 预测标识:仅当 `isPrediction = true` 时显示
|
|
||||||
|
|
||||||
**其他**
|
|
||||||
- ✅ 作者信息:移除屏幕尺寸限制,仅当 `author` 存在时显示
|
|
||||||
|
|
||||||
#### 优先级视觉样式
|
|
||||||
- ✅ 边框宽度:根据优先级动态调整 (2px/4px/6px)
|
|
||||||
- ✅ 背景色深度:根据优先级使用不同深度的颜色
|
|
||||||
- 亮色模式: .50 (普通) / .100 (重要) / .200 (紧急)
|
|
||||||
- 暗色模式: 使用 typeConfig 的 darkBg 配置
|
|
||||||
|
|
||||||
#### 布局优化
|
|
||||||
- ✅ 内容和元数据区域的左侧填充根据图标显示状态自适应
|
|
||||||
- ✅ 无图标时不添加额外的左侧间距
|
|
||||||
|
|
||||||
## 预期效果
|
|
||||||
|
|
||||||
### 视觉改进
|
|
||||||
- **清晰度提升**:移除冗余的优先级标签,视觉更整洁
|
|
||||||
- **优先级强化**:
|
|
||||||
- 紧急通知:6px 粗边框 + 深色背景 + **红色脉冲动画** → 视觉冲击力极强
|
|
||||||
- 重要通知:4px 中等边框 + 中色背景 + 图标 → 醒目但不打扰
|
|
||||||
- 普通通知:2px 细边框 + 白色/极淡背景 → 低视觉干扰
|
|
||||||
- **可点击性一目了然**:
|
|
||||||
- 可点击:完整边框 + 明显阴影 → 卡片悬浮感
|
|
||||||
- 不可点击:仅左侧边框 + 淡阴影 → 平面感
|
|
||||||
- **信息密度降低**:减少不必要的视觉元素,关键信息更突出
|
|
||||||
|
|
||||||
### 用户体验
|
|
||||||
- **紧急通知引起注意**:脉冲动画确保用户不会错过紧急信息
|
|
||||||
- **快速识别优先级**:
|
|
||||||
- 动画 = 紧急(需要立即关注)
|
|
||||||
- 图标 + 粗边框 = 重要(需要关注)
|
|
||||||
- 细边框 + 淡背景 = 普通(可稍后查看)
|
|
||||||
- **可点击性无需 hover**:
|
|
||||||
- 完整边框 + 悬浮感 = 可以点击查看详情
|
|
||||||
- 仅左侧边框 = 信息已完整,无需跳转
|
|
||||||
- **智能显示**:可选信息只在数据存在时显示,避免空白占位
|
|
||||||
- **响应式优化**:所有设备上保持一致的显示逻辑
|
|
||||||
|
|
||||||
### 向后兼容
|
|
||||||
- ✅ 完全兼容现有通知数据结构
|
|
||||||
- ✅ 可选字段不存在时自动隐藏
|
|
||||||
- ✅ 不影响现有功能(点击、关闭、自动消失等)
|
|
||||||
|
|
||||||
## 测试建议
|
|
||||||
|
|
||||||
### 1. 功能测试
|
|
||||||
```bash
|
|
||||||
# 启动开发服务器
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 观察不同优先级通知的显示效果
|
|
||||||
# - 紧急通知:粗边框 (6px) + 深色背景 + 红色脉冲动画 + 图标 + 不自动关闭
|
|
||||||
# - 重要通知:中等边框 (4px) + 中色背景 + 图标 + 30秒后关闭
|
|
||||||
# - 普通通知:细边框 (2px) + 白色背景 + 无图标 + 15秒后关闭
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.1 动画测试
|
|
||||||
- [ ] 紧急通知的脉冲动画流畅无卡顿
|
|
||||||
- [ ] 动画周期为 2 秒
|
|
||||||
- [ ] 动画在紧急通知显示期间持续循环
|
|
||||||
- [ ] 阴影扩散效果清晰可见
|
|
||||||
|
|
||||||
### 2. 边界测试
|
|
||||||
- [ ] 仅必需字段的通知(无作者、无 AI 标识、无预测标识)
|
|
||||||
- [ ] 包含所有可选字段的通知
|
|
||||||
- [ ] 不同类型的通知(公告、股票、事件、分析报告)
|
|
||||||
- [ ] 不同优先级的通知(紧急、重要、普通)
|
|
||||||
|
|
||||||
### 3. 响应式测试
|
|
||||||
- [ ] 移动设备 (< 480px)
|
|
||||||
- [ ] 平板设备 (480px - 768px)
|
|
||||||
- [ ] 桌面设备 (> 768px)
|
|
||||||
|
|
||||||
### 4. 暗色模式测试
|
|
||||||
- [ ] 切换到暗色模式,确认背景色对比度合适
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 关键代码变更
|
|
||||||
|
|
||||||
#### 1. 脉冲动画实现
|
|
||||||
```javascript
|
|
||||||
// 导入 keyframes
|
|
||||||
import { keyframes } from '@emotion/react';
|
|
||||||
|
|
||||||
// 定义脉冲动画
|
|
||||||
const pulseAnimation = keyframes`
|
|
||||||
0%, 100% {
|
|
||||||
border-left-color: currentColor;
|
|
||||||
box-shadow: 0 0 0 0 currentColor;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
border-left-color: currentColor;
|
|
||||||
box-shadow: -4px 0 12px 0 currentColor;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 应用到紧急通知
|
|
||||||
<Box
|
|
||||||
animation={priority === PRIORITY_LEVELS.URGENT
|
|
||||||
? `${pulseAnimation} 2s ease-in-out infinite`
|
|
||||||
: undefined}
|
|
||||||
...
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 优先级标签自动隐藏
|
|
||||||
```javascript
|
|
||||||
// PRIORITY_CONFIGS 中所有 show 属性设置为 false
|
|
||||||
show: false, // 不再显示标签,改用边框+背景色表示
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 背景色优先级优化
|
|
||||||
```javascript
|
|
||||||
const getPriorityBgColor = () => {
|
|
||||||
const colorScheme = typeConfig.colorScheme;
|
|
||||||
if (!isDark) {
|
|
||||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
|
||||||
return `${colorScheme}.200`; // 深色背景 + 脉冲动画
|
|
||||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
|
||||||
return `${colorScheme}.100`; // 中色背景
|
|
||||||
} else {
|
|
||||||
return 'white'; // 极淡背景(降低视觉干扰)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
|
||||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
|
||||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
|
||||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
|
||||||
} else {
|
|
||||||
return 'gray.800'; // 暗灰背景(降低视觉干扰)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 图标条件显示
|
|
||||||
```javascript
|
|
||||||
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT ||
|
|
||||||
priority === PRIORITY_LEVELS.IMPORTANT;
|
|
||||||
|
|
||||||
{shouldShowIcon && (
|
|
||||||
<Icon as={typeConfig.icon} ... />
|
|
||||||
)}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 后续改进建议
|
|
||||||
|
|
||||||
### 短期
|
|
||||||
- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
|
|
||||||
- [ ] 提供配置选项让用户自定义显示元素
|
|
||||||
|
|
||||||
### 长期
|
|
||||||
- [ ] 支持通知分组(按类型或优先级)
|
|
||||||
- [ ] 添加通知搜索和筛选功能
|
|
||||||
- [ ] 通知历史记录可视化统计
|
|
||||||
|
|
||||||
## 构建状态
|
|
||||||
✅ 构建成功 (npm run build)
|
|
||||||
✅ 无语法错误
|
|
||||||
✅ 无 TypeScript 错误
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,614 +0,0 @@
|
|||||||
# PostHog Dashboard 配置指南
|
|
||||||
|
|
||||||
## 📊 目的
|
|
||||||
|
|
||||||
本指南帮助你在PostHog中配置关键的分析Dashboard和Insights,快速获得有价值的用户行为洞察。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 推荐Dashboard列表
|
|
||||||
|
|
||||||
### 1. 📈 核心指标Dashboard
|
|
||||||
**用途**: 监控产品整体健康度
|
|
||||||
|
|
||||||
### 2. 🔄 用户留存Dashboard
|
|
||||||
**用途**: 分析用户留存和流失
|
|
||||||
|
|
||||||
### 3. 💰 收入转化Dashboard
|
|
||||||
**用途**: 监控付费转化漏斗
|
|
||||||
|
|
||||||
### 4. 🎨 功能使用Dashboard
|
|
||||||
**用途**: 了解功能受欢迎程度
|
|
||||||
|
|
||||||
### 5. 🔍 搜索行为Dashboard
|
|
||||||
**用途**: 优化搜索体验
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Dashboard 1: 核心指标
|
|
||||||
|
|
||||||
### Insight 1.1: 每日活跃用户(DAU)
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `$pageview`
|
|
||||||
**时间范围**: 过去30天
|
|
||||||
**分组**: 按日
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: $pageview
|
|
||||||
Unique users
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 1.2: 新用户注册趋势
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `USER_SIGNED_UP`
|
|
||||||
**时间范围**: 过去30天
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: USER_SIGNED_UP
|
|
||||||
Count of events
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
Breakdown: signup_method
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 1.3: 用户登录方式分布
|
|
||||||
**类型**: Pie Chart
|
|
||||||
**事件**: `USER_LOGGED_IN`
|
|
||||||
**时间范围**: 过去7天
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: USER_LOGGED_IN
|
|
||||||
Count of events
|
|
||||||
Date range: Last 7 days
|
|
||||||
Breakdown: login_method
|
|
||||||
Visualization: Pie
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 1.4: 最受欢迎的页面
|
|
||||||
**类型**: Table
|
|
||||||
**事件**: `$pageview`
|
|
||||||
**时间范围**: 过去7天
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: $pageview
|
|
||||||
Count of events
|
|
||||||
Date range: Last 7 days
|
|
||||||
Breakdown: $current_url
|
|
||||||
Order: Descending
|
|
||||||
Limit: Top 10
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 1.5: 平台分布
|
|
||||||
**类型**: Bar Chart
|
|
||||||
**事件**: `$pageview`
|
|
||||||
**时间范围**: 过去30天
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: $pageview
|
|
||||||
Unique users
|
|
||||||
Date range: Last 30 days
|
|
||||||
Breakdown: $os
|
|
||||||
Visualization: Bar
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Dashboard 2: 用户留存
|
|
||||||
|
|
||||||
### Insight 2.1: 用户留存曲线
|
|
||||||
**类型**: Retention
|
|
||||||
**初始事件**: `USER_SIGNED_UP`
|
|
||||||
**返回事件**: `$pageview`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Cohort defining event: USER_SIGNED_UP
|
|
||||||
Returning event: $pageview
|
|
||||||
Period: Daily
|
|
||||||
Date range: Last 8 weeks
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 2.2: 功能留存率
|
|
||||||
**类型**: Retention
|
|
||||||
**初始事件**: 各功能首次使用事件
|
|
||||||
**返回事件**: 各功能再次使用
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Cohort defining event: TRADING_SIMULATION_ENTERED
|
|
||||||
Returning event: TRADING_SIMULATION_ENTERED
|
|
||||||
Period: Weekly
|
|
||||||
Date range: Last 12 weeks
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 2.3: 社区互动留存
|
|
||||||
**类型**: Retention
|
|
||||||
**初始事件**: `Community Page Viewed`
|
|
||||||
**返回事件**: `NEWS_ARTICLE_CLICKED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Cohort defining event: Community Page Viewed
|
|
||||||
Returning event: NEWS_ARTICLE_CLICKED
|
|
||||||
Period: Daily
|
|
||||||
Date range: Last 30 days
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 2.4: 活跃用户分层
|
|
||||||
**类型**: Trends
|
|
||||||
**多个事件**: 按活跃度分类
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event 1: $pageview (filter: >= 20 events in last 7 days)
|
|
||||||
Event 2: $pageview (filter: 10-19 events in last 7 days)
|
|
||||||
Event 3: $pageview (filter: 3-9 events in last 7 days)
|
|
||||||
Event 4: $pageview (filter: 1-2 events in last 7 days)
|
|
||||||
Date range: Last 30 days
|
|
||||||
Unique users
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💰 Dashboard 3: 收入转化
|
|
||||||
|
|
||||||
### Insight 3.1: 付费转化漏斗
|
|
||||||
**类型**: Funnel
|
|
||||||
**步骤**:
|
|
||||||
1. SUBSCRIPTION_PAGE_VIEWED
|
|
||||||
2. Pricing Plan Selected
|
|
||||||
3. PAYMENT_INITIATED
|
|
||||||
4. PAYMENT_SUCCESSFUL
|
|
||||||
5. SUBSCRIPTION_CREATED
|
|
||||||
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Funnel step 1: SUBSCRIPTION_PAGE_VIEWED
|
|
||||||
Funnel step 2: Pricing Plan Selected
|
|
||||||
Funnel step 3: PAYMENT_INITIATED
|
|
||||||
Funnel step 4: PAYMENT_SUCCESSFUL
|
|
||||||
Funnel step 5: SUBSCRIPTION_CREATED
|
|
||||||
Conversion window: 1 hour
|
|
||||||
Date range: Last 30 days
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 3.2: 付费墙转化率
|
|
||||||
**类型**: Funnel
|
|
||||||
**步骤**:
|
|
||||||
1. PAYWALL_SHOWN
|
|
||||||
2. PAYWALL_UPGRADE_CLICKED
|
|
||||||
3. SUBSCRIPTION_PAGE_VIEWED
|
|
||||||
4. PAYMENT_SUCCESSFUL
|
|
||||||
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Funnel step 1: PAYWALL_SHOWN
|
|
||||||
Funnel step 2: PAYWALL_UPGRADE_CLICKED
|
|
||||||
Funnel step 3: SUBSCRIPTION_PAGE_VIEWED
|
|
||||||
Funnel step 4: PAYMENT_SUCCESSFUL
|
|
||||||
Breakdown: feature (付费墙触发功能)
|
|
||||||
Date range: Last 30 days
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 3.3: 定价方案选择分布
|
|
||||||
**类型**: Pie Chart
|
|
||||||
**事件**: `Pricing Plan Selected`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: Pricing Plan Selected
|
|
||||||
Count of events
|
|
||||||
Breakdown: plan_name
|
|
||||||
Date range: Last 30 days
|
|
||||||
Visualization: Pie
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 3.4: 计费周期偏好
|
|
||||||
**类型**: Bar Chart
|
|
||||||
**事件**: `Pricing Plan Selected`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: Pricing Plan Selected
|
|
||||||
Count of events
|
|
||||||
Breakdown: billing_cycle
|
|
||||||
Date range: Last 30 days
|
|
||||||
Visualization: Bar
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 3.5: 支付成功率
|
|
||||||
**类型**: Trends (Formula)
|
|
||||||
**计算**: (PAYMENT_SUCCESSFUL / PAYMENT_INITIATED) * 100
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Series A: PAYMENT_SUCCESSFUL (Count)
|
|
||||||
Series B: PAYMENT_INITIATED (Count)
|
|
||||||
Formula: (A / B) * 100
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 3.6: 订阅收入趋势
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `SUBSCRIPTION_CREATED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: SUBSCRIPTION_CREATED
|
|
||||||
Sum of property: amount
|
|
||||||
Date range: Last 90 days
|
|
||||||
Interval: Week
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 3.7: 支付失败原因分析
|
|
||||||
**类型**: Table
|
|
||||||
**事件**: `PAYMENT_FAILED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: PAYMENT_FAILED
|
|
||||||
Count of events
|
|
||||||
Breakdown: error_reason
|
|
||||||
Date range: Last 30 days
|
|
||||||
Order: Descending
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Dashboard 4: 功能使用
|
|
||||||
|
|
||||||
### Insight 4.1: 功能使用频率排名
|
|
||||||
**类型**: Table
|
|
||||||
**多个事件**: 各功能的关键事件
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Events:
|
|
||||||
- Community Page Viewed
|
|
||||||
- EVENT_DETAIL_VIEWED
|
|
||||||
- DASHBOARD_CENTER_VIEWED
|
|
||||||
- TRADING_SIMULATION_ENTERED
|
|
||||||
- STOCK_OVERVIEW_VIEWED
|
|
||||||
Count of events
|
|
||||||
Date range: Last 7 days
|
|
||||||
Order: Descending
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 4.2: 新闻浏览趋势
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `NEWS_ARTICLE_CLICKED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: NEWS_ARTICLE_CLICKED
|
|
||||||
Count of events
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
Breakdown: importance (按重要性分组)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 4.3: 搜索使用趋势
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: SEARCH_QUERY_SUBMITTED
|
|
||||||
Count of events
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
Breakdown: context
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 4.4: 模拟盘交易活跃度
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `Simulation Order Placed`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: Simulation Order Placed
|
|
||||||
Count of events
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
Breakdown: order_type (买入/卖出)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 4.5: 社交互动参与度
|
|
||||||
**类型**: Trends (Stacked)
|
|
||||||
**多个事件**:
|
|
||||||
- Comment Added
|
|
||||||
- Comment Liked
|
|
||||||
- CONTENT_SHARED
|
|
||||||
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event 1: Comment Added
|
|
||||||
Event 2: Comment Liked
|
|
||||||
Event 3: CONTENT_SHARED
|
|
||||||
Count of events
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
Visualization: Area (Stacked)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 4.6: 个人资料完善度
|
|
||||||
**类型**: Funnel
|
|
||||||
**步骤**:
|
|
||||||
1. USER_SIGNED_UP
|
|
||||||
2. PROFILE_UPDATED
|
|
||||||
3. Avatar Uploaded
|
|
||||||
4. Account Bound
|
|
||||||
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Funnel step 1: USER_SIGNED_UP
|
|
||||||
Funnel step 2: PROFILE_UPDATED
|
|
||||||
Funnel step 3: Avatar Uploaded
|
|
||||||
Funnel step 4: Account Bound
|
|
||||||
Date range: Last 30 days
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Dashboard 5: 搜索行为
|
|
||||||
|
|
||||||
### Insight 5.1: 搜索量趋势
|
|
||||||
**类型**: Trends
|
|
||||||
**事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: SEARCH_QUERY_SUBMITTED
|
|
||||||
Count of events
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 5.2: 搜索无结果率
|
|
||||||
**类型**: Trends (Formula)
|
|
||||||
**计算**: (SEARCH_NO_RESULTS / SEARCH_QUERY_SUBMITTED) * 100
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Series A: SEARCH_NO_RESULTS (Count)
|
|
||||||
Series B: SEARCH_QUERY_SUBMITTED (Count)
|
|
||||||
Formula: (A / B) * 100
|
|
||||||
Date range: Last 30 days
|
|
||||||
Interval: Day
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 5.3: 热门搜索词
|
|
||||||
**类型**: Table
|
|
||||||
**事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: SEARCH_QUERY_SUBMITTED
|
|
||||||
Count of events
|
|
||||||
Breakdown: query
|
|
||||||
Date range: Last 7 days
|
|
||||||
Order: Descending
|
|
||||||
Limit: Top 20
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 5.4: 搜索结果点击率
|
|
||||||
**类型**: Funnel
|
|
||||||
**步骤**:
|
|
||||||
1. SEARCH_QUERY_SUBMITTED
|
|
||||||
2. SEARCH_RESULT_CLICKED
|
|
||||||
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Funnel step 1: SEARCH_QUERY_SUBMITTED
|
|
||||||
Funnel step 2: SEARCH_RESULT_CLICKED
|
|
||||||
Breakdown: context
|
|
||||||
Date range: Last 30 days
|
|
||||||
```
|
|
||||||
|
|
||||||
### Insight 5.5: 搜索筛选使用
|
|
||||||
**类型**: Table
|
|
||||||
**事件**: `SEARCH_FILTER_APPLIED`
|
|
||||||
**配置**:
|
|
||||||
```
|
|
||||||
Event: SEARCH_FILTER_APPLIED
|
|
||||||
Count of events
|
|
||||||
Breakdown: filter_type
|
|
||||||
Date range: Last 30 days
|
|
||||||
Order: Descending
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 推荐Cohorts(用户分组)
|
|
||||||
|
|
||||||
### Cohort 1: 活跃用户
|
|
||||||
**条件**:
|
|
||||||
```
|
|
||||||
用户在过去7天内执行了:
|
|
||||||
$pageview (至少5次)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cohort 2: 付费用户
|
|
||||||
**条件**:
|
|
||||||
```
|
|
||||||
用户执行过:
|
|
||||||
SUBSCRIPTION_CREATED
|
|
||||||
并且
|
|
||||||
subscription_tier 不等于 'free'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cohort 3: 社区活跃用户
|
|
||||||
**条件**:
|
|
||||||
```
|
|
||||||
用户在过去30天内执行了:
|
|
||||||
Comment Added (至少1次)
|
|
||||||
或
|
|
||||||
Comment Liked (至少3次)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cohort 4: 流失风险用户
|
|
||||||
**条件**:
|
|
||||||
```
|
|
||||||
用户满足:
|
|
||||||
上次访问时间 > 7天前
|
|
||||||
并且
|
|
||||||
历史访问次数 >= 5次
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cohort 5: 高价值潜在用户
|
|
||||||
**条件**:
|
|
||||||
```
|
|
||||||
用户在过去30天内:
|
|
||||||
PAYWALL_SHOWN (至少2次)
|
|
||||||
并且
|
|
||||||
未执行过 SUBSCRIPTION_CREATED
|
|
||||||
并且
|
|
||||||
$pageview (至少10次)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cohort 6: 新用户(激活中)
|
|
||||||
**条件**:
|
|
||||||
```
|
|
||||||
用户执行过:
|
|
||||||
USER_SIGNED_UP (在过去7天内)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 推荐Actions(动作定义)
|
|
||||||
|
|
||||||
### Action 1: 深度参与
|
|
||||||
**定义**: 用户在单次会话中执行了多个关键操作
|
|
||||||
**包含事件**:
|
|
||||||
- NEWS_ARTICLE_CLICKED (至少2次)
|
|
||||||
- EVENT_DETAIL_VIEWED (至少1次)
|
|
||||||
- Comment Added 或 Comment Liked (至少1次)
|
|
||||||
|
|
||||||
### Action 2: 付费意向
|
|
||||||
**定义**: 用户展现付费兴趣
|
|
||||||
**包含事件**:
|
|
||||||
- PAYWALL_SHOWN
|
|
||||||
- PAYWALL_UPGRADE_CLICKED
|
|
||||||
- SUBSCRIPTION_PAGE_VIEWED
|
|
||||||
|
|
||||||
### Action 3: 模拟盘活跃
|
|
||||||
**定义**: 用户积极使用模拟盘
|
|
||||||
**包含事件**:
|
|
||||||
- TRADING_SIMULATION_ENTERED
|
|
||||||
- Simulation Order Placed (至少1次)
|
|
||||||
- Simulation Holdings Viewed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 配置步骤
|
|
||||||
|
|
||||||
### 创建Dashboard
|
|
||||||
1. 登录PostHog
|
|
||||||
2. 左侧菜单选择 "Dashboards"
|
|
||||||
3. 点击 "New dashboard"
|
|
||||||
4. 输入Dashboard名称(如"核心指标Dashboard")
|
|
||||||
5. 点击 "Create"
|
|
||||||
|
|
||||||
### 添加Insight
|
|
||||||
1. 在Dashboard页面,点击 "Add insight"
|
|
||||||
2. 选择Insight类型(Trends/Funnel/Retention等)
|
|
||||||
3. 配置事件和参数
|
|
||||||
4. 点击 "Save & add to dashboard"
|
|
||||||
|
|
||||||
### 配置Cohort
|
|
||||||
1. 左侧菜单选择 "Cohorts"
|
|
||||||
2. 点击 "New cohort"
|
|
||||||
3. 设置Cohort名称
|
|
||||||
4. 添加筛选条件
|
|
||||||
5. 点击 "Save"
|
|
||||||
|
|
||||||
### 配置Action
|
|
||||||
1. 左侧菜单选择 "Data management" -> "Actions"
|
|
||||||
2. 点击 "New action"
|
|
||||||
3. 选择 "From event or pageview"
|
|
||||||
4. 添加匹配条件
|
|
||||||
5. 点击 "Save"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔔 推荐Alerts(告警配置)
|
|
||||||
|
|
||||||
### Alert 1: 支付成功率下降
|
|
||||||
**条件**: 支付成功率 < 80%
|
|
||||||
**检查频率**: 每小时
|
|
||||||
**通知方式**: Email + Slack
|
|
||||||
|
|
||||||
### Alert 2: 搜索无结果率过高
|
|
||||||
**条件**: 搜索无结果率 > 30%
|
|
||||||
**检查频率**: 每天
|
|
||||||
**通知方式**: Email
|
|
||||||
|
|
||||||
### Alert 3: 新用户注册激增
|
|
||||||
**条件**: 新注册用户数 > 正常值的2倍
|
|
||||||
**检查频率**: 每小时
|
|
||||||
**通知方式**: Slack
|
|
||||||
|
|
||||||
### Alert 4: 系统异常
|
|
||||||
**条件**: 错误事件数 > 100/小时
|
|
||||||
**检查频率**: 每15分钟
|
|
||||||
**通知方式**: Email + Slack + PagerDuty
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 使用建议
|
|
||||||
|
|
||||||
### 日常监控
|
|
||||||
**建议查看频率**: 每天
|
|
||||||
**关注Dashboard**:
|
|
||||||
- 核心指标Dashboard
|
|
||||||
- 收入转化Dashboard
|
|
||||||
|
|
||||||
### 周度回顾
|
|
||||||
**建议查看频率**: 每周一
|
|
||||||
**关注Dashboard**:
|
|
||||||
- 用户留存Dashboard
|
|
||||||
- 功能使用Dashboard
|
|
||||||
|
|
||||||
### 月度分析
|
|
||||||
**建议查看频率**: 每月初
|
|
||||||
**关注Dashboard**:
|
|
||||||
- 所有Dashboard
|
|
||||||
- Cohorts分析
|
|
||||||
- Retention详细报告
|
|
||||||
|
|
||||||
### 决策支持
|
|
||||||
**使用场景**:
|
|
||||||
- 功能优先级排序 → 查看功能使用Dashboard
|
|
||||||
- 转化率优化 → 查看收入转化Dashboard
|
|
||||||
- 用户流失分析 → 查看用户留存Dashboard
|
|
||||||
- 搜索体验优化 → 查看搜索行为Dashboard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 高级分析技巧
|
|
||||||
|
|
||||||
### 1. Funnel分解分析
|
|
||||||
在漏斗的每一步添加Breakdown,分析不同用户群的转化差异:
|
|
||||||
- 按 subscription_tier 分解
|
|
||||||
- 按 signup_method 分解
|
|
||||||
- 按 $os 分解
|
|
||||||
|
|
||||||
### 2. Cohort对比
|
|
||||||
创建多个Cohort,在Insights中对比不同群体的行为:
|
|
||||||
- 付费用户 vs 免费用户
|
|
||||||
- 新用户 vs 老用户
|
|
||||||
- 活跃用户 vs 流失用户
|
|
||||||
|
|
||||||
### 3. Path Analysis
|
|
||||||
使用Paths功能分析用户旅程:
|
|
||||||
- 从注册到首次付费的路径
|
|
||||||
- 从首页到核心功能的路径
|
|
||||||
- 流失用户的最后操作路径
|
|
||||||
|
|
||||||
### 4. 时间对比
|
|
||||||
使用 "Compare to previous period" 功能:
|
|
||||||
- 本周 vs 上周
|
|
||||||
- 本月 vs 上月
|
|
||||||
- 节假日 vs 平常
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 相关资源
|
|
||||||
|
|
||||||
- [PostHog Dashboard文档](https://posthog.com/docs/user-guides/dashboards)
|
|
||||||
- [PostHog Insights文档](https://posthog.com/docs/user-guides/insights)
|
|
||||||
- [PostHog Cohorts文档](https://posthog.com/docs/user-guides/cohorts)
|
|
||||||
- [TRACKING_VALIDATION_CHECKLIST.md](./TRACKING_VALIDATION_CHECKLIST.md) - 验证清单
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档版本**: v1.0
|
|
||||||
**最后更新**: 2025-10-29
|
|
||||||
**维护者**: 数据分析团队
|
|
||||||
@@ -1,841 +0,0 @@
|
|||||||
# PostHog 事件追踪实施总结
|
|
||||||
|
|
||||||
## ✅ 已完成的追踪
|
|
||||||
|
|
||||||
### 1. Home 页面(首页/落地页)
|
|
||||||
|
|
||||||
**已实施的追踪事件**:
|
|
||||||
|
|
||||||
#### 📄 页面浏览
|
|
||||||
- **事件**: `LANDING_PAGE_VIEWED`
|
|
||||||
- **触发时机**: 页面加载
|
|
||||||
- **属性**:
|
|
||||||
- `timestamp` - 访问时间
|
|
||||||
- `is_authenticated` - 是否已登录
|
|
||||||
- `user_id` - 用户ID(如果已登录)
|
|
||||||
|
|
||||||
#### 🎯 功能卡片点击
|
|
||||||
- **事件**: `FEATURE_CARD_CLICKED`
|
|
||||||
- **触发时机**: 用户点击任何功能卡片
|
|
||||||
- **属性**:
|
|
||||||
- `feature_id` - 功能ID(news-catalyst, concepts, stocks, etc.)
|
|
||||||
- `feature_title` - 功能标题
|
|
||||||
- `feature_url` - 目标URL
|
|
||||||
- `is_featured` - 是否为推荐功能(新闻中心为 true)
|
|
||||||
- `link_type` - 链接类型(internal/external)
|
|
||||||
|
|
||||||
**追踪的6个核心功能**:
|
|
||||||
1. **新闻中心** (`news-catalyst`) - 推荐功能,黄色边框
|
|
||||||
2. **概念中心** (`concepts`)
|
|
||||||
3. **个股信息汇总** (`stocks`)
|
|
||||||
4. **涨停板块分析** (`limit-analyse`)
|
|
||||||
5. **个股罗盘** (`company`)
|
|
||||||
6. **模拟盘交易** (`trading-simulation`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. StockOverview 页面(个股中心)✅ 已完成
|
|
||||||
|
|
||||||
**注意**:个股中心页面已完全实现 PostHog 追踪,通过 `src/views/StockOverview/hooks/useStockOverviewEvents.js` Hook。
|
|
||||||
|
|
||||||
**已实施的追踪事件**:
|
|
||||||
|
|
||||||
#### 📄 页面浏览
|
|
||||||
- **事件**: `STOCK_OVERVIEW_VIEWED`
|
|
||||||
- **触发时机**: 页面加载
|
|
||||||
- **属性**:
|
|
||||||
- `timestamp` - 访问时间
|
|
||||||
|
|
||||||
#### 📊 市场统计数据查看
|
|
||||||
- **事件**: `STOCK_LIST_VIEWED`
|
|
||||||
- **触发时机**: 加载市场统计数据
|
|
||||||
- **属性**:
|
|
||||||
- `total_market_cap` - 总市值
|
|
||||||
- `total_volume` - 总成交量
|
|
||||||
- `rising_stocks` - 上涨股票数
|
|
||||||
- `falling_stocks` - 下跌股票数
|
|
||||||
- `data_date` - 数据日期
|
|
||||||
|
|
||||||
#### 🔍 搜索追踪
|
|
||||||
- **事件**: `SEARCH_INITIATED` / `STOCK_SEARCHED`
|
|
||||||
- **触发时机**: 用户输入搜索、完成搜索
|
|
||||||
- **属性**:
|
|
||||||
- `query` - 搜索关键词
|
|
||||||
- `result_count` - 搜索结果数量
|
|
||||||
- `has_results` - 是否有结果
|
|
||||||
- `context` - 固定为 'stock_overview'
|
|
||||||
|
|
||||||
#### 🎯 搜索结果点击
|
|
||||||
- **事件**: `SEARCH_RESULT_CLICKED`
|
|
||||||
- **触发时机**: 用户点击搜索结果
|
|
||||||
- **属性**:
|
|
||||||
- `stock_code` - 股票代码
|
|
||||||
- `stock_name` - 股票名称
|
|
||||||
- `exchange` - 交易所
|
|
||||||
- `position` - 在搜索结果中的位置
|
|
||||||
- `context` - 固定为 'stock_overview'
|
|
||||||
|
|
||||||
#### 🔥 概念卡片点击
|
|
||||||
- **事件**: `CONCEPT_CLICKED`
|
|
||||||
- **触发时机**: 用户点击热门概念卡片
|
|
||||||
- **属性**:
|
|
||||||
- `concept_name` - 概念名称
|
|
||||||
- `concept_code` - 概念代码
|
|
||||||
- `change_percent` - 涨跌幅
|
|
||||||
- `stock_count` - 股票数量
|
|
||||||
- `rank` - 排名
|
|
||||||
- `source` - 固定为 'daily_hot_concepts'
|
|
||||||
|
|
||||||
#### 🏷️ 概念股票标签点击
|
|
||||||
- **事件**: `CONCEPT_STOCK_CLICKED`
|
|
||||||
- **触发时机**: 点击概念下的股票标签
|
|
||||||
- **属性**:
|
|
||||||
- `stock_code` - 股票代码
|
|
||||||
- `stock_name` - 股票名称
|
|
||||||
- `concept_name` - 所属概念
|
|
||||||
- `source` - 固定为 'daily_hot_concepts_tag'
|
|
||||||
|
|
||||||
#### 📊 热力图股票点击
|
|
||||||
- **事件**: `STOCK_CLICKED`
|
|
||||||
- **触发时机**: 点击热力图中的股票
|
|
||||||
- **属性**:
|
|
||||||
- `stock_code` - 股票代码
|
|
||||||
- `stock_name` - 股票名称
|
|
||||||
- `change_percent` - 涨跌幅
|
|
||||||
- `market_cap_range` - 市值区间
|
|
||||||
- `source` - 固定为 'market_heatmap'
|
|
||||||
|
|
||||||
#### 📅 日期选择变化
|
|
||||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
|
||||||
- **触发时机**: 用户选择不同的交易日期
|
|
||||||
- **属性**:
|
|
||||||
- `filter_type` - 固定为 'date'
|
|
||||||
- `filter_value` - 新选择的日期
|
|
||||||
- `previous_value` - 之前的日期
|
|
||||||
- `context` - 固定为 'stock_overview'
|
|
||||||
|
|
||||||
**实施方式**: Custom Hook (`useStockOverviewEvents.js`) 已集成
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Concept 页面(概念中心)
|
|
||||||
|
|
||||||
**已实施的追踪事件**:
|
|
||||||
|
|
||||||
#### 📄 页面浏览
|
|
||||||
- **事件**: `CONCEPT_CENTER_VIEWED`
|
|
||||||
- **触发时机**: 页面加载
|
|
||||||
- **属性**:
|
|
||||||
- `timestamp` - 访问时间
|
|
||||||
|
|
||||||
#### 🔍 搜索查询
|
|
||||||
- **事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
- **触发时机**: 用户搜索概念
|
|
||||||
- **属性**:
|
|
||||||
- `query` - 搜索关键词
|
|
||||||
- `category` - 固定为 'concept'
|
|
||||||
- `result_count` - 搜索结果数量
|
|
||||||
- `has_results` - 是否有结果
|
|
||||||
|
|
||||||
#### 🎚️ 筛选追踪
|
|
||||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
|
||||||
- **触发时机**: 用户更改筛选条件
|
|
||||||
- **属性**:
|
|
||||||
- `filter_type` - 筛选类型(sort/date)
|
|
||||||
- `filter_value` - 筛选值
|
|
||||||
- `previous_value` - 之前的值
|
|
||||||
- `context` - 固定为 'concept_center'
|
|
||||||
|
|
||||||
**支持的筛选类型**:
|
|
||||||
1. **排序** (`sort`): 涨跌幅/相关度/股票数量/概念名称
|
|
||||||
2. **日期范围** (`date`): 选择交易日期
|
|
||||||
|
|
||||||
#### 🎯 概念卡片点击
|
|
||||||
- **事件**: `CONCEPT_CLICKED`
|
|
||||||
- **触发时机**: 用户点击概念卡片
|
|
||||||
- **属性**:
|
|
||||||
- `concept_id` - 概念ID
|
|
||||||
- `concept_name` - 概念名称
|
|
||||||
- `change_percent` - 涨跌幅
|
|
||||||
- `stock_count` - 股票数量
|
|
||||||
- `position` - 在列表中的位置
|
|
||||||
- `source` - 固定为 'concept_center_list'
|
|
||||||
|
|
||||||
#### 👀 查看个股
|
|
||||||
- **事件**: `CONCEPT_STOCKS_VIEWED`
|
|
||||||
- **触发时机**: 用户点击"查看个股"按钮
|
|
||||||
- **属性**:
|
|
||||||
- `concept_name` - 概念名称
|
|
||||||
- `stock_count` - 股票数量
|
|
||||||
- `source` - 固定为 'concept_center'
|
|
||||||
|
|
||||||
#### 🏷️ 概念股票点击
|
|
||||||
- **事件**: `CONCEPT_STOCK_CLICKED`
|
|
||||||
- **触发时机**: 点击概念股票表格中的股票
|
|
||||||
- **属性**:
|
|
||||||
- `stock_code` - 股票代码
|
|
||||||
- `stock_name` - 股票名称
|
|
||||||
- `concept_name` - 所属概念
|
|
||||||
- `source` - 固定为 'concept_center_stock_table'
|
|
||||||
|
|
||||||
#### 📊 历史时间轴查看
|
|
||||||
- **事件**: `CONCEPT_TIMELINE_VIEWED`
|
|
||||||
- **触发时机**: 用户点击"历史时间轴"按钮
|
|
||||||
- **属性**:
|
|
||||||
- `concept_id` - 概念ID
|
|
||||||
- `concept_name` - 概念名称
|
|
||||||
- `source` - 固定为 'concept_center'
|
|
||||||
|
|
||||||
#### 📄 翻页追踪
|
|
||||||
- **事件**: `NEWS_LIST_VIEWED`
|
|
||||||
- **触发时机**: 用户翻页
|
|
||||||
- **属性**:
|
|
||||||
- `page` - 页码
|
|
||||||
- `filters` - 当前筛选条件
|
|
||||||
- `sort` - 排序方式
|
|
||||||
- `has_query` - 是否有搜索词
|
|
||||||
- `date` - 日期
|
|
||||||
- `context` - 固定为 'concept_center'
|
|
||||||
|
|
||||||
#### 🔄 视图模式切换
|
|
||||||
- **事件**: `VIEW_MODE_CHANGED`
|
|
||||||
- **触发时机**: 用户切换网格/列表视图
|
|
||||||
- **属性**:
|
|
||||||
- `view_mode` - 新视图模式(grid/list)
|
|
||||||
- `previous_mode` - 之前的模式
|
|
||||||
- `context` - 固定为 'concept_center'
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Company 页面(公司详情/个股罗盘)
|
|
||||||
|
|
||||||
**已实施的追踪事件**:
|
|
||||||
|
|
||||||
#### 📄 页面浏览
|
|
||||||
- **事件**: `COMPANY_PAGE_VIEWED`
|
|
||||||
- **触发时机**: 页面加载
|
|
||||||
- **属性**:
|
|
||||||
- `timestamp` - 访问时间
|
|
||||||
- `stock_code` - 当前查看的股票代码
|
|
||||||
|
|
||||||
#### 🔍 股票搜索
|
|
||||||
- **事件**: `STOCK_SEARCHED`
|
|
||||||
- **触发时机**: 用户输入股票代码并查询
|
|
||||||
- **属性**:
|
|
||||||
- `query` - 搜索的股票代码
|
|
||||||
- `stock_code` - 股票代码
|
|
||||||
- `previous_stock_code` - 之前查看的股票代码
|
|
||||||
- `context` - 固定为 'company_page'
|
|
||||||
|
|
||||||
#### 🔄 Tab 切换
|
|
||||||
- **事件**: `TAB_CHANGED`
|
|
||||||
- **触发时机**: 用户切换不同的 Tab
|
|
||||||
- **属性**:
|
|
||||||
- `tab_index` - Tab 索引(0-3)
|
|
||||||
- `tab_name` - Tab 名称(公司概览/股票行情/财务全景/盈利预测)
|
|
||||||
- `previous_tab_index` - 之前的 Tab 索引
|
|
||||||
- `stock_code` - 当前股票代码
|
|
||||||
- `context` - 固定为 'company_page'
|
|
||||||
|
|
||||||
**支持的 Tab**:
|
|
||||||
1. **公司概览** (index 0): 公司基本信息
|
|
||||||
2. **股票行情** (index 1): 实时行情数据
|
|
||||||
3. **财务全景** (index 2): 财务报表分析
|
|
||||||
4. **盈利预测** (index 3): 盈利预测数据
|
|
||||||
|
|
||||||
#### ⭐ 自选股管理
|
|
||||||
- **事件**: `WATCHLIST_ADDED` / `WATCHLIST_REMOVED`
|
|
||||||
- **触发时机**: 用户添加/移除自选股
|
|
||||||
- **属性**:
|
|
||||||
- `stock_code` - 股票代码
|
|
||||||
- `source` - 固定为 'company_page'
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Community 页面(新闻催化分析)
|
|
||||||
|
|
||||||
**已实施的追踪事件**:
|
|
||||||
|
|
||||||
#### 📄 页面浏览
|
|
||||||
- **事件**: `COMMUNITY_PAGE_VIEWED`
|
|
||||||
- **触发时机**: 页面加载
|
|
||||||
- **属性**:
|
|
||||||
- `timestamp` - 访问时间
|
|
||||||
- `has_hot_events` - 是否有热点事件
|
|
||||||
- `has_keywords` - 是否有热门关键词
|
|
||||||
|
|
||||||
#### 🔍 搜索追踪
|
|
||||||
- **事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
- **触发时机**: 用户输入搜索关键词
|
|
||||||
- **属性**:
|
|
||||||
- `query` - 搜索关键词
|
|
||||||
- `category` - 分类(固定为 'news')
|
|
||||||
- `previous_query` - 上一次搜索词
|
|
||||||
|
|
||||||
#### 🎚️ 筛选追踪
|
|
||||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
|
||||||
- **触发时机**: 用户更改筛选条件
|
|
||||||
- **属性**:
|
|
||||||
- `filter_type` - 筛选类型(sort/importance/date_range/industry)
|
|
||||||
- `filter_value` - 筛选值
|
|
||||||
- `previous_value` - 上一次的值
|
|
||||||
|
|
||||||
**支持的筛选类型**:
|
|
||||||
1. **排序** (`sort`): 最新/最热/重要性
|
|
||||||
2. **重要性** (`importance`): 全部/高/中/低
|
|
||||||
3. **时间范围** (`date_range`): 今天/近7天/近30天
|
|
||||||
4. **行业** (`industry`): 各行业代码
|
|
||||||
|
|
||||||
#### 🗞️ 新闻点击追踪
|
|
||||||
- **事件**: `NEWS_ARTICLE_CLICKED`
|
|
||||||
- **触发时机**: 用户点击新闻事件
|
|
||||||
- **属性**:
|
|
||||||
- `event_id` - 事件ID
|
|
||||||
- `event_title` - 事件标题
|
|
||||||
- `importance` - 重要性等级
|
|
||||||
- `source` - 来源(固定为 'community_page')
|
|
||||||
- `has_stocks` - 是否包含相关股票
|
|
||||||
- `has_concepts` - 是否包含相关概念
|
|
||||||
|
|
||||||
#### 📖 详情查看追踪
|
|
||||||
- **事件**: `NEWS_DETAIL_OPENED`
|
|
||||||
- **触发时机**: 用户点击"查看详情"
|
|
||||||
- **属性**:
|
|
||||||
- `event_id` - 事件ID
|
|
||||||
- `source` - 来源(固定为 'community_page')
|
|
||||||
|
|
||||||
#### 📄 翻页追踪
|
|
||||||
- **事件**: `NEWS_LIST_VIEWED`
|
|
||||||
- **触发时机**: 用户翻页
|
|
||||||
- **属性**:
|
|
||||||
- `page` - 页码
|
|
||||||
- `filters` - 当前筛选条件
|
|
||||||
- `sort` - 排序方式
|
|
||||||
- `importance` - 重要性
|
|
||||||
- `has_query` - 是否有搜索词
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 实施方式
|
|
||||||
|
|
||||||
### 方案:Custom Hook 集成(推荐)
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✅ 集中管理,易于维护
|
|
||||||
- ✅ 自动追踪,无需修改组件
|
|
||||||
- ✅ 符合关注点分离原则
|
|
||||||
- ✅ 便于测试和调试
|
|
||||||
|
|
||||||
### 修改的文件
|
|
||||||
|
|
||||||
#### 0. `src/views/StockOverview/hooks/useStockOverviewEvents.js` ✅
|
|
||||||
|
|
||||||
**文件已存在**,无需修改。已完整实现个股中心的所有追踪事件。
|
|
||||||
|
|
||||||
#### 1. `src/views/Concept/hooks/useConceptEvents.js`
|
|
||||||
|
|
||||||
**新建 Hook 文件**:
|
|
||||||
```javascript
|
|
||||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
|
||||||
```
|
|
||||||
|
|
||||||
**提供的追踪函数**:
|
|
||||||
- `trackConceptSearched()` - 搜索概念
|
|
||||||
- `trackFilterApplied()` - 筛选变化
|
|
||||||
- `trackConceptClicked()` - 概念点击
|
|
||||||
- `trackConceptStocksViewed()` - 查看个股
|
|
||||||
- `trackConceptStockClicked()` - 点击概念股票
|
|
||||||
- `trackConceptTimelineViewed()` - 历史时间轴
|
|
||||||
- `trackPageChange()` - 翻页
|
|
||||||
- `trackViewModeChanged()` - 视图切换
|
|
||||||
|
|
||||||
#### 2. `src/views/Company/hooks/useCompanyEvents.js`
|
|
||||||
|
|
||||||
**新建 Hook 文件**:
|
|
||||||
```javascript
|
|
||||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
|
||||||
```
|
|
||||||
|
|
||||||
**提供的追踪函数**:
|
|
||||||
- `trackStockSearched()` - 股票搜索
|
|
||||||
- `trackTabChanged()` - Tab 切换
|
|
||||||
- `trackWatchlistAdded()` - 加入自选
|
|
||||||
- `trackWatchlistRemoved()` - 移除自选
|
|
||||||
|
|
||||||
#### 3. `src/views/Company/index.js`
|
|
||||||
|
|
||||||
**添加的导入**:
|
|
||||||
```javascript
|
|
||||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的 Hook**:
|
|
||||||
```javascript
|
|
||||||
const {
|
|
||||||
trackStockSearched,
|
|
||||||
trackTabChanged,
|
|
||||||
trackWatchlistAdded,
|
|
||||||
trackWatchlistRemoved,
|
|
||||||
} = useCompanyEvents({ stockCode });
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的 State**:
|
|
||||||
```javascript
|
|
||||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改的函数**:
|
|
||||||
1. **`handleSearch`**: 追踪股票搜索
|
|
||||||
2. **`handleWatchlistToggle`**: 追踪自选股添加/移除
|
|
||||||
3. **Tabs `onChange`**: 追踪 Tab 切换
|
|
||||||
|
|
||||||
#### 4. `src/views/Concept/index.js`
|
|
||||||
|
|
||||||
**添加的导入**:
|
|
||||||
```javascript
|
|
||||||
import { useConceptEvents } from './hooks/useConceptEvents';
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的 Hook**:
|
|
||||||
```javascript
|
|
||||||
const {
|
|
||||||
trackConceptSearched,
|
|
||||||
trackFilterApplied,
|
|
||||||
trackConceptClicked,
|
|
||||||
trackConceptStocksViewed,
|
|
||||||
trackConceptStockClicked,
|
|
||||||
trackConceptTimelineViewed,
|
|
||||||
trackPageChange,
|
|
||||||
trackViewModeChanged,
|
|
||||||
} = useConceptEvents({ navigate });
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改的函数**:
|
|
||||||
1. **`handleSearch`**: 追踪搜索查询
|
|
||||||
2. **`handleSortChange`**: 追踪排序变化
|
|
||||||
3. **`handleDateChange`**: 追踪日期变化
|
|
||||||
4. **`handlePageChange`**: 追踪翻页
|
|
||||||
5. **`handleConceptClick`**: 追踪概念点击
|
|
||||||
6. **`handleViewStocks`**: 追踪查看个股
|
|
||||||
7. **`handleViewContent`**: 追踪历史时间轴
|
|
||||||
8. **视图切换按钮**: 追踪网格/列表切换
|
|
||||||
|
|
||||||
#### 3. `src/views/Home/HomePage.js`
|
|
||||||
|
|
||||||
**添加的导入**:
|
|
||||||
```javascript
|
|
||||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
|
||||||
import { ACQUISITION_EVENTS } from '../../lib/constants';
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的 Hook**:
|
|
||||||
```javascript
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的 useEffect**(页面浏览追踪):
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
is_authenticated: isAuthenticated,
|
|
||||||
user_id: user?.id || null,
|
|
||||||
});
|
|
||||||
}, [track, isAuthenticated, user?.id]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改的函数**:
|
|
||||||
- **`handleProductClick`**: 从接收 URL 改为接收完整 feature 对象,添加追踪逻辑
|
|
||||||
|
|
||||||
**修改后的代码**:
|
|
||||||
```javascript
|
|
||||||
const handleProductClick = useCallback((feature) => {
|
|
||||||
// 🎯 PostHog 追踪:功能卡片点击
|
|
||||||
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
|
|
||||||
feature_id: feature.id,
|
|
||||||
feature_title: feature.title,
|
|
||||||
feature_url: feature.url,
|
|
||||||
is_featured: feature.featured || false,
|
|
||||||
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 原有导航逻辑
|
|
||||||
if (feature.url.startsWith('http')) {
|
|
||||||
window.open(feature.url, '_blank');
|
|
||||||
} else {
|
|
||||||
navigate(feature.url);
|
|
||||||
}
|
|
||||||
}, [track, navigate]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**更新的 onClick 事件**:
|
|
||||||
```javascript
|
|
||||||
// 从
|
|
||||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
|
||||||
|
|
||||||
// 改为
|
|
||||||
onClick={() => handleProductClick(coreFeatures[0])}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1. `src/views/Community/hooks/useEventFilters.js`
|
|
||||||
|
|
||||||
**添加的导入**:
|
|
||||||
```javascript
|
|
||||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的Hook**:
|
|
||||||
```javascript
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改的函数**:
|
|
||||||
1. **`updateFilters`**: 追踪搜索和筛选
|
|
||||||
2. **`handlePageChange`**: 追踪翻页
|
|
||||||
3. **`handleEventClick`**: 追踪新闻点击
|
|
||||||
4. **`handleViewDetail`**: 追踪详情查看
|
|
||||||
|
|
||||||
#### 2. `src/views/Community/index.js`
|
|
||||||
|
|
||||||
**添加的导入**:
|
|
||||||
```javascript
|
|
||||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS } from '../../lib/constants';
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的Hook**:
|
|
||||||
```javascript
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
```
|
|
||||||
|
|
||||||
**添加的useEffect**:
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
has_hot_events: hotEvents && hotEvents.length > 0,
|
|
||||||
has_keywords: popularKeywords && popularKeywords.length > 0,
|
|
||||||
});
|
|
||||||
}, [track]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 追踪效果示例
|
|
||||||
|
|
||||||
### 用户行为路径示例
|
|
||||||
|
|
||||||
**首页转化路径**:
|
|
||||||
```
|
|
||||||
1. 游客访问首页
|
|
||||||
→ 触发: LANDING_PAGE_VIEWED
|
|
||||||
→ 属性: { is_authenticated: false, user_id: null }
|
|
||||||
|
|
||||||
2. 点击"新闻中心"功能卡片
|
|
||||||
→ 触发: FEATURE_CARD_CLICKED
|
|
||||||
→ 属性: { feature_id: "news-catalyst", feature_title: "新闻中心", is_featured: true, link_type: "internal" }
|
|
||||||
|
|
||||||
3. 进入 Community 页面
|
|
||||||
→ 触发: COMMUNITY_PAGE_VIEWED
|
|
||||||
```
|
|
||||||
|
|
||||||
**Community 页面行为路径**:
|
|
||||||
```
|
|
||||||
1. 用户进入 Community 页面
|
|
||||||
→ 触发: COMMUNITY_PAGE_VIEWED
|
|
||||||
|
|
||||||
2. 用户搜索 "人工智能"
|
|
||||||
→ 触发: SEARCH_QUERY_SUBMITTED
|
|
||||||
→ 属性: { query: "人工智能", category: "news" }
|
|
||||||
|
|
||||||
3. 用户筛选 "重要性:高"
|
|
||||||
→ 触发: SEARCH_FILTER_APPLIED
|
|
||||||
→ 属性: { filter_type: "importance", filter_value: "high" }
|
|
||||||
|
|
||||||
4. 用户点击第一条新闻
|
|
||||||
→ 触发: NEWS_ARTICLE_CLICKED
|
|
||||||
→ 属性: { event_id: "123", event_title: "...", importance: "high", source: "community_page" }
|
|
||||||
|
|
||||||
5. 用户翻到第2页
|
|
||||||
→ 触发: NEWS_LIST_VIEWED
|
|
||||||
→ 属性: { page: 2, filters: { sort: "new", importance: "high", has_query: true } }
|
|
||||||
|
|
||||||
6. 用户点击"查看详情"
|
|
||||||
→ 触发: NEWS_DETAIL_OPENED
|
|
||||||
→ 属性: { event_id: "456", source: "community_page" }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 测试方法
|
|
||||||
|
|
||||||
### 1. 使用 Redux DevTools
|
|
||||||
|
|
||||||
1. 打开应用:`npm start`
|
|
||||||
2. 打开浏览器 Redux DevTools
|
|
||||||
3. 筛选 `posthog/trackEvent` actions
|
|
||||||
4. 执行各种操作
|
|
||||||
5. 查看追踪的事件和属性
|
|
||||||
|
|
||||||
### 2. 控制台日志
|
|
||||||
|
|
||||||
开发环境下,PostHog 会自动输出日志:
|
|
||||||
|
|
||||||
```
|
|
||||||
📍 Event tracked: Community Page Viewed { timestamp: "...", has_hot_events: true }
|
|
||||||
📍 Event tracked: Search Query Submitted { query: "人工智能", category: "news" }
|
|
||||||
📍 Event tracked: Search Filter Applied { filter_type: "importance", filter_value: "high" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. PostHog Dashboard
|
|
||||||
|
|
||||||
1. 登录 PostHog 后台
|
|
||||||
2. 查看 "Events" 页面
|
|
||||||
3. 筛选 Community 相关事件:
|
|
||||||
- `Community Page Viewed`
|
|
||||||
- `Search Query Submitted`
|
|
||||||
- `Search Filter Applied`
|
|
||||||
- `News Article Clicked`
|
|
||||||
- `News List Viewed`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 数据分析建议
|
|
||||||
|
|
||||||
### 1. 搜索行为分析
|
|
||||||
|
|
||||||
**问题**: 用户最常搜索什么?
|
|
||||||
|
|
||||||
**方法**:
|
|
||||||
- 筛选 `SEARCH_QUERY_SUBMITTED` 事件
|
|
||||||
- 按 `query` 属性分组
|
|
||||||
- 查看 Top 关键词
|
|
||||||
|
|
||||||
### 2. 筛选偏好分析
|
|
||||||
|
|
||||||
**问题**: 用户更喜欢什么排序方式?
|
|
||||||
|
|
||||||
**方法**:
|
|
||||||
- 筛选 `SEARCH_FILTER_APPLIED` 事件
|
|
||||||
- 按 `filter_type: "sort"` 筛选
|
|
||||||
- 按 `filter_value` 分组统计
|
|
||||||
|
|
||||||
### 3. 新闻热度分析
|
|
||||||
|
|
||||||
**问题**: 哪些新闻最受欢迎?
|
|
||||||
|
|
||||||
**方法**:
|
|
||||||
- 筛选 `NEWS_ARTICLE_CLICKED` 事件
|
|
||||||
- 按 `event_id` 分组
|
|
||||||
- 统计点击次数
|
|
||||||
|
|
||||||
### 4. 用户旅程分析
|
|
||||||
|
|
||||||
**问题**: 用户从搜索到点击的转化率?
|
|
||||||
|
|
||||||
**方法**:
|
|
||||||
- 创建漏斗:
|
|
||||||
1. `COMMUNITY_PAGE_VIEWED`
|
|
||||||
2. `SEARCH_QUERY_SUBMITTED`
|
|
||||||
3. `NEWS_ARTICLE_CLICKED`
|
|
||||||
- 分析每一步的流失率
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 扩展计划
|
|
||||||
|
|
||||||
### 下一步:其他页面追踪
|
|
||||||
|
|
||||||
按优先级排序:
|
|
||||||
|
|
||||||
1. **Concept(概念中心)** ⭐⭐⭐
|
|
||||||
- 搜索概念
|
|
||||||
- 点击概念卡片
|
|
||||||
- 查看概念详情
|
|
||||||
- 点击概念内股票
|
|
||||||
|
|
||||||
2. **StockOverview(个股中心)** ⭐⭐⭐
|
|
||||||
- 搜索股票
|
|
||||||
- 点击股票卡片
|
|
||||||
- 查看股票详情
|
|
||||||
- 切换 Tab
|
|
||||||
|
|
||||||
3. **LimitAnalyse(涨停分析)** ⭐⭐
|
|
||||||
- 进入页面
|
|
||||||
- 点击涨停板块
|
|
||||||
- 展开板块详情
|
|
||||||
- 点击涨停个股
|
|
||||||
|
|
||||||
4. **TradingSimulation(模拟盘)** ⭐⭐
|
|
||||||
- 进入模拟盘
|
|
||||||
- 下单操作
|
|
||||||
- 查看持仓
|
|
||||||
- 查看历史
|
|
||||||
|
|
||||||
5. **Company(公司详情)** ⭐
|
|
||||||
- 查看公司概览
|
|
||||||
- 查看财务全景
|
|
||||||
- 查看盈利预测
|
|
||||||
- Tab 切换
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 最佳实践
|
|
||||||
|
|
||||||
### 1. 属性命名规范
|
|
||||||
|
|
||||||
- 使用 **snake_case** 命名(与 PostHog 推荐一致)
|
|
||||||
- 属性名要 **描述性强**,易于理解
|
|
||||||
- 使用 **布尔值** 表示是/否(has_xxx, is_xxx)
|
|
||||||
- 使用 **枚举值** 表示类别(filter_type: "sort")
|
|
||||||
|
|
||||||
### 2. 事件追踪原则
|
|
||||||
|
|
||||||
- **追踪用户意图**,而不仅仅是点击
|
|
||||||
- **添加上下文**,帮助分析(previous_value, source)
|
|
||||||
- **保持一致性**,相似事件使用相似属性
|
|
||||||
- **避免敏感信息**,不追踪用户隐私数据
|
|
||||||
|
|
||||||
### 3. 性能优化
|
|
||||||
|
|
||||||
- 使用 **`usePostHogTrack`** 而不是 `usePostHogRedux`
|
|
||||||
- 更轻量,只订阅追踪功能
|
|
||||||
- 避免不必要的重渲染
|
|
||||||
- 在 **Custom Hooks** 中集成,而不是每个组件
|
|
||||||
- 集中管理,易于维护
|
|
||||||
- 减少重复代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 1. 依赖管理
|
|
||||||
|
|
||||||
确保 `useCallback` 的依赖数组包含 `track`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 正确
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
track(EVENT_NAME, { ... });
|
|
||||||
}, [track]);
|
|
||||||
|
|
||||||
// ❌ 错误(缺少 track)
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
track(EVENT_NAME, { ... });
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 事件去重
|
|
||||||
|
|
||||||
避免重复追踪相同事件:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 正确(只在值变化时追踪)
|
|
||||||
if (newFilters.sort !== filters.sort) {
|
|
||||||
track(SEARCH_FILTER_APPLIED, { ... });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ 错误(每次都追踪)
|
|
||||||
track(SEARCH_FILTER_APPLIED, { ... });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 空值处理
|
|
||||||
|
|
||||||
使用安全的属性访问:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 正确
|
|
||||||
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0)
|
|
||||||
|
|
||||||
// ❌ 错误(可能报错)
|
|
||||||
has_stocks: event.related_stocks.length > 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 参考资料
|
|
||||||
|
|
||||||
- **PostHog Events 文档**: https://posthog.com/docs/data/events
|
|
||||||
- **PostHog Properties 文档**: https://posthog.com/docs/data/properties
|
|
||||||
- **Redux PostHog 集成**: `POSTHOG_REDUX_INTEGRATION.md`
|
|
||||||
- **事件常量定义**: `src/lib/constants.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 总结
|
|
||||||
|
|
||||||
### 已实现的功能
|
|
||||||
|
|
||||||
- ✅ Home 页面追踪(2个事件)
|
|
||||||
- ✅ StockOverview 页面完整追踪(10个事件)✨ 已完成
|
|
||||||
- ✅ Concept 页面完整追踪(9个事件)
|
|
||||||
- ✅ Company 页面完整追踪(5个事件)
|
|
||||||
- ✅ Community 页面完整追踪(7个事件)
|
|
||||||
- ✅ Custom Hook 集成方案
|
|
||||||
- ✅ Redux DevTools 调试支持
|
|
||||||
- ✅ 详细的事件属性
|
|
||||||
|
|
||||||
### 追踪的用户行为
|
|
||||||
|
|
||||||
**Home 页面**:
|
|
||||||
1. **页面访问** - 了解流量来源、登录转化率
|
|
||||||
2. **功能卡片点击** - 识别最受欢迎的功能
|
|
||||||
3. **推荐功能效果** - 分析特色功能(新闻中心)的点击率
|
|
||||||
|
|
||||||
**StockOverview 页面** ✨:
|
|
||||||
1. **页面访问** - 了解个股中心流量
|
|
||||||
2. **搜索行为** - 股票搜索、搜索结果点击
|
|
||||||
3. **概念交互** - 热门概念点击、概念股票标签点击
|
|
||||||
4. **热力图交互** - 热力图中股票点击
|
|
||||||
5. **数据筛选** - 日期选择变化
|
|
||||||
6. **市场统计** - 市场数据查看
|
|
||||||
|
|
||||||
**Concept 页面**:
|
|
||||||
1. **页面访问** - 了解概念中心流量
|
|
||||||
2. **搜索行为** - 概念搜索、搜索结果数量
|
|
||||||
3. **筛选偏好** - 排序方式、日期选择
|
|
||||||
4. **概念交互** - 概念点击、位置追踪
|
|
||||||
5. **个股查看** - 查看个股、股票点击
|
|
||||||
6. **时间轴查看** - 历史时间轴
|
|
||||||
7. **翻页行为** - 优化分页逻辑
|
|
||||||
8. **视图切换** - 网格/列表偏好
|
|
||||||
|
|
||||||
**Company 页面**:
|
|
||||||
1. **页面访问** - 了解公司详情页流量
|
|
||||||
2. **股票搜索** - 用户查询哪些股票
|
|
||||||
3. **Tab 切换** - 用户最关注哪个 Tab(概览/行情/财务/预测)
|
|
||||||
4. **自选股管理** - 自选股添加/移除行为
|
|
||||||
5. **股票切换** - 分析用户查看股票的路径
|
|
||||||
|
|
||||||
**Community 页面**:
|
|
||||||
1. **页面访问** - 了解流量来源
|
|
||||||
2. **搜索行为** - 了解用户需求
|
|
||||||
3. **筛选偏好** - 优化默认设置
|
|
||||||
4. **内容点击** - 识别热门内容
|
|
||||||
5. **详情查看** - 分析用户兴趣
|
|
||||||
6. **翻页行为** - 优化分页逻辑
|
|
||||||
|
|
||||||
### 下一步计划
|
|
||||||
|
|
||||||
1. ~~在关键页面实施追踪(Home, StockOverview, Concept, Company, Community)~~ ✅ 已完成
|
|
||||||
2. **下一步**:其他页面追踪
|
|
||||||
- LimitAnalyse(涨停分析)⭐⭐
|
|
||||||
- TradingSimulation(模拟盘)⭐⭐
|
|
||||||
3. 创建 PostHog Dashboard 和 Insights
|
|
||||||
4. 设置用户行为漏斗分析
|
|
||||||
5. 配置 Feature Flags 进行 A/B 测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Home, StockOverview, Concept, Company, Community 页面追踪全部完成!** 🚀
|
|
||||||
|
|
||||||
现在你可以在 PostHog 后台看到完整的用户行为数据:
|
|
||||||
- **首页** → **个股中心/概念中心/公司详情/新闻中心** 的完整转化路径
|
|
||||||
- **搜索行为**、**筛选偏好**、**内容点击** 的详细数据
|
|
||||||
- **Tab 切换**、**视图切换**、**翻页行为** 的用户习惯分析
|
|
||||||
- **自选股管理** 的用户行为追踪
|
|
||||||
|
|
||||||
共追踪 **33个事件**,覆盖 **5个核心页面**。
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
# PostHog 集成完成总结
|
|
||||||
|
|
||||||
## ✅ 已完成的工作
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
```bash
|
|
||||||
npm install posthog-js@^1.280.1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 创建核心文件
|
|
||||||
|
|
||||||
#### 📦 PostHog SDK 封装 (`src/lib/posthog.js`)
|
|
||||||
- 提供完整的 PostHog API 封装
|
|
||||||
- 包含函数:
|
|
||||||
- `initPostHog()` - 初始化 SDK
|
|
||||||
- `identifyUser()` - 识别用户
|
|
||||||
- `trackEvent()` - 追踪自定义事件
|
|
||||||
- `trackPageView()` - 追踪页面浏览
|
|
||||||
- `resetUser()` - 重置用户会话(登出时调用)
|
|
||||||
- `optIn()` / `optOut()` - 用户隐私控制
|
|
||||||
- `getFeatureFlag()` - 获取 Feature Flag(A/B 测试)
|
|
||||||
|
|
||||||
#### 📊 事件常量定义 (`src/lib/constants.js`)
|
|
||||||
基于 AARRR 框架的完整事件体系:
|
|
||||||
- **Acquisition(获客)**: Landing Page, CTA, Pricing
|
|
||||||
- **Activation(激活)**: Login, Signup, WeChat QR
|
|
||||||
- **Retention(留存)**: Dashboard, News, Concept, Stock, Company
|
|
||||||
- **Referral(推荐)**: Share, Invite
|
|
||||||
- **Revenue(收入)**: Payment, Subscription
|
|
||||||
|
|
||||||
#### 🪝 React Hooks
|
|
||||||
- `usePostHog` (`src/hooks/usePostHog.js`) - 在组件中使用 PostHog
|
|
||||||
- `usePageTracking` (`src/hooks/usePageTracking.js`) - 自动页面浏览追踪
|
|
||||||
|
|
||||||
#### 🎁 Provider 组件 (`src/components/PostHogProvider.js`)
|
|
||||||
- 全局初始化 PostHog
|
|
||||||
- 自动追踪页面浏览
|
|
||||||
- 根据路由自动识别页面类型
|
|
||||||
|
|
||||||
### 3. 集成到应用
|
|
||||||
|
|
||||||
#### App.js 修改
|
|
||||||
在最外层添加了 `PostHogProvider`:
|
|
||||||
```jsx
|
|
||||||
<PostHogProvider>
|
|
||||||
<ReduxProvider store={store}>
|
|
||||||
<ChakraProvider theme={theme}>
|
|
||||||
{/* 其他 Providers */}
|
|
||||||
</ChakraProvider>
|
|
||||||
</ReduxProvider>
|
|
||||||
</PostHogProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 环境变量配置
|
|
||||||
|
|
||||||
`.env` 文件中添加了:
|
|
||||||
```bash
|
|
||||||
# PostHog API Key(需要填写你的 PostHog 项目 Key)
|
|
||||||
REACT_APP_POSTHOG_KEY=
|
|
||||||
|
|
||||||
# PostHog API Host
|
|
||||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
|
||||||
|
|
||||||
# Session Recording 开关
|
|
||||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 如何使用
|
|
||||||
|
|
||||||
### 1. 配置 PostHog API Key
|
|
||||||
|
|
||||||
1. 登录 [PostHog](https://app.posthog.com)
|
|
||||||
2. 创建项目(或使用现有项目)
|
|
||||||
3. 在项目设置中找到 **API Key**
|
|
||||||
4. 复制 API Key 并填入 `.env` 文件:
|
|
||||||
```bash
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 自动追踪页面浏览
|
|
||||||
|
|
||||||
✅ **无需额外配置**,PostHogProvider 会自动追踪所有路由变化和页面浏览。
|
|
||||||
|
|
||||||
### 3. 追踪自定义事件
|
|
||||||
|
|
||||||
在任意组件中使用 `usePostHog` Hook:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { usePostHog } from 'hooks/usePostHog';
|
|
||||||
import { RETENTION_EVENTS } from 'lib/constants';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { track } = usePostHog();
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
|
||||||
article_id: '12345',
|
|
||||||
article_title: '市场分析报告',
|
|
||||||
source: 'community_page',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return <button onClick={handleClick}>阅读文章</button>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 用户识别(登录时)
|
|
||||||
|
|
||||||
在 `AuthContext` 中,登录成功后调用:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { identifyUser } from 'lib/posthog';
|
|
||||||
|
|
||||||
// 登录成功后
|
|
||||||
identifyUser(user.id, {
|
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
|
||||||
subscription_tier: user.subscription_type || 'free',
|
|
||||||
registration_date: user.created_at,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 重置用户会话(登出时)
|
|
||||||
|
|
||||||
在 `AuthContext` 中,登出时调用:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { resetUser } from 'lib/posthog';
|
|
||||||
|
|
||||||
// 登出时
|
|
||||||
resetUser();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 PostHog 功能
|
|
||||||
|
|
||||||
### 1. 页面浏览分析
|
|
||||||
- 自动追踪所有页面访问
|
|
||||||
- 分析用户访问路径
|
|
||||||
- 识别热门页面
|
|
||||||
|
|
||||||
### 2. 用户行为分析
|
|
||||||
- 追踪用户点击、搜索、筛选等行为
|
|
||||||
- 分析功能使用频率
|
|
||||||
- 了解用户偏好
|
|
||||||
|
|
||||||
### 3. 漏斗分析
|
|
||||||
- 分析用户转化路径
|
|
||||||
- 识别流失点
|
|
||||||
- 优化用户体验
|
|
||||||
|
|
||||||
### 4. 队列分析(Cohort Analysis)
|
|
||||||
- 按注册时间、订阅类型等分组用户
|
|
||||||
- 分析不同用户群体的行为差异
|
|
||||||
|
|
||||||
### 5. Session Recording(可选)
|
|
||||||
- 录制用户操作视频
|
|
||||||
- 可视化用户体验问题
|
|
||||||
- 需要在 `.env` 中开启:`REACT_APP_ENABLE_SESSION_RECORDING=true`
|
|
||||||
|
|
||||||
### 6. Feature Flags(A/B 测试)
|
|
||||||
```jsx
|
|
||||||
const { getFlag, isEnabled } = usePostHog();
|
|
||||||
|
|
||||||
// 检查功能开关
|
|
||||||
if (isEnabled('new_dashboard_design')) {
|
|
||||||
return <NewDashboard />;
|
|
||||||
} else {
|
|
||||||
return <OldDashboard />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 隐私和安全
|
|
||||||
|
|
||||||
### 自动隐私保护
|
|
||||||
- 自动屏蔽密码、邮箱、手机号输入框
|
|
||||||
- 不追踪敏感 API 端点(`/api/auth/login`, `/api/payment` 等)
|
|
||||||
- 尊重浏览器 Do Not Track 设置
|
|
||||||
|
|
||||||
### 用户隐私控制
|
|
||||||
用户可选择退出追踪:
|
|
||||||
```jsx
|
|
||||||
const { optOut, optIn, isOptedOut } = usePostHog();
|
|
||||||
|
|
||||||
// 退出追踪
|
|
||||||
optOut();
|
|
||||||
|
|
||||||
// 重新加入
|
|
||||||
optIn();
|
|
||||||
|
|
||||||
// 检查状态
|
|
||||||
if (isOptedOut()) {
|
|
||||||
console.log('用户已退出追踪');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 下一步建议
|
|
||||||
|
|
||||||
### 1. 在关键页面添加事件追踪
|
|
||||||
|
|
||||||
例如在 **Community**、**Concept**、**Stock** 等页面添加:
|
|
||||||
- 搜索事件
|
|
||||||
- 点击事件
|
|
||||||
- 筛选事件
|
|
||||||
|
|
||||||
### 2. 在 AuthContext 中集成用户识别
|
|
||||||
|
|
||||||
登录成功时调用 `identifyUser()`,登出时调用 `resetUser()`
|
|
||||||
|
|
||||||
### 3. 设置 Feature Flags
|
|
||||||
|
|
||||||
在 PostHog 后台创建 Feature Flags,用于 A/B 测试新功能
|
|
||||||
|
|
||||||
### 4. 配置 Dashboard 和 Insights
|
|
||||||
|
|
||||||
在 PostHog 后台创建:
|
|
||||||
- 用户活跃度 Dashboard
|
|
||||||
- 功能使用频率 Insights
|
|
||||||
- 转化漏斗分析
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 参考资料
|
|
||||||
|
|
||||||
- [PostHog 官方文档](https://posthog.com/docs)
|
|
||||||
- [PostHog React 集成](https://posthog.com/docs/libraries/react)
|
|
||||||
- [PostHog Feature Flags](https://posthog.com/docs/feature-flags)
|
|
||||||
- [PostHog Session Recording](https://posthog.com/docs/session-replay)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
1. **开发环境下会自动启用调试模式**,控制台会输出详细的追踪日志
|
|
||||||
2. **PostHog API Key 为空时**,SDK 会发出警告但不会影响应用运行
|
|
||||||
3. **Session Recording 默认关闭**,需要时再开启以节省资源
|
|
||||||
4. **所有事件常量已定义**在 `src/lib/constants.js`,使用时直接导入
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**集成完成!** 🎉
|
|
||||||
|
|
||||||
现在你可以:
|
|
||||||
1. 填写 PostHog API Key
|
|
||||||
2. 启动应用:`npm start`
|
|
||||||
3. 在 PostHog 后台查看实时数据
|
|
||||||
|
|
||||||
如有问题,请参考 PostHog 官方文档或联系技术支持。
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
# PostHog Redux 集成完成总结
|
|
||||||
|
|
||||||
## ✅ 已完成的工作
|
|
||||||
|
|
||||||
PostHog 已成功从 **React Context** 迁移到 **Redux** 进行全局状态管理!
|
|
||||||
|
|
||||||
### 1. 创建的核心文件
|
|
||||||
|
|
||||||
#### 📦 Redux Slice (`src/store/slices/posthogSlice.js`)
|
|
||||||
完整的 PostHog 状态管理:
|
|
||||||
- **State 管理**: 初始化状态、用户信息、事件队列、Feature Flags
|
|
||||||
- **Async Thunks**:
|
|
||||||
- `initializePostHog()` - 初始化 SDK
|
|
||||||
- `identifyUser()` - 识别用户
|
|
||||||
- `resetUser()` - 重置会话
|
|
||||||
- `trackEvent()` - 追踪事件
|
|
||||||
- `flushCachedEvents()` - 刷新离线事件
|
|
||||||
- **Selectors**: 提供便捷的状态选择器
|
|
||||||
|
|
||||||
#### ⚡ Redux Middleware (`src/store/middleware/posthogMiddleware.js`)
|
|
||||||
自动追踪中间件:
|
|
||||||
- **自动拦截 Actions**: 当特定 Redux actions 被 dispatch 时自动追踪
|
|
||||||
- **路由追踪**: 自动识别页面类型并追踪浏览
|
|
||||||
- **离线事件缓存**: 网络恢复时自动刷新缓存事件
|
|
||||||
- **性能追踪**: 追踪耗时较长的操作
|
|
||||||
|
|
||||||
**自动追踪的 Actions**:
|
|
||||||
```javascript
|
|
||||||
'auth/login/fulfilled' → USER_LOGGED_IN
|
|
||||||
'auth/logout' → USER_LOGGED_OUT
|
|
||||||
'communityData/fetchHotEvents/fulfilled' → NEWS_LIST_VIEWED
|
|
||||||
'payment/success' → PAYMENT_SUCCESSFUL
|
|
||||||
// ... 更多
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 🪝 React Hooks (`src/hooks/usePostHogRedux.js`)
|
|
||||||
提供便捷的 API:
|
|
||||||
- `usePostHogRedux()` - 完整功能 Hook
|
|
||||||
- `usePostHogTrack()` - 仅追踪功能(性能优化)
|
|
||||||
- `usePostHogFlags()` - 仅 Feature Flags(性能优化)
|
|
||||||
- `usePostHogUser()` - 仅用户管理(性能优化)
|
|
||||||
|
|
||||||
### 2. 修改的文件
|
|
||||||
|
|
||||||
#### Redux Store (`src/store/index.js`)
|
|
||||||
```javascript
|
|
||||||
import posthogReducer from './slices/posthogSlice';
|
|
||||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
|
||||||
|
|
||||||
export const store = configureStore({
|
|
||||||
reducer: {
|
|
||||||
communityData: communityDataReducer,
|
|
||||||
posthog: posthogReducer, // ✅ 新增
|
|
||||||
},
|
|
||||||
middleware: (getDefaultMiddleware) =>
|
|
||||||
getDefaultMiddleware({...})
|
|
||||||
.concat(posthogMiddleware), // ✅ 新增
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### App.js
|
|
||||||
- ❌ 移除了 `<PostHogProvider>` 包装
|
|
||||||
- ✅ 在 `AppContent` 中添加 Redux 初始化:
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(initializePostHog());
|
|
||||||
}, [dispatch]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 保留的文件(仍然需要)
|
|
||||||
|
|
||||||
- ✅ `src/lib/posthog.js` - PostHog SDK 封装
|
|
||||||
- ✅ `src/lib/constants.js` - 事件常量(AARRR 框架)
|
|
||||||
- ✅ `src/hooks/usePostHog.js` - 原 Hook(可选保留,兼容旧代码)
|
|
||||||
|
|
||||||
### 4. 可以删除的文件(不再需要)
|
|
||||||
|
|
||||||
- ❌ `src/components/PostHogProvider.js` - 改用 Redux 管理
|
|
||||||
- ❌ `src/hooks/usePageTracking.js` - 改由 Middleware 处理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Redux 方案的优势
|
|
||||||
|
|
||||||
### 1. **集中式状态管理**
|
|
||||||
PostHog 状态与其他应用状态统一管理,便于维护和调试。
|
|
||||||
|
|
||||||
### 2. **自动追踪**
|
|
||||||
通过 Middleware 自动拦截 Redux actions,无需手动调用追踪。
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 旧方案(手动追踪)
|
|
||||||
const handleLogin = () => {
|
|
||||||
// ... 登录逻辑
|
|
||||||
track(ACTIVATION_EVENTS.USER_LOGGED_IN, { ... });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 新方案(自动追踪)
|
|
||||||
const handleLogin = () => {
|
|
||||||
dispatch(loginUser({ ... })); // ✅ Middleware 自动追踪
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Redux DevTools 集成**
|
|
||||||
可以在 Redux DevTools 中查看所有 PostHog 事件:
|
|
||||||
|
|
||||||
```
|
|
||||||
Action: posthog/trackEvent/fulfilled
|
|
||||||
Payload: {
|
|
||||||
eventName: "News Article Clicked",
|
|
||||||
properties: { article_id: "123" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **离线事件缓存**
|
|
||||||
自动缓存离线时的事件,网络恢复后批量发送。
|
|
||||||
|
|
||||||
### 5. **时间旅行调试**
|
|
||||||
可以回放和调试用户行为,定位问题更容易。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 使用指南
|
|
||||||
|
|
||||||
### 1. 基础用法 - 追踪自定义事件
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS } from 'lib/constants';
|
|
||||||
|
|
||||||
function NewsArticle({ article }) {
|
|
||||||
const { track } = usePostHogRedux();
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
|
||||||
article_id: article.id,
|
|
||||||
article_title: article.title,
|
|
||||||
source: 'community_page',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div onClick={handleClick}>{article.title}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 用户识别(登录时)
|
|
||||||
|
|
||||||
在 `AuthContext` 或登录成功回调中:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
|
||||||
|
|
||||||
function AuthContext() {
|
|
||||||
const { identify, reset } = usePostHogRedux();
|
|
||||||
|
|
||||||
const handleLoginSuccess = (user) => {
|
|
||||||
// 识别用户
|
|
||||||
identify(user.id, {
|
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
|
||||||
subscription_tier: user.subscription_type || 'free',
|
|
||||||
registration_date: user.created_at,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
// 重置用户会话
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
return { handleLoginSuccess, handleLogout };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Feature Flags(A/B 测试)
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { usePostHogFlags } from 'hooks/usePostHogRedux';
|
|
||||||
|
|
||||||
function Dashboard() {
|
|
||||||
const { isEnabled } = usePostHogFlags();
|
|
||||||
|
|
||||||
if (isEnabled('new_dashboard_design')) {
|
|
||||||
return <NewDashboard />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <OldDashboard />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 自动追踪(推荐)
|
|
||||||
|
|
||||||
**无需手动追踪**,只需 dispatch Redux action,Middleware 会自动处理:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// ✅ 登录时自动追踪
|
|
||||||
dispatch(loginUser({ email, password }));
|
|
||||||
// → Middleware 自动追踪 USER_LOGGED_IN
|
|
||||||
|
|
||||||
// ✅ 获取新闻时自动追踪
|
|
||||||
dispatch(fetchHotEvents());
|
|
||||||
// → Middleware 自动追踪 NEWS_LIST_VIEWED
|
|
||||||
|
|
||||||
// ✅ 支付成功时自动追踪
|
|
||||||
dispatch(paymentSuccess({ amount, transactionId }));
|
|
||||||
// → Middleware 自动追踪 PAYMENT_SUCCESSFUL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 性能优化 Hook
|
|
||||||
|
|
||||||
如果只需要追踪功能,使用轻量级 Hook:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { usePostHogTrack } from 'hooks/usePostHogRedux';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { track } = usePostHogTrack(); // ✅ 只订阅追踪功能
|
|
||||||
|
|
||||||
// 不会因为 PostHog 状态变化而重新渲染
|
|
||||||
return <button onClick={() => track('Button Clicked')}>Click</button>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 配置自动追踪规则
|
|
||||||
|
|
||||||
在 `src/store/middleware/posthogMiddleware.js` 中添加新规则:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const ACTION_TO_EVENT_MAP = {
|
|
||||||
// 添加你的 action
|
|
||||||
'myFeature/actionName': {
|
|
||||||
event: RETENTION_EVENTS.MY_EVENT,
|
|
||||||
getProperties: (action) => ({
|
|
||||||
property1: action.payload?.value1,
|
|
||||||
property2: action.payload?.value2,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 调试技巧
|
|
||||||
|
|
||||||
### 1. Redux DevTools
|
|
||||||
|
|
||||||
打开 Redux DevTools,筛选 `posthog/` actions:
|
|
||||||
|
|
||||||
```
|
|
||||||
posthog/initializePostHog/fulfilled
|
|
||||||
posthog/identifyUser/fulfilled
|
|
||||||
posthog/trackEvent/fulfilled
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 查看 PostHog 状态
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { selectPostHog } from 'store/slices/posthogSlice';
|
|
||||||
|
|
||||||
function DebugPanel() {
|
|
||||||
const posthog = useSelector(selectPostHog);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<pre>{JSON.stringify(posthog, null, 2)}</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 控制台日志
|
|
||||||
|
|
||||||
开发环境下会自动输出日志:
|
|
||||||
|
|
||||||
```
|
|
||||||
[PostHog Middleware] 自动追踪事件: User Logged In { user_id: 123 }
|
|
||||||
[PostHog] 📍 Event tracked: News Article Clicked
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 State 结构
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
posthog: {
|
|
||||||
// 初始化状态
|
|
||||||
isInitialized: true,
|
|
||||||
initError: null,
|
|
||||||
|
|
||||||
// 用户信息
|
|
||||||
user: {
|
|
||||||
userId: "123",
|
|
||||||
email: "user@example.com",
|
|
||||||
subscription_tier: "pro"
|
|
||||||
},
|
|
||||||
|
|
||||||
// 事件队列(离线缓存)
|
|
||||||
eventQueue: [
|
|
||||||
{ eventName: "...", properties: {...}, timestamp: "..." }
|
|
||||||
],
|
|
||||||
|
|
||||||
// Feature Flags
|
|
||||||
featureFlags: {
|
|
||||||
new_dashboard_design: true,
|
|
||||||
beta_feature: false
|
|
||||||
},
|
|
||||||
|
|
||||||
// 配置
|
|
||||||
config: {
|
|
||||||
apiKey: "phc_...",
|
|
||||||
apiHost: "https://app.posthog.com",
|
|
||||||
sessionRecording: false
|
|
||||||
},
|
|
||||||
|
|
||||||
// 统计
|
|
||||||
stats: {
|
|
||||||
totalEvents: 150,
|
|
||||||
lastEventTime: "2025-10-28T12:00:00Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 高级功能
|
|
||||||
|
|
||||||
### 1. 手动触发页面浏览
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { trackModalView, trackTabChange } from 'store/middleware/posthogMiddleware';
|
|
||||||
|
|
||||||
// Modal 打开时
|
|
||||||
dispatch(trackModalView('User Settings Modal', { source: 'nav_bar' }));
|
|
||||||
|
|
||||||
// Tab 切换时
|
|
||||||
dispatch(trackTabChange('Related Stocks', { from_tab: 'Overview' }));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 刷新离线事件
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { flushCachedEvents } from 'store/slices/posthogSlice';
|
|
||||||
|
|
||||||
// 网络恢复时自动触发,也可以手动触发
|
|
||||||
dispatch(flushCachedEvents());
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 性能追踪
|
|
||||||
|
|
||||||
给 action 添加时间戳:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { withTiming } from 'store/middleware/posthogMiddleware';
|
|
||||||
|
|
||||||
// 追踪耗时操作
|
|
||||||
dispatch(withTiming(fetchBigData()));
|
|
||||||
// → 如果超过 1 秒,会自动追踪性能事件
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 1. **环境变量**
|
|
||||||
|
|
||||||
确保 `.env` 文件中配置了 PostHog API Key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
|
||||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Redux Middleware 顺序**
|
|
||||||
|
|
||||||
PostHog Middleware 应该在其他 middleware 之后:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
.concat(otherMiddleware)
|
|
||||||
.concat(posthogMiddleware) // ✅ 最后添加
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **避免循环依赖**
|
|
||||||
|
|
||||||
不要在 Middleware 中 dispatch 会触发 Middleware 的 action。
|
|
||||||
|
|
||||||
### 4. **序列化检查**
|
|
||||||
|
|
||||||
已经在 store 配置中忽略了 PostHog actions 的序列化检查。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 从旧版本迁移
|
|
||||||
|
|
||||||
如果你的代码中使用了旧的 `usePostHog` Hook:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// 旧代码
|
|
||||||
import { usePostHog } from 'hooks/usePostHog';
|
|
||||||
const { track } = usePostHog();
|
|
||||||
|
|
||||||
// 新代码(推荐)
|
|
||||||
import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
|
||||||
const { track } = usePostHogRedux();
|
|
||||||
```
|
|
||||||
|
|
||||||
**兼容性**: 旧的 `usePostHog` Hook 仍然可用,但推荐迁移到 Redux 版本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 参考资料
|
|
||||||
|
|
||||||
- [PostHog 官方文档](https://posthog.com/docs)
|
|
||||||
- [Redux Toolkit 文档](https://redux-toolkit.js.org/)
|
|
||||||
- [Redux Middleware 文档](https://redux.js.org/tutorials/fundamentals/part-4-store#middleware)
|
|
||||||
- [AARRR 框架](https://www.productplan.com/glossary/aarrr-framework/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 总结
|
|
||||||
|
|
||||||
PostHog 已成功集成到 Redux!主要优势:
|
|
||||||
|
|
||||||
1. ✅ **自动追踪**: Middleware 自动拦截 actions
|
|
||||||
2. ✅ **集中管理**: 统一的 Redux 状态管理
|
|
||||||
3. ✅ **调试友好**: Redux DevTools 支持
|
|
||||||
4. ✅ **离线支持**: 自动缓存和刷新事件
|
|
||||||
5. ✅ **性能优化**: 提供多个轻量级 Hooks
|
|
||||||
|
|
||||||
现在你可以:
|
|
||||||
1. 启动应用:`npm start`
|
|
||||||
2. 打开 Redux DevTools 查看 PostHog 状态
|
|
||||||
3. 执行操作(登录、浏览页面、点击按钮)
|
|
||||||
4. 观察自动追踪的事件
|
|
||||||
|
|
||||||
Have fun tracking! 🚀
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
# PostHog 本地上报能力测试指南
|
|
||||||
|
|
||||||
本文档指导您完成 PostHog 事件追踪功能的完整测试。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 准备工作
|
|
||||||
|
|
||||||
### 步骤 1:获取 PostHog API Key
|
|
||||||
|
|
||||||
#### 1.1 登录 PostHog
|
|
||||||
|
|
||||||
打开浏览器,访问:
|
|
||||||
```
|
|
||||||
https://app.posthog.com
|
|
||||||
```
|
|
||||||
|
|
||||||
使用您的账号登录。
|
|
||||||
|
|
||||||
#### 1.2 创建测试项目(如果还没有)
|
|
||||||
|
|
||||||
1. 点击页面左上角的项目切换器
|
|
||||||
2. 点击 "+ Create Project"
|
|
||||||
3. 填写项目信息:
|
|
||||||
- **Project name**: `vf_react_dev`(推荐)或自定义名称
|
|
||||||
- **Organization**: 选择您的组织
|
|
||||||
4. 点击 "Create Project"
|
|
||||||
|
|
||||||
#### 1.3 获取 API Key
|
|
||||||
|
|
||||||
1. 进入项目设置:
|
|
||||||
- 点击左侧边栏底部的 **"Settings"** ⚙️
|
|
||||||
- 选择 **"Project"** 标签
|
|
||||||
|
|
||||||
2. 找到 "Project API Key" 部分
|
|
||||||
- 您会看到一个以 `phc_` 开头的长字符串
|
|
||||||
- 例如:`phc_abcdefghijklmnopqrstuvwxyz1234567890`
|
|
||||||
|
|
||||||
3. 复制 API Key
|
|
||||||
- 点击 API Key 右侧的复制按钮 📋
|
|
||||||
- 或手动选中并复制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 配置本地环境
|
|
||||||
|
|
||||||
### 步骤 2:配置 .env.local
|
|
||||||
|
|
||||||
打开项目根目录的 `.env.local` 文件,找到以下行:
|
|
||||||
|
|
||||||
```env
|
|
||||||
REACT_APP_POSTHOG_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
将您刚才复制的 API Key 粘贴进去:
|
|
||||||
|
|
||||||
```env
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_your_actual_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
**完整示例:**
|
|
||||||
```env
|
|
||||||
# PostHog 配置(本地开发)
|
|
||||||
REACT_APP_POSTHOG_KEY=phc_abcdefghijklmnopqrstuvwxyz1234567890
|
|
||||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
|
||||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ **重要**:保存文件后必须重启应用才能生效!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 启动应用
|
|
||||||
|
|
||||||
### 步骤 3:重启开发服务器
|
|
||||||
|
|
||||||
如果应用正在运行,先停止它:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式 1:使用命令
|
|
||||||
npm run kill-port
|
|
||||||
|
|
||||||
# 方式 2:在终端按 Ctrl+C
|
|
||||||
```
|
|
||||||
|
|
||||||
然后重新启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤 4:验证初始化
|
|
||||||
|
|
||||||
应用启动后,打开浏览器:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
**立即按 F12 打开浏览器控制台**,您应该看到以下日志:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
✅ PostHog initialized successfully
|
|
||||||
📊 PostHog Analytics initialized
|
|
||||||
👤 User identified: user_xxx (如果已登录)
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **如果看到以上日志,说明 PostHog 初始化成功!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 测试事件追踪
|
|
||||||
|
|
||||||
### 测试 1:页面浏览事件
|
|
||||||
|
|
||||||
#### 操作步骤:
|
|
||||||
1. 访问首页:http://localhost:3000
|
|
||||||
2. 导航到社区页面:点击导航栏 "社区"
|
|
||||||
3. 导航到个股中心:点击导航栏 "个股中心"
|
|
||||||
4. 导航到概念中心:点击导航栏 "概念中心"
|
|
||||||
5. 导航到涨停分析:点击导航栏 "涨停分析"
|
|
||||||
|
|
||||||
#### 期待结果:
|
|
||||||
|
|
||||||
**控制台输出:**
|
|
||||||
```javascript
|
|
||||||
[PostHog] Event: $pageview
|
|
||||||
Properties: {
|
|
||||||
$current_url: "http://localhost:3000/community",
|
|
||||||
page_path: "/community",
|
|
||||||
page_type: "feature",
|
|
||||||
feature_name: "community"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**验证方法:**
|
|
||||||
1. 打开 PostHog Dashboard
|
|
||||||
2. 进入 **"Activity" → "Live Events"**
|
|
||||||
3. 观察实时事件流(延迟 1-2 秒)
|
|
||||||
4. 应该看到 `$pageview` 事件,每次页面切换一个
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 测试 2:社区页面交互事件
|
|
||||||
|
|
||||||
#### 操作步骤:
|
|
||||||
|
|
||||||
1. **搜索功能**
|
|
||||||
- 点击搜索框
|
|
||||||
- 输入 "科技"
|
|
||||||
- 按回车搜索
|
|
||||||
|
|
||||||
2. **筛选功能**
|
|
||||||
- 点击 "筛选" 按钮
|
|
||||||
- 选择某个筛选条件
|
|
||||||
- 应用筛选
|
|
||||||
|
|
||||||
3. **内容交互**
|
|
||||||
- 点击任意帖子卡片
|
|
||||||
- 点击用户头像
|
|
||||||
|
|
||||||
#### 期待结果:
|
|
||||||
|
|
||||||
**控制台输出:**
|
|
||||||
```javascript
|
|
||||||
📍 Event tracked: search_initiated
|
|
||||||
context: "community"
|
|
||||||
|
|
||||||
📍 Event tracked: search_query_submitted
|
|
||||||
query: "科技"
|
|
||||||
category: "community"
|
|
||||||
|
|
||||||
📍 Event tracked: filter_applied
|
|
||||||
filter_type: "category"
|
|
||||||
filter_value: "tech"
|
|
||||||
|
|
||||||
📍 Event tracked: post_clicked
|
|
||||||
post_id: "123"
|
|
||||||
post_title: "标题"
|
|
||||||
```
|
|
||||||
|
|
||||||
**PostHog Live Events:**
|
|
||||||
```
|
|
||||||
🔴 search_initiated
|
|
||||||
🔴 search_query_submitted
|
|
||||||
🔴 filter_applied
|
|
||||||
🔴 post_clicked
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 测试 3:个股中心交互事件
|
|
||||||
|
|
||||||
#### 操作步骤:
|
|
||||||
|
|
||||||
1. **搜索股票**
|
|
||||||
- 进入个股中心页面
|
|
||||||
- 点击搜索框
|
|
||||||
- 输入股票名称或代码
|
|
||||||
|
|
||||||
2. **概念交互**
|
|
||||||
- 点击某个概念板块
|
|
||||||
- 点击概念下的股票
|
|
||||||
|
|
||||||
3. **热力图交互**
|
|
||||||
- 点击热力图中的股票方块
|
|
||||||
- 查看股票详情
|
|
||||||
|
|
||||||
#### 期待结果:
|
|
||||||
|
|
||||||
**控制台输出:**
|
|
||||||
```javascript
|
|
||||||
📍 Event tracked: stock_overview_page_viewed
|
|
||||||
|
|
||||||
📍 Event tracked: stock_searched
|
|
||||||
query: "科技股"
|
|
||||||
|
|
||||||
📍 Event tracked: concept_clicked
|
|
||||||
concept_name: "人工智能"
|
|
||||||
|
|
||||||
📍 Event tracked: concept_stock_clicked
|
|
||||||
stock_code: "000001"
|
|
||||||
stock_name: "平安银行"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 测试 4:概念中心交互事件
|
|
||||||
|
|
||||||
#### 操作步骤:
|
|
||||||
|
|
||||||
1. **列表浏览**
|
|
||||||
- 进入概念中心
|
|
||||||
- 切换排序方式
|
|
||||||
|
|
||||||
2. **时间线查看**
|
|
||||||
- 点击某个概念卡片
|
|
||||||
- 打开时间线 Modal
|
|
||||||
- 展开某个日期
|
|
||||||
- 点击新闻/报告
|
|
||||||
|
|
||||||
#### 期待结果:
|
|
||||||
|
|
||||||
**控制台输出:**
|
|
||||||
```javascript
|
|
||||||
📍 Event tracked: concept_list_viewed
|
|
||||||
sort_by: "change_percent_desc"
|
|
||||||
|
|
||||||
📍 Event tracked: concept_clicked
|
|
||||||
concept_name: "芯片"
|
|
||||||
|
|
||||||
📍 Event tracked: concept_detail_viewed
|
|
||||||
concept_name: "芯片"
|
|
||||||
view_type: "timeline_modal"
|
|
||||||
|
|
||||||
📍 Event tracked: timeline_date_toggled
|
|
||||||
date: "2025-01-15"
|
|
||||||
action: "expand"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 测试 5:涨停分析交互事件
|
|
||||||
|
|
||||||
#### 操作步骤:
|
|
||||||
|
|
||||||
1. **日期选择**
|
|
||||||
- 进入涨停分析页面
|
|
||||||
- 选择不同日期
|
|
||||||
|
|
||||||
2. **板块交互**
|
|
||||||
- 展开某个板块
|
|
||||||
- 点击板块名称
|
|
||||||
|
|
||||||
3. **股票交互**
|
|
||||||
- 点击涨停股票
|
|
||||||
- 查看详情
|
|
||||||
|
|
||||||
#### 期待结果:
|
|
||||||
|
|
||||||
**控制台输出:**
|
|
||||||
```javascript
|
|
||||||
📍 Event tracked: limit_analyse_page_viewed
|
|
||||||
|
|
||||||
📍 Event tracked: date_selected
|
|
||||||
date: "20250115"
|
|
||||||
|
|
||||||
📍 Event tracked: sector_toggled
|
|
||||||
sector_name: "科技"
|
|
||||||
action: "expand"
|
|
||||||
|
|
||||||
📍 Event tracked: limit_stock_clicked
|
|
||||||
stock_code: "000001"
|
|
||||||
stock_name: "平安银行"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 验证上报结果
|
|
||||||
|
|
||||||
### 在 PostHog Dashboard 验证
|
|
||||||
|
|
||||||
#### 步骤 1:打开 Live Events
|
|
||||||
|
|
||||||
1. 登录 PostHog Dashboard
|
|
||||||
2. 选择您的测试项目
|
|
||||||
3. 点击左侧菜单 **"Activity"**
|
|
||||||
4. 选择 **"Live Events"**
|
|
||||||
|
|
||||||
#### 步骤 2:观察实时事件流
|
|
||||||
|
|
||||||
您应该看到实时的事件流,格式类似:
|
|
||||||
|
|
||||||
```
|
|
||||||
🔴 LIVE $pageview 1s ago
|
|
||||||
page_path: /community
|
|
||||||
user_id: anonymous_abc123
|
|
||||||
|
|
||||||
🔴 LIVE search_initiated 2s ago
|
|
||||||
context: community
|
|
||||||
|
|
||||||
🔴 LIVE search_query_submitted 3s ago
|
|
||||||
query: "科技"
|
|
||||||
category: "community"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤 3:检查事件属性
|
|
||||||
|
|
||||||
点击任意事件,展开详情,验证:
|
|
||||||
- ✅ 事件名称正确
|
|
||||||
- ✅ 所有属性完整
|
|
||||||
- ✅ 时间戳准确
|
|
||||||
- ✅ 用户信息正确
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 测试清单
|
|
||||||
|
|
||||||
使用以下清单记录测试结果:
|
|
||||||
|
|
||||||
### 页面浏览事件(5项)
|
|
||||||
|
|
||||||
- [ ] 首页浏览 - `$pageview`
|
|
||||||
- [ ] 社区页面浏览 - `community_page_viewed`
|
|
||||||
- [ ] 个股中心浏览 - `stock_overview_page_viewed`
|
|
||||||
- [ ] 概念中心浏览 - `concept_page_viewed`
|
|
||||||
- [ ] 涨停分析浏览 - `limit_analyse_page_viewed`
|
|
||||||
|
|
||||||
### 社区页面事件(6项)
|
|
||||||
|
|
||||||
- [ ] 搜索初始化 - `search_initiated`
|
|
||||||
- [ ] 搜索查询提交 - `search_query_submitted`
|
|
||||||
- [ ] 筛选器应用 - `filter_applied`
|
|
||||||
- [ ] 帖子点击 - `post_clicked`
|
|
||||||
- [ ] 评论点击 - `comment_clicked`
|
|
||||||
- [ ] 用户资料查看 - `user_profile_viewed`
|
|
||||||
|
|
||||||
### 个股中心事件(4项)
|
|
||||||
|
|
||||||
- [ ] 股票搜索 - `stock_searched`
|
|
||||||
- [ ] 概念点击 - `concept_clicked`
|
|
||||||
- [ ] 概念股票点击 - `concept_stock_clicked`
|
|
||||||
- [ ] 热力图股票点击 - `heatmap_stock_clicked`
|
|
||||||
|
|
||||||
### 概念中心事件(5项)
|
|
||||||
|
|
||||||
- [ ] 概念列表查看 - `concept_list_viewed`
|
|
||||||
- [ ] 排序更改 - `sort_changed`
|
|
||||||
- [ ] 概念点击 - `concept_clicked`
|
|
||||||
- [ ] 概念详情查看 - `concept_detail_viewed`
|
|
||||||
- [ ] 新闻/报告点击 - `news_clicked` / `report_clicked`
|
|
||||||
|
|
||||||
### 涨停分析事件(6项)
|
|
||||||
|
|
||||||
- [ ] 页面查看 - `limit_analyse_page_viewed`
|
|
||||||
- [ ] 日期选择 - `date_selected`
|
|
||||||
- [ ] 每日统计查看 - `daily_stats_viewed`
|
|
||||||
- [ ] 板块展开/收起 - `sector_toggled`
|
|
||||||
- [ ] 板块点击 - `sector_clicked`
|
|
||||||
- [ ] 涨停股票点击 - `limit_stock_clicked`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 常见问题
|
|
||||||
|
|
||||||
### 问题 1:控制台没有看到 PostHog 日志
|
|
||||||
|
|
||||||
**可能原因:**
|
|
||||||
- API Key 配置错误
|
|
||||||
- 应用没有重启
|
|
||||||
- 浏览器控制台过滤了日志
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
1. 检查 `.env.local` 中的 API Key 是否正确
|
|
||||||
2. 确保重启了应用:`npm run kill-port && npm start`
|
|
||||||
3. 打开控制台,清除所有过滤器
|
|
||||||
4. 刷新页面
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 2:PostHog Live Events 没有数据
|
|
||||||
|
|
||||||
**可能原因:**
|
|
||||||
- 网络问题
|
|
||||||
- API Key 错误
|
|
||||||
- 项目选择错误
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
1. 打开浏览器网络面板(Network)
|
|
||||||
2. 筛选 XHR 请求,查找 `posthog.com` 的请求
|
|
||||||
3. 检查请求状态码:
|
|
||||||
- `200 OK` → 正常
|
|
||||||
- `401 Unauthorized` → API Key 错误
|
|
||||||
- `404 Not Found` → 项目不存在
|
|
||||||
4. 确认 PostHog Dashboard 选择了正确的项目
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 3:事件上报了,但属性不完整
|
|
||||||
|
|
||||||
**可能原因:**
|
|
||||||
- 代码中传递的参数不完整
|
|
||||||
- 某些状态未正确初始化
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
1. 查看控制台的详细日志
|
|
||||||
2. 对比 PostHog Live Events 中的数据
|
|
||||||
3. 检查对应的事件追踪代码
|
|
||||||
4. 提供反馈给开发团队
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📸 测试截图建议
|
|
||||||
|
|
||||||
为了完整记录测试结果,建议截图:
|
|
||||||
|
|
||||||
1. **PostHog 初始化成功**
|
|
||||||
- 浏览器控制台初始化日志
|
|
||||||
|
|
||||||
2. **Live Events 实时流**
|
|
||||||
- PostHog Dashboard Live Events 页面
|
|
||||||
|
|
||||||
3. **典型事件详情**
|
|
||||||
- 展开某个事件,显示所有属性
|
|
||||||
|
|
||||||
4. **事件统计**
|
|
||||||
- PostHog Insights 或 Trends 页面
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 测试完成后
|
|
||||||
|
|
||||||
测试完成后,您可以:
|
|
||||||
|
|
||||||
1. **保持配置**
|
|
||||||
- 保留 API Key 在 `.env.local` 中
|
|
||||||
- 继续使用控制台 + PostHog Cloud 双模式
|
|
||||||
|
|
||||||
2. **切换回仅控制台模式**
|
|
||||||
- 清空 `.env.local` 中的 `REACT_APP_POSTHOG_KEY`
|
|
||||||
- 重启应用
|
|
||||||
- 仅在控制台查看事件(不上报)
|
|
||||||
|
|
||||||
3. **配置生产环境**
|
|
||||||
- 创建生产环境的 PostHog 项目
|
|
||||||
- 将生产 API Key 填入 `.env` 文件
|
|
||||||
- 部署时使用生产配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**祝测试顺利!** 🎉
|
|
||||||
|
|
||||||
如有任何问题,请查阅:
|
|
||||||
- [PostHog 官方文档](https://posthog.com/docs)
|
|
||||||
- [ENVIRONMENT_SETUP.md](./ENVIRONMENT_SETUP.md)
|
|
||||||
- [POSTHOG_INTEGRATION.md](./POSTHOG_INTEGRATION.md)
|
|
||||||
@@ -1,561 +0,0 @@
|
|||||||
# PostHog 事件追踪开发者指南
|
|
||||||
|
|
||||||
## 📚 目录
|
|
||||||
|
|
||||||
1. [快速开始](#快速开始)
|
|
||||||
2. [Hook使用指南](#hook使用指南)
|
|
||||||
3. [添加新的追踪Hook](#添加新的追踪hook)
|
|
||||||
4. [集成追踪到组件](#集成追踪到组件)
|
|
||||||
5. [最佳实践](#最佳实践)
|
|
||||||
6. [常见问题](#常见问题)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 当前已有的追踪Hooks
|
|
||||||
|
|
||||||
| Hook名称 | 用途 | 适用场景 |
|
|
||||||
|---------|------|---------|
|
|
||||||
| `useAuthEvents` | 认证事件 | 注册、登录、登出、微信授权 |
|
|
||||||
| `useStockOverviewEvents` | 个股分析 | 个股页面浏览、图表查看、指标分析 |
|
|
||||||
| `useConceptEvents` | 概念追踪 | 概念浏览、搜索、相关股票查看 |
|
|
||||||
| `useCompanyEvents` | 公司分析 | 公司详情、财务数据、行业对比 |
|
|
||||||
| `useLimitAnalyseEvents` | 涨停分析 | 涨停榜单、筛选、个股详情 |
|
|
||||||
| `useCommunityEvents` | 社区事件 | 新闻浏览、事件追踪、评论互动 |
|
|
||||||
| `useEventDetailEvents` | 事件详情 | 事件分析、时间线、影响评估 |
|
|
||||||
| `useDashboardEvents` | 仪表板 | 自选股、关注事件、评论管理 |
|
|
||||||
| `useTradingSimulationEvents` | 模拟盘 | 下单、持仓、收益追踪 |
|
|
||||||
| `useSearchEvents` | 搜索行为 | 搜索查询、结果点击、筛选 |
|
|
||||||
| `useNavigationEvents` | 导航交互 | 菜单点击、主题切换、Logo点击 |
|
|
||||||
| `useProfileEvents` | 个人资料 | 资料更新、密码修改、账号绑定 |
|
|
||||||
| `useSubscriptionEvents` | 订阅支付 | 定价选择、支付流程、订阅管理 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Hook使用指南
|
|
||||||
|
|
||||||
### 1. 基础用法
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 第一步:导入Hook
|
|
||||||
import { useSearchEvents } from '../../hooks/useSearchEvents';
|
|
||||||
|
|
||||||
// 第二步:在组件中初始化
|
|
||||||
function SearchComponent() {
|
|
||||||
const searchEvents = useSearchEvents({ context: 'global' });
|
|
||||||
|
|
||||||
// 第三步:在事件处理函数中调用追踪方法
|
|
||||||
const handleSearch = (query) => {
|
|
||||||
searchEvents.trackSearchQuerySubmitted(query, resultCount);
|
|
||||||
// ... 执行搜索逻辑
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 带参数的Hook初始化
|
|
||||||
|
|
||||||
大多数Hook支持配置参数,用于区分不同的使用场景:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 搜索Hook - 指定搜索上下文
|
|
||||||
const searchEvents = useSearchEvents({
|
|
||||||
context: 'community' // 或 'stock', 'news', 'concept'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 个人资料Hook - 指定页面类型
|
|
||||||
const profileEvents = useProfileEvents({
|
|
||||||
pageType: 'settings' // 或 'profile', 'security'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 导航Hook - 指定组件位置
|
|
||||||
const navEvents = useNavigationEvents({
|
|
||||||
component: 'top_nav' // 或 'sidebar', 'footer'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 订阅Hook - 传入当前订阅信息
|
|
||||||
const subscriptionEvents = useSubscriptionEvents({
|
|
||||||
currentSubscription: {
|
|
||||||
plan: user?.subscription_plan || 'free',
|
|
||||||
status: user?.subscription_status || 'none'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 常见追踪模式
|
|
||||||
|
|
||||||
#### 模式A:简单事件追踪
|
|
||||||
```javascript
|
|
||||||
// 点击事件
|
|
||||||
<Button onClick={() => {
|
|
||||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
|
||||||
navigate('/concepts');
|
|
||||||
}}>
|
|
||||||
概念中心
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 模式B:成功/失败双向追踪
|
|
||||||
```javascript
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await saveData();
|
|
||||||
profileEvents.trackProfileUpdated(updatedFields, data);
|
|
||||||
toast({ title: "保存成功" });
|
|
||||||
} catch (error) {
|
|
||||||
profileEvents.trackProfileUpdateFailed(attemptedFields, error.message);
|
|
||||||
toast({ title: "保存失败" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 模式C:条件追踪
|
|
||||||
```javascript
|
|
||||||
const handleSearch = (query, resultCount) => {
|
|
||||||
// 只在有查询词时追踪
|
|
||||||
if (query) {
|
|
||||||
searchEvents.trackSearchQuerySubmitted(query, resultCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无结果时自动触发额外追踪
|
|
||||||
if (resultCount === 0) {
|
|
||||||
// Hook内部已自动追踪 SEARCH_NO_RESULTS
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔨 添加新的追踪Hook
|
|
||||||
|
|
||||||
### 步骤1:创建Hook文件
|
|
||||||
|
|
||||||
在 `/src/hooks/` 目录下创建新文件,例如 `useYourFeatureEvents.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/hooks/useYourFeatureEvents.js
|
|
||||||
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 - 使用上下文
|
|
||||||
* @returns {Object} 事件追踪处理函数集合
|
|
||||||
*/
|
|
||||||
export const useYourFeatureEvents = ({ context = 'default' } = {}) => {
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪功能操作
|
|
||||||
* @param {string} actionName - 操作名称
|
|
||||||
* @param {Object} details - 操作详情
|
|
||||||
*/
|
|
||||||
const trackFeatureAction = useCallback((actionName, details = {}) => {
|
|
||||||
if (!actionName) {
|
|
||||||
logger.warn('useYourFeatureEvents', 'trackFeatureAction: actionName is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(RETENTION_EVENTS.FEATURE_USED, {
|
|
||||||
feature_name: 'your_feature',
|
|
||||||
action_name: actionName,
|
|
||||||
context,
|
|
||||||
...details,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useYourFeatureEvents', '📊 Feature Action Tracked', {
|
|
||||||
actionName,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
trackFeatureAction,
|
|
||||||
// ... 更多追踪方法
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useYourFeatureEvents;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤2:定义事件常量(如需要)
|
|
||||||
|
|
||||||
在 `/src/lib/constants.js` 中添加新事件:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export const RETENTION_EVENTS = {
|
|
||||||
// ... 现有事件
|
|
||||||
YOUR_FEATURE_VIEWED: 'Your Feature Viewed',
|
|
||||||
YOUR_FEATURE_ACTION: 'Your Feature Action',
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤3:在组件中集成
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { useYourFeatureEvents } from '../../hooks/useYourFeatureEvents';
|
|
||||||
|
|
||||||
function YourComponent() {
|
|
||||||
const featureEvents = useYourFeatureEvents({ context: 'main_page' });
|
|
||||||
|
|
||||||
const handleAction = () => {
|
|
||||||
featureEvents.trackFeatureAction('button_clicked', {
|
|
||||||
button_name: 'submit',
|
|
||||||
user_role: user?.role
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Button onClick={handleAction}>Submit</Button>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 集成追踪到组件
|
|
||||||
|
|
||||||
### 完整集成示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/views/YourFeature/YourComponent.js
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useYourFeatureEvents } from '../../hooks/useYourFeatureEvents';
|
|
||||||
|
|
||||||
export default function YourComponent() {
|
|
||||||
const [data, setData] = useState([]);
|
|
||||||
|
|
||||||
// 🎯 初始化追踪Hook
|
|
||||||
const featureEvents = useYourFeatureEvents({
|
|
||||||
context: 'your_feature'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🎯 页面加载时自动追踪
|
|
||||||
useEffect(() => {
|
|
||||||
featureEvents.trackPageViewed();
|
|
||||||
}, [featureEvents]);
|
|
||||||
|
|
||||||
// 🎯 用户操作追踪
|
|
||||||
const handleItemClick = (item) => {
|
|
||||||
featureEvents.trackItemClicked(item.id, item.name);
|
|
||||||
// ... 业务逻辑
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🎯 表单提交追踪(成功/失败)
|
|
||||||
const handleSubmit = async (formData) => {
|
|
||||||
try {
|
|
||||||
const result = await submitData(formData);
|
|
||||||
featureEvents.trackSubmitSuccess(formData, result);
|
|
||||||
toast({ title: '提交成功' });
|
|
||||||
} catch (error) {
|
|
||||||
featureEvents.trackSubmitFailed(formData, error.message);
|
|
||||||
toast({ title: '提交失败' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data.map(item => (
|
|
||||||
<div key={item.id} onClick={() => handleItemClick(item)}>
|
|
||||||
{item.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
{/* 表单内容 */}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 最佳实践
|
|
||||||
|
|
||||||
### 1. 命名规范
|
|
||||||
|
|
||||||
#### Hook命名
|
|
||||||
- 使用 `use` 前缀:`useFeatureEvents`
|
|
||||||
- 描述性名称:`useSubscriptionEvents` 而非 `useSubEvents`
|
|
||||||
|
|
||||||
#### 追踪方法命名
|
|
||||||
- 使用 `track` 前缀:`trackButtonClicked`
|
|
||||||
- 动词+名词结构:`trackSearchSubmitted`, `trackProfileUpdated`
|
|
||||||
- 明确动作:`trackPaymentSuccessful` 而非 `trackPayment`
|
|
||||||
|
|
||||||
#### 事件常量命名
|
|
||||||
- 大写+下划线:`SEARCH_QUERY_SUBMITTED`
|
|
||||||
- 名词+动词结构:`PROFILE_UPDATED`, `PAYMENT_INITIATED`
|
|
||||||
|
|
||||||
### 2. 参数设计
|
|
||||||
|
|
||||||
#### 必填参数前置
|
|
||||||
```javascript
|
|
||||||
// ✅ 好的设计
|
|
||||||
trackSearchSubmitted(query, resultCount, filters)
|
|
||||||
|
|
||||||
// ❌ 不好的设计
|
|
||||||
trackSearchSubmitted(filters, resultCount, query)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 使用对象参数处理复杂数据
|
|
||||||
```javascript
|
|
||||||
// ✅ 好的设计
|
|
||||||
trackPaymentInitiated({
|
|
||||||
planName: 'pro',
|
|
||||||
amount: 99,
|
|
||||||
currency: 'CNY',
|
|
||||||
paymentMethod: 'wechat_pay'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ❌ 不好的设计
|
|
||||||
trackPaymentInitiated(planName, amount, currency, paymentMethod)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 提供默认值
|
|
||||||
```javascript
|
|
||||||
const trackAction = useCallback((name, details = {}) => {
|
|
||||||
track(EVENT_NAME, {
|
|
||||||
action_name: name,
|
|
||||||
context: context || 'default',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...details
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 错误处理
|
|
||||||
|
|
||||||
#### 参数验证
|
|
||||||
```javascript
|
|
||||||
const trackFeature = useCallback((featureName) => {
|
|
||||||
if (!featureName) {
|
|
||||||
logger.warn('useFeatureEvents', 'trackFeature: featureName is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(EVENTS.FEATURE_USED, { feature_name: featureName });
|
|
||||||
}, [track]);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 避免追踪崩溃影响业务
|
|
||||||
```javascript
|
|
||||||
const handleAction = async () => {
|
|
||||||
try {
|
|
||||||
// 业务逻辑
|
|
||||||
const result = await doSomething();
|
|
||||||
|
|
||||||
// 追踪放在业务逻辑之后,不影响核心功能
|
|
||||||
try {
|
|
||||||
featureEvents.trackActionSuccess(result);
|
|
||||||
} catch (trackError) {
|
|
||||||
logger.error('Tracking failed', trackError);
|
|
||||||
// 不抛出错误,不影响用户体验
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 业务逻辑错误处理
|
|
||||||
toast({ title: '操作失败' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 性能优化
|
|
||||||
|
|
||||||
#### 使用 useCallback 包装追踪函数
|
|
||||||
```javascript
|
|
||||||
const trackAction = useCallback((actionName) => {
|
|
||||||
track(EVENTS.ACTION, { action_name: actionName });
|
|
||||||
}, [track]);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 避免在循环中追踪
|
|
||||||
```javascript
|
|
||||||
// ❌ 不好的做法
|
|
||||||
items.forEach(item => {
|
|
||||||
trackItemViewed(item.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ 好的做法
|
|
||||||
trackItemsViewed(items.length, items.map(i => i.id));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 批量追踪
|
|
||||||
```javascript
|
|
||||||
// 一次追踪包含所有信息
|
|
||||||
trackBatchAction({
|
|
||||||
action_type: 'bulk_delete',
|
|
||||||
item_count: selectedItems.length,
|
|
||||||
item_ids: selectedItems.map(i => i.id)
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 调试支持
|
|
||||||
|
|
||||||
#### 使用 logger.debug
|
|
||||||
```javascript
|
|
||||||
const trackAction = useCallback((actionName) => {
|
|
||||||
track(EVENTS.ACTION, { action_name: actionName });
|
|
||||||
|
|
||||||
logger.debug('useFeatureEvents', '📊 Action Tracked', {
|
|
||||||
actionName,
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 在开发环境显示追踪信息
|
|
||||||
```javascript
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[PostHog Track]', eventName, properties);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 常见问题
|
|
||||||
|
|
||||||
### Q1: Hook 内的 useCallback 依赖项应该包含哪些?
|
|
||||||
|
|
||||||
**A:** 只包含函数内部使用的外部变量:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const trackAction = useCallback((name) => {
|
|
||||||
// ✅ track 和 context 被使用,需要在依赖项中
|
|
||||||
track(EVENTS.ACTION, {
|
|
||||||
name,
|
|
||||||
context
|
|
||||||
});
|
|
||||||
}, [track, context]); // 正确的依赖项
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q2: 何时使用自动追踪 vs 手动追踪?
|
|
||||||
|
|
||||||
**A:**
|
|
||||||
- **自动追踪**:页面浏览、组件挂载时的事件
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
featureEvents.trackPageViewed();
|
|
||||||
}, [featureEvents]);
|
|
||||||
```
|
|
||||||
|
|
||||||
- **手动追踪**:用户主动操作的事件
|
|
||||||
```javascript
|
|
||||||
<Button onClick={() => {
|
|
||||||
featureEvents.trackButtonClicked();
|
|
||||||
handleAction();
|
|
||||||
}}>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q3: 如何追踪异步操作的完整流程?
|
|
||||||
|
|
||||||
**A:** 分别追踪开始、成功、失败:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleAsyncAction = async () => {
|
|
||||||
// 1. 追踪开始
|
|
||||||
featureEvents.trackActionStarted();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await doAsyncWork();
|
|
||||||
|
|
||||||
// 2. 追踪成功
|
|
||||||
featureEvents.trackActionSuccess(result);
|
|
||||||
} catch (error) {
|
|
||||||
// 3. 追踪失败
|
|
||||||
featureEvents.trackActionFailed(error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q4: 追踪中应该包含哪些用户信息?
|
|
||||||
|
|
||||||
**A:**
|
|
||||||
- ✅ **可以包含**:用户ID、角色、订阅状态、使用偏好
|
|
||||||
- ❌ **不应包含**:密码、完整邮箱、手机号、支付信息
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 正确
|
|
||||||
track(EVENT, {
|
|
||||||
user_id: user.id,
|
|
||||||
user_role: user.role,
|
|
||||||
subscription_tier: user.subscription_tier
|
|
||||||
});
|
|
||||||
|
|
||||||
// ❌ 错误
|
|
||||||
track(EVENT, {
|
|
||||||
password: user.password, // 绝对不要追踪密码
|
|
||||||
email: user.email, // 避免完整邮箱
|
|
||||||
credit_card: '****1234' // 不追踪支付信息
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q5: 如何在多个组件间共享追踪逻辑?
|
|
||||||
|
|
||||||
**A:** 使用自定义Hook:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// hooks/useCommonTracking.js
|
|
||||||
export const useCommonTracking = () => {
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
|
|
||||||
const trackError = useCallback((errorMessage, errorCode) => {
|
|
||||||
track('Error Occurred', {
|
|
||||||
error_message: errorMessage,
|
|
||||||
error_code: errorCode,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}, [track]);
|
|
||||||
|
|
||||||
return { trackError };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 在多个组件中使用
|
|
||||||
function ComponentA() {
|
|
||||||
const { trackError } = useCommonTracking();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentB() {
|
|
||||||
const { trackError } = useCommonTracking();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 追踪检查清单
|
|
||||||
|
|
||||||
在添加新功能时,确保追踪以下关键点:
|
|
||||||
|
|
||||||
- [ ] **页面/组件加载** - 用户到达这个页面
|
|
||||||
- [ ] **主要操作** - 用户执行的核心功能
|
|
||||||
- [ ] **成功状态** - 操作成功完成
|
|
||||||
- [ ] **失败状态** - 操作失败及原因
|
|
||||||
- [ ] **用户输入** - 搜索、筛选、表单提交(不包含敏感信息)
|
|
||||||
- [ ] **导航行为** - 点击链接、返回、跳转
|
|
||||||
- [ ] **关键决策点** - 用户做出选择的时刻
|
|
||||||
- [ ] **转化漏斗** - 从意向到完成的关键步骤
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 相关资源
|
|
||||||
|
|
||||||
- [PostHog 官方文档](https://posthog.com/docs)
|
|
||||||
- [POSTHOG_INTEGRATION.md](./POSTHOG_INTEGRATION.md) - 集成总体说明
|
|
||||||
- [constants.js](./src/lib/constants.js) - 所有事件常量定义
|
|
||||||
- [usePostHogRedux.js](./src/hooks/usePostHogRedux.js) - 核心追踪Hook
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 版本历史
|
|
||||||
|
|
||||||
- **v1.0** (2025-10-29): 初始版本,包含13个追踪Hook的完整使用指南
|
|
||||||
- **v1.1** (待定): 计划添加P2功能追踪指南
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**维护者**: 开发团队
|
|
||||||
**最后更新**: 2025-10-29
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# PostHog 快速测试清单
|
|
||||||
|
|
||||||
**测试模式:** 控制台 Debug 模式(暂无 Cloud 上报)
|
|
||||||
|
|
||||||
**应用地址:** http://localhost:3000
|
|
||||||
|
|
||||||
**控制台:** 按 F12 打开
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 初始化检查
|
|
||||||
|
|
||||||
启动应用后,控制台应显示:
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ PostHog initialized successfully
|
|
||||||
📊 PostHog Analytics initialized
|
|
||||||
⚠️ PostHog API key not found. Analytics will be disabled.
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **状态:** 正常(仅控制台模式)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 事件测试清单
|
|
||||||
|
|
||||||
### 1. 页面浏览事件(5项)
|
|
||||||
|
|
||||||
| 操作 | 预期事件 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 访问首页 | `$pageview` | [ ] |
|
|
||||||
| 访问社区页面 | `community_page_viewed` | [ ] |
|
|
||||||
| 访问个股中心 | `stock_overview_page_viewed` | [ ] |
|
|
||||||
| 访问概念中心 | `concept_page_viewed` | [ ] |
|
|
||||||
| 访问涨停分析 | `limit_analyse_page_viewed` | [ ] |
|
|
||||||
|
|
||||||
**控制台输出示例:**
|
|
||||||
```javascript
|
|
||||||
📍 Event tracked: community_page_viewed
|
|
||||||
timestamp: "2025-01-15T10:30:00.000Z"
|
|
||||||
page_path: "/community"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 社区页面事件(6项)
|
|
||||||
|
|
||||||
| 操作 | 预期事件 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 点击搜索框 | `search_initiated` | [ ] |
|
|
||||||
| 输入关键词搜索 | `search_query_submitted` | [ ] |
|
|
||||||
| 应用筛选器 | `filter_applied` | [ ] |
|
|
||||||
| 点击帖子 | `post_clicked` | [ ] |
|
|
||||||
| 点击评论 | `comment_clicked` | [ ] |
|
|
||||||
| 查看用户资料 | `user_profile_viewed` | [ ] |
|
|
||||||
|
|
||||||
**控制台输出示例:**
|
|
||||||
```javascript
|
|
||||||
📍 Event tracked: search_initiated
|
|
||||||
context: "community"
|
|
||||||
|
|
||||||
📍 Event tracked: search_query_submitted
|
|
||||||
query: "科技"
|
|
||||||
category: "community"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 个股中心事件(4项)
|
|
||||||
|
|
||||||
| 操作 | 预期事件 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 搜索股票 | `stock_searched` | [ ] |
|
|
||||||
| 点击概念 | `concept_clicked` | [ ] |
|
|
||||||
| 点击概念下的股票 | `concept_stock_clicked` | [ ] |
|
|
||||||
| 点击热力图股票 | `heatmap_stock_clicked` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 概念中心事件(5项)
|
|
||||||
|
|
||||||
| 操作 | 预期事件 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 查看概念列表 | `concept_list_viewed` | [ ] |
|
|
||||||
| 切换排序 | `sort_changed` | [ ] |
|
|
||||||
| 点击概念 | `concept_clicked` | [ ] |
|
|
||||||
| 打开时间线 Modal | `concept_detail_viewed` | [ ] |
|
|
||||||
| 点击新闻/报告 | `news_clicked` / `report_clicked` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 涨停分析事件(6项)
|
|
||||||
|
|
||||||
| 操作 | 预期事件 | 状态 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 进入页面 | `limit_analyse_page_viewed` | [ ] |
|
|
||||||
| 选择日期 | `date_selected` | [ ] |
|
|
||||||
| 查看每日统计 | `daily_stats_viewed` | [ ] |
|
|
||||||
| 展开/收起板块 | `sector_toggled` | [ ] |
|
|
||||||
| 点击板块 | `sector_clicked` | [ ] |
|
|
||||||
| 点击涨停股票 | `limit_stock_clicked` | [ ] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 测试技巧
|
|
||||||
|
|
||||||
### 控制台过滤
|
|
||||||
|
|
||||||
如果日志太多,可以过滤:
|
|
||||||
1. 在控制台顶部的过滤框输入:`Event tracked`
|
|
||||||
2. 只显示事件追踪日志
|
|
||||||
|
|
||||||
### 查看详细信息
|
|
||||||
|
|
||||||
每个事件日志都可以展开:
|
|
||||||
1. 点击日志左侧的箭头 ▶️
|
|
||||||
2. 查看完整的事件属性
|
|
||||||
|
|
||||||
### 清除日志
|
|
||||||
|
|
||||||
- 点击控制台左上角的 🚫 图标清除所有日志
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 测试完成后
|
|
||||||
|
|
||||||
### 记录结果
|
|
||||||
|
|
||||||
- 通过的测试项:___/26
|
|
||||||
- 失败的测试项:___
|
|
||||||
- 发现的问题:___
|
|
||||||
|
|
||||||
### 下一步
|
|
||||||
|
|
||||||
1. **等待真实 API Key**
|
|
||||||
- 管理员提供 PostHog API Key
|
|
||||||
- 配置到 `.env.local`
|
|
||||||
- 重启应用
|
|
||||||
|
|
||||||
2. **测试 Cloud 上报**
|
|
||||||
- 重复上述测试
|
|
||||||
- 在 PostHog Dashboard 查看 Live Events
|
|
||||||
- 验证数据完整性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**测试日期:** _________
|
|
||||||
**测试人:** _________
|
|
||||||
**环境:** 本地开发(控制台模式)
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
# StockDetailPanel 原始业务逻辑文档
|
|
||||||
|
|
||||||
> **文档版本**: 1.0
|
|
||||||
> **组件文件**: `src/views/Community/components/StockDetailPanel.js`
|
|
||||||
> **原始行数**: 1067 行
|
|
||||||
> **创建日期**: 2025-10-30
|
|
||||||
> **重构前快照**: 用于记录重构前的完整业务逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 目录
|
|
||||||
|
|
||||||
1. [组件概述](#1-组件概述)
|
|
||||||
2. [权限控制系统](#2-权限控制系统)
|
|
||||||
3. [数据加载流程](#3-数据加载流程)
|
|
||||||
4. [K线数据缓存机制](#4-k线数据缓存机制)
|
|
||||||
5. [自选股管理](#5-自选股管理)
|
|
||||||
6. [实时监控功能](#6-实时监控功能)
|
|
||||||
7. [搜索和过滤](#7-搜索和过滤)
|
|
||||||
8. [UI 交互逻辑](#8-ui-交互逻辑)
|
|
||||||
9. [状态管理](#9-状态管理)
|
|
||||||
10. [API 端点清单](#10-api-端点清单)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 组件概述
|
|
||||||
|
|
||||||
### 1.1 功能描述
|
|
||||||
|
|
||||||
StockDetailPanel 是一个 Ant Design Drawer 组件,用于展示事件相关的详细信息,包括:
|
|
||||||
|
|
||||||
- **相关标的**: 事件关联的股票列表、实时行情、分时图
|
|
||||||
- **相关概念**: 事件涉及的概念板块
|
|
||||||
- **历史事件对比**: 类似历史事件的表现分析
|
|
||||||
- **传导链分析**: 事件的传导路径和影响链(Max 会员功能)
|
|
||||||
|
|
||||||
### 1.2 组件属性
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
StockDetailPanel({
|
|
||||||
visible, // boolean - 是否显示 Drawer
|
|
||||||
event, // Object - 事件对象 {id, title, start_time, created_at, ...}
|
|
||||||
onClose // Function - 关闭回调
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 核心依赖
|
|
||||||
|
|
||||||
- **useSubscription**: 订阅权限管理 hook
|
|
||||||
- **eventService**: 事件数据 API 服务
|
|
||||||
- **stockService**: 股票数据 API 服务
|
|
||||||
- **logger**: 日志工具
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 权限控制系统
|
|
||||||
|
|
||||||
### 2.1 权限层级
|
|
||||||
|
|
||||||
系统采用三层订阅模型:
|
|
||||||
|
|
||||||
| 功能 | 权限标识 | 所需版本 | 图标 |
|
|
||||||
|------|---------|---------|------|
|
|
||||||
| 相关标的 | `related_stocks` | Pro | 🔒 |
|
|
||||||
| 相关概念 | `related_concepts` | Pro | 🔒 |
|
|
||||||
| 历史事件对比 | `historical_events_full` | Pro | 🔒 |
|
|
||||||
| 传导链分析 | `transmission_chain` | Max | 👑 |
|
|
||||||
|
|
||||||
### 2.2 权限检查流程
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Hook 初始化
|
|
||||||
const { hasFeatureAccess, getRequiredLevel, getUpgradeRecommendation } = useSubscription();
|
|
||||||
|
|
||||||
// Tab 渲染时检查
|
|
||||||
hasFeatureAccess('related_stocks') ? (
|
|
||||||
// 渲染完整功能
|
|
||||||
) : (
|
|
||||||
// 渲染锁定提示 UI
|
|
||||||
renderLockedContent('related_stocks', '相关标的')
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 权限拦截机制
|
|
||||||
|
|
||||||
**Tab 点击拦截**(已注释,未使用):
|
|
||||||
```javascript
|
|
||||||
const handleTabAccess = (featureName, tabKey) => {
|
|
||||||
if (!hasFeatureAccess(featureName)) {
|
|
||||||
const recommendation = getUpgradeRecommendation(featureName);
|
|
||||||
setUpgradeFeature(recommendation?.required || 'pro');
|
|
||||||
setUpgradeModalOpen(true);
|
|
||||||
return false; // 阻止 Tab 切换
|
|
||||||
}
|
|
||||||
setActiveTab(tabKey);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 锁定 UI 渲染
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const renderLockedContent = (featureName, description) => {
|
|
||||||
const recommendation = getUpgradeRecommendation(featureName);
|
|
||||||
const isProRequired = recommendation?.required === 'pro';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 图标: Pro版显示🔒, Max版显示👑 */}
|
|
||||||
<LockOutlined /> or <CrownOutlined />
|
|
||||||
|
|
||||||
{/* 提示消息 */}
|
|
||||||
<Alert message={`${description}功能已锁定`} />
|
|
||||||
|
|
||||||
{/* 升级按钮 */}
|
|
||||||
<Button onClick={() => setUpgradeModalOpen(true)}>
|
|
||||||
升级到 {isProRequired ? 'Pro版' : 'Max版'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 升级模态框
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
<SubscriptionUpgradeModal
|
|
||||||
isOpen={upgradeModalOpen}
|
|
||||||
onClose={() => setUpgradeModalOpen(false)}
|
|
||||||
requiredLevel={upgradeFeature} // 'pro' | 'max'
|
|
||||||
featureName={upgradeFeature === 'pro' ? '相关分析功能' : '传导链分析'}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 数据加载流程
|
|
||||||
|
|
||||||
### 3.1 加载时机
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible && event) {
|
|
||||||
setActiveTab('stocks');
|
|
||||||
loadAllData();
|
|
||||||
}
|
|
||||||
}, [visible, event]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**触发条件**: Drawer 可见 `visible=true` 且 `event` 对象存在
|
|
||||||
|
|
||||||
### 3.2 并发加载策略
|
|
||||||
|
|
||||||
`loadAllData()` 函数同时发起 **5 个独立 API 请求**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const loadAllData = () => {
|
|
||||||
// 1. 加载用户自选股列表 (独立调用)
|
|
||||||
loadWatchlist();
|
|
||||||
|
|
||||||
// 2. 加载相关标的 → 连锁加载行情数据
|
|
||||||
eventService.getRelatedStocks(event.id)
|
|
||||||
.then(res => {
|
|
||||||
setRelatedStocks(res.data);
|
|
||||||
|
|
||||||
// 2.1 如果有股票,立即加载行情
|
|
||||||
if (res.data.length > 0) {
|
|
||||||
const codes = res.data.map(s => s.stock_code);
|
|
||||||
stockService.getQuotes(codes, event.created_at)
|
|
||||||
.then(quotes => setStockQuotes(quotes));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 加载事件详情
|
|
||||||
eventService.getEventDetail(event.id)
|
|
||||||
.then(res => setEventDetail(res.data));
|
|
||||||
|
|
||||||
// 4. 加载历史事件
|
|
||||||
eventService.getHistoricalEvents(event.id)
|
|
||||||
.then(res => setHistoricalEvents(res.data));
|
|
||||||
|
|
||||||
// 5. 加载传导链分析
|
|
||||||
eventService.getTransmissionChainAnalysis(event.id)
|
|
||||||
.then(res => setChainAnalysis(res.data));
|
|
||||||
|
|
||||||
// 6. 加载超预期得分
|
|
||||||
eventService.getExpectationScore(event.id)
|
|
||||||
.then(res => setExpectationScore(res.data));
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 数据依赖关系
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[loadAllData] --> B[getRelatedStocks]
|
|
||||||
A --> C[getEventDetail]
|
|
||||||
A --> D[getHistoricalEvents]
|
|
||||||
A --> E[getTransmissionChainAnalysis]
|
|
||||||
A --> F[getExpectationScore]
|
|
||||||
A --> G[loadWatchlist]
|
|
||||||
|
|
||||||
B -->|成功且有数据| H[getQuotes]
|
|
||||||
|
|
||||||
B --> I[setRelatedStocks]
|
|
||||||
H --> J[setStockQuotes]
|
|
||||||
C --> K[setEventDetail]
|
|
||||||
D --> L[setHistoricalEvents]
|
|
||||||
E --> M[setChainAnalysis]
|
|
||||||
F --> N[setExpectationScore]
|
|
||||||
G --> O[setWatchlistStocks]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 加载状态管理
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 主加载状态
|
|
||||||
const [loading, setLoading] = useState(false); // 相关标的加载中
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false); // 事件详情加载中
|
|
||||||
|
|
||||||
// 使用示例
|
|
||||||
setLoading(true);
|
|
||||||
eventService.getRelatedStocks(event.id)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 错误处理
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 使用 logger 记录错误
|
|
||||||
stockService.getQuotes(codes, event.created_at)
|
|
||||||
.catch(error => logger.error('StockDetailPanel', 'getQuotes', error, {
|
|
||||||
stockCodes: codes,
|
|
||||||
eventTime: event.created_at
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. K线数据缓存机制
|
|
||||||
|
|
||||||
### 4.1 缓存架构
|
|
||||||
|
|
||||||
**三层 Map 缓存**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 全局缓存(组件级别,不跨实例)
|
|
||||||
const klineDataCache = new Map(); // 数据缓存: key → data[]
|
|
||||||
const pendingRequests = new Map(); // 请求去重: key → Promise
|
|
||||||
const lastRequestTime = new Map(); // 时间戳: key → timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 缓存键生成
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const getCacheKey = (stockCode, eventTime) => {
|
|
||||||
const date = eventTime
|
|
||||||
? moment(eventTime).format('YYYY-MM-DD')
|
|
||||||
: moment().format('YYYY-MM-DD');
|
|
||||||
return `${stockCode}|${date}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 示例: "600000.SH|2024-10-30"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 智能刷新策略
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const shouldRefreshData = (cacheKey) => {
|
|
||||||
const lastTime = lastRequestTime.get(cacheKey);
|
|
||||||
if (!lastTime) return true; // 无缓存,需要刷新
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - lastTime;
|
|
||||||
|
|
||||||
// 检测是否为当日交易时段
|
|
||||||
const today = moment().format('YYYY-MM-DD');
|
|
||||||
const isToday = cacheKey.includes(today);
|
|
||||||
const currentHour = new Date().getHours();
|
|
||||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
|
||||||
|
|
||||||
if (isToday && isTradingHours) {
|
|
||||||
return elapsed > 30000; // 交易时段: 30秒刷新
|
|
||||||
}
|
|
||||||
|
|
||||||
return elapsed > 3600000; // 非交易时段/历史数据: 1小时刷新
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
| 场景 | 刷新间隔 | 原因 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 当日 + 交易时段 (9:00-16:00) | 30 秒 | 实时性要求高 |
|
|
||||||
| 当日 + 非交易时段 | 1 小时 | 数据不会变化 |
|
|
||||||
| 历史日期 | 1 小时 | 数据固定不变 |
|
|
||||||
|
|
||||||
### 4.4 请求去重机制
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const fetchKlineData = async (stockCode, eventTime) => {
|
|
||||||
const cacheKey = getCacheKey(stockCode, eventTime);
|
|
||||||
|
|
||||||
// 1️⃣ 检查缓存
|
|
||||||
if (klineDataCache.has(cacheKey) && !shouldRefreshData(cacheKey)) {
|
|
||||||
return klineDataCache.get(cacheKey); // 直接返回缓存
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2️⃣ 检查是否有进行中的请求(防止重复请求)
|
|
||||||
if (pendingRequests.has(cacheKey)) {
|
|
||||||
return pendingRequests.get(cacheKey); // 返回同一个 Promise
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3️⃣ 发起新请求
|
|
||||||
const requestPromise = stockService
|
|
||||||
.getKlineData(stockCode, 'timeline', eventTime)
|
|
||||||
.then((res) => {
|
|
||||||
const data = Array.isArray(res?.data) ? res.data : [];
|
|
||||||
// 更新缓存
|
|
||||||
klineDataCache.set(cacheKey, data);
|
|
||||||
lastRequestTime.set(cacheKey, Date.now());
|
|
||||||
// 清除 pending 状态
|
|
||||||
pendingRequests.delete(cacheKey);
|
|
||||||
return data;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
pendingRequests.delete(cacheKey);
|
|
||||||
// 如果有旧缓存,返回旧数据
|
|
||||||
if (klineDataCache.has(cacheKey)) {
|
|
||||||
return klineDataCache.get(cacheKey);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存到 pending
|
|
||||||
pendingRequests.set(cacheKey, requestPromise);
|
|
||||||
return requestPromise;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**去重效果**:
|
|
||||||
- 同时有 10 个组件请求同一只股票的同一天数据
|
|
||||||
- 实际只会发出 **1 个 API 请求**
|
|
||||||
- 其他 9 个请求共享同一个 Promise
|
|
||||||
|
|
||||||
### 4.5 MiniTimelineChart 使用缓存
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const MiniTimelineChart = ({ stockCode, eventTime }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
// 检查缓存
|
|
||||||
const cacheKey = getCacheKey(stockCode, eventTime);
|
|
||||||
const cachedData = klineDataCache.get(cacheKey);
|
|
||||||
|
|
||||||
if (cachedData && cachedData.length > 0) {
|
|
||||||
setData(cachedData); // 使用缓存
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无缓存,发起请求
|
|
||||||
fetchKlineData(stockCode, eventTime)
|
|
||||||
.then(result => setData(result));
|
|
||||||
}, [stockCode, eventTime]);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 自选股管理
|
|
||||||
|
|
||||||
### 5.1 加载自选股列表
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const loadWatchlist = async () => {
|
|
||||||
const apiBase = getApiBase(); // 根据环境获取 API base URL
|
|
||||||
|
|
||||||
const response = await fetch(`${apiBase}/api/account/watchlist`, {
|
|
||||||
credentials: 'include' // ⚠️ 关键: 发送 cookies 进行认证
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.data) {
|
|
||||||
// 转换为 Set 数据结构,便于快速查找
|
|
||||||
const watchlistSet = new Set(data.data.map(item => item.stock_code));
|
|
||||||
setWatchlistStocks(watchlistSet);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**API 响应格式**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": [
|
|
||||||
{"stock_code": "600000.SH", "stock_name": "浦发银行"},
|
|
||||||
{"stock_code": "000001.SZ", "stock_name": "平安银行"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 添加/移除自选股
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleWatchlistToggle = async (stockCode, isInWatchlist) => {
|
|
||||||
const apiBase = getApiBase();
|
|
||||||
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (isInWatchlist) {
|
|
||||||
// 🗑️ 删除操作
|
|
||||||
response = await fetch(`${apiBase}/api/account/watchlist/${stockCode}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// ➕ 添加操作
|
|
||||||
const stockInfo = relatedStocks.find(s => s.stock_code === stockCode);
|
|
||||||
|
|
||||||
response = await fetch(`${apiBase}/api/account/watchlist`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
stock_code: stockCode,
|
|
||||||
stock_name: stockInfo?.stock_name || stockCode
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
message.success(isInWatchlist ? '已从自选股移除' : '已加入自选股');
|
|
||||||
|
|
||||||
// 更新本地状态(乐观更新)
|
|
||||||
setWatchlistStocks(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
isInWatchlist ? newSet.delete(stockCode) : newSet.add(stockCode);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
message.error(data.error || '操作失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 UI 集成
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 在 StockTable 的"操作"列中
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
render: (_, record) => {
|
|
||||||
const isInWatchlist = watchlistStocks.has(record.stock_code);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type={isInWatchlist ? 'default' : 'primary'}
|
|
||||||
icon={isInWatchlist ? <StarFilled /> : <StarOutlined />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation(); // 防止触发行点击
|
|
||||||
handleWatchlistToggle(record.stock_code, isInWatchlist);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isInWatchlist ? '已关注' : '加自选'}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 实时监控功能
|
|
||||||
|
|
||||||
### 6.1 监控机制
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const [isMonitoring, setIsMonitoring] = useState(false);
|
|
||||||
const monitoringIntervalRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 清理旧定时器
|
|
||||||
if (monitoringIntervalRef.current) {
|
|
||||||
clearInterval(monitoringIntervalRef.current);
|
|
||||||
monitoringIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMonitoring && relatedStocks.length > 0) {
|
|
||||||
// 定义更新函数
|
|
||||||
const updateQuotes = () => {
|
|
||||||
const codes = relatedStocks.map(s => s.stock_code);
|
|
||||||
stockService.getQuotes(codes, event?.created_at)
|
|
||||||
.then(quotes => setStockQuotes(quotes))
|
|
||||||
.catch(error => logger.error('...', error));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 立即执行一次
|
|
||||||
updateQuotes();
|
|
||||||
|
|
||||||
// 设置定时器: 每 5 秒刷新
|
|
||||||
monitoringIntervalRef.current = setInterval(updateQuotes, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
return () => {
|
|
||||||
if (monitoringIntervalRef.current) {
|
|
||||||
clearInterval(monitoringIntervalRef.current);
|
|
||||||
monitoringIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isMonitoring, relatedStocks, event]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 监控控制
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleMonitoringToggle = () => {
|
|
||||||
setIsMonitoring(prev => !prev);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**UI 表现**:
|
|
||||||
```javascript
|
|
||||||
<Button
|
|
||||||
className={`monitoring-button ${isMonitoring ? 'monitoring' : ''}`}
|
|
||||||
onClick={handleMonitoringToggle}
|
|
||||||
>
|
|
||||||
{isMonitoring ? '停止监控' : '实时监控'}
|
|
||||||
</Button>
|
|
||||||
<div>每5秒自动更新行情数据</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 组件卸载清理
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// 组件卸载时清理定时器,防止内存泄漏
|
|
||||||
if (monitoringIntervalRef.current) {
|
|
||||||
clearInterval(monitoringIntervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 搜索和过滤
|
|
||||||
|
|
||||||
### 7.1 搜索状态
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 过滤逻辑
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
if (!searchText.trim()) {
|
|
||||||
setFilteredStocks(relatedStocks); // 无搜索词,显示全部
|
|
||||||
} else {
|
|
||||||
const filtered = relatedStocks.filter(stock =>
|
|
||||||
stock.stock_code.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
||||||
stock.stock_name.toLowerCase().includes(searchText.toLowerCase())
|
|
||||||
);
|
|
||||||
setFilteredStocks(filtered);
|
|
||||||
}
|
|
||||||
}, [searchText, relatedStocks]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**搜索特性**:
|
|
||||||
- 不区分大小写
|
|
||||||
- 同时匹配股票代码和股票名称
|
|
||||||
- 实时过滤(每次输入都触发)
|
|
||||||
|
|
||||||
### 7.3 搜索 UI
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
<Input
|
|
||||||
placeholder="搜索股票代码或名称..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
allowClear // 显示清除按钮
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. UI 交互逻辑
|
|
||||||
|
|
||||||
### 8.1 Tab 切换
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const [activeTab, setActiveTab] = useState('stocks');
|
|
||||||
|
|
||||||
<AntdTabs
|
|
||||||
activeKey={activeTab}
|
|
||||||
onChange={setActiveTab} // 直接设置,无拦截
|
|
||||||
items={tabItems}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tab 列表**:
|
|
||||||
```javascript
|
|
||||||
const tabItems = [
|
|
||||||
{ key: 'stocks', label: '相关标的', children: ... },
|
|
||||||
{ key: 'concepts', label: '相关概念', children: ... },
|
|
||||||
{ key: 'historical', label: '历史事件对比', children: ... },
|
|
||||||
{ key: 'chain', label: '传导链分析', children: ... }
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 固定图表管理
|
|
||||||
|
|
||||||
**添加固定图表** (行点击):
|
|
||||||
```javascript
|
|
||||||
const handleRowEvents = (record) => ({
|
|
||||||
onClick: () => {
|
|
||||||
setFixedCharts((prev) => {
|
|
||||||
// 防止重复添加
|
|
||||||
if (prev.find(item => item.stock.stock_code === record.stock_code)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return [...prev, { stock: record, chartType: 'timeline' }];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
style: { cursor: 'pointer' }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**移除固定图表**:
|
|
||||||
```javascript
|
|
||||||
const handleUnfixChart = (stock) => {
|
|
||||||
setFixedCharts((prev) =>
|
|
||||||
prev.filter(item => item.stock.stock_code !== stock.stock_code)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**渲染固定图表**:
|
|
||||||
```javascript
|
|
||||||
{fixedCharts.map(({ stock }, index) => (
|
|
||||||
<StockChartAntdModal
|
|
||||||
key={`fixed-chart-${stock.stock_code}-${index}`}
|
|
||||||
open={true}
|
|
||||||
onCancel={() => handleUnfixChart(stock)}
|
|
||||||
stock={stock}
|
|
||||||
eventTime={formattedEventTime}
|
|
||||||
fixed={true}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 行展开/收起逻辑
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
|
||||||
|
|
||||||
const toggleRowExpand = (stockCode) => {
|
|
||||||
setExpandedRows(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.has(stockCode) ? newSet.delete(stockCode) : newSet.add(stockCode);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**应用场景**: 关联描述文本过长时的展开/收起
|
|
||||||
|
|
||||||
### 8.4 讨论模态框
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const [discussionModalVisible, setDiscussionModalVisible] = useState(false);
|
|
||||||
const [discussionType, setDiscussionType] = useState('事件讨论');
|
|
||||||
|
|
||||||
<Button onClick={() => {
|
|
||||||
setDiscussionType('事件讨论');
|
|
||||||
setDiscussionModalVisible(true);
|
|
||||||
}}>
|
|
||||||
查看事件讨论
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<EventDiscussionModal
|
|
||||||
isOpen={discussionModalVisible}
|
|
||||||
onClose={() => setDiscussionModalVisible(false)}
|
|
||||||
eventId={event?.id}
|
|
||||||
eventTitle={event?.title}
|
|
||||||
discussionType={discussionType}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 状态管理
|
|
||||||
|
|
||||||
### 9.1 状态清单
|
|
||||||
|
|
||||||
| 状态名 | 类型 | 初始值 | 用途 |
|
|
||||||
|--------|------|--------|------|
|
|
||||||
| `activeTab` | string | `'stocks'` | 当前激活的 Tab |
|
|
||||||
| `loading` | boolean | `false` | 相关标的加载状态 |
|
|
||||||
| `detailLoading` | boolean | `false` | 事件详情加载状态 |
|
|
||||||
| `relatedStocks` | Array | `[]` | 相关股票列表 |
|
|
||||||
| `stockQuotes` | Object | `{}` | 股票行情字典 |
|
|
||||||
| `selectedStock` | Object | `null` | 当前选中的股票(未使用) |
|
|
||||||
| `chartData` | Object | `null` | 图表数据(未使用) |
|
|
||||||
| `eventDetail` | Object | `null` | 事件详情 |
|
|
||||||
| `historicalEvents` | Array | `[]` | 历史事件列表 |
|
|
||||||
| `chainAnalysis` | Object | `null` | 传导链分析数据 |
|
|
||||||
| `posts` | Array | `[]` | 讨论帖子(未使用) |
|
|
||||||
| `fixedCharts` | Array | `[]` | 固定图表列表 |
|
|
||||||
| `searchText` | string | `''` | 搜索文本 |
|
|
||||||
| `isMonitoring` | boolean | `false` | 实时监控开关 |
|
|
||||||
| `filteredStocks` | Array | `[]` | 过滤后的股票列表 |
|
|
||||||
| `expectationScore` | Object | `null` | 超预期得分 |
|
|
||||||
| `watchlistStocks` | Set | `new Set()` | 自选股集合 |
|
|
||||||
| `discussionModalVisible` | boolean | `false` | 讨论模态框可见性 |
|
|
||||||
| `discussionType` | string | `'事件讨论'` | 讨论类型 |
|
|
||||||
| `upgradeModalOpen` | boolean | `false` | 升级模态框可见性 |
|
|
||||||
| `upgradeFeature` | string | `''` | 需要升级的功能 |
|
|
||||||
|
|
||||||
### 9.2 Ref 引用
|
|
||||||
|
|
||||||
| Ref 名 | 用途 |
|
|
||||||
|--------|------|
|
|
||||||
| `monitoringIntervalRef` | 存储监控定时器 ID |
|
|
||||||
| `tableRef` | Table 组件引用(未使用) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. API 端点清单
|
|
||||||
|
|
||||||
### 10.1 事件相关 API
|
|
||||||
|
|
||||||
| API | 方法 | 参数 | 返回数据 | 用途 |
|
|
||||||
|-----|------|------|---------|------|
|
|
||||||
| `eventService.getRelatedStocks(eventId)` | GET | 事件ID | `{ success, data: Stock[] }` | 获取相关股票 |
|
|
||||||
| `eventService.getEventDetail(eventId)` | GET | 事件ID | `{ success, data: EventDetail }` | 获取事件详情 |
|
|
||||||
| `eventService.getHistoricalEvents(eventId)` | GET | 事件ID | `{ success, data: Event[] }` | 获取历史事件 |
|
|
||||||
| `eventService.getTransmissionChainAnalysis(eventId)` | GET | 事件ID | `{ success, data: ChainAnalysis }` | 获取传导链分析 |
|
|
||||||
| `eventService.getExpectationScore(eventId)` | GET | 事件ID | `{ success, data: Score }` | 获取超预期得分 |
|
|
||||||
|
|
||||||
### 10.2 股票相关 API
|
|
||||||
|
|
||||||
| API | 方法 | 参数 | 返回数据 | 用途 |
|
|
||||||
|-----|------|------|---------|------|
|
|
||||||
| `stockService.getQuotes(codes[], eventTime)` | GET | 股票代码数组, 事件时间 | `{ [code]: Quote }` | 批量获取行情 |
|
|
||||||
| `stockService.getKlineData(code, type, eventTime)` | GET | 股票代码, K线类型, 事件时间 | `{ success, data: Kline[] }` | 获取K线数据 |
|
|
||||||
|
|
||||||
**K线类型**: `'timeline'` (分时), `'daily'` (日K), `'weekly'` (周K), `'monthly'` (月K)
|
|
||||||
|
|
||||||
### 10.3 自选股 API
|
|
||||||
|
|
||||||
| API | 方法 | 请求体 | 返回数据 | 用途 |
|
|
||||||
|-----|------|--------|---------|------|
|
|
||||||
| `GET /api/account/watchlist` | GET | - | `{ success, data: Watchlist[] }` | 获取自选股列表 |
|
|
||||||
| `POST /api/account/watchlist` | POST | `{ stock_code, stock_name }` | `{ success }` | 添加自选股 |
|
|
||||||
| `DELETE /api/account/watchlist/:code` | DELETE | - | `{ success }` | 移除自选股 |
|
|
||||||
|
|
||||||
**认证方式**: 所有 API 都使用 `credentials: 'include'` 携带 cookies
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 附录
|
|
||||||
|
|
||||||
### A. 数据结构定义
|
|
||||||
|
|
||||||
#### Stock (股票)
|
|
||||||
```typescript
|
|
||||||
interface Stock {
|
|
||||||
stock_code: string; // 股票代码, 如 "600000.SH"
|
|
||||||
stock_name: string; // 股票名称, 如 "浦发银行"
|
|
||||||
relation_desc: string | { // 关联描述
|
|
||||||
data: Array<{
|
|
||||||
query_part?: string;
|
|
||||||
sentences?: string;
|
|
||||||
}>
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Quote (行情)
|
|
||||||
```typescript
|
|
||||||
interface Quote {
|
|
||||||
change: number; // 涨跌幅 (百分比)
|
|
||||||
price: number; // 当前价格
|
|
||||||
volume: number; // 成交量
|
|
||||||
// ... 其他字段
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event (事件)
|
|
||||||
```typescript
|
|
||||||
interface Event {
|
|
||||||
id: string; // 事件 ID
|
|
||||||
title: string; // 事件标题
|
|
||||||
start_time: string; // 事件开始时间 (ISO 8601)
|
|
||||||
created_at: string; // 创建时间
|
|
||||||
// ... 其他字段
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### B. 性能优化要点
|
|
||||||
|
|
||||||
1. **请求去重**: 使用 `pendingRequests` Map 防止重复请求
|
|
||||||
2. **智能缓存**: 根据交易时段动态调整刷新策略
|
|
||||||
3. **并发加载**: 5 个 API 请求并发执行
|
|
||||||
4. **乐观更新**: 自选股操作立即更新 UI,无需等待后端响应
|
|
||||||
5. **定时器清理**: 组件卸载时清理定时器,防止内存泄漏
|
|
||||||
|
|
||||||
### C. 安全要点
|
|
||||||
|
|
||||||
1. **认证**: 所有 API 请求携带 credentials: 'include'
|
|
||||||
2. **权限检查**: 每个 Tab 渲染前检查用户权限
|
|
||||||
3. **错误处理**: 所有 API 调用都有 catch 错误处理
|
|
||||||
4. **日志记录**: 使用 logger 记录关键操作和错误
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档结束**
|
|
||||||
|
|
||||||
> 该文档记录了重构前 StockDetailPanel.js 的完整业务逻辑,可作为重构验证的参考基准。
|
|
||||||
@@ -1,740 +0,0 @@
|
|||||||
# StockDetailPanel 重构前后对比文档
|
|
||||||
|
|
||||||
> **重构日期**: 2025-10-30
|
|
||||||
> **重构目标**: 从 1067 行单体组件优化到模块化架构
|
|
||||||
> **架构模式**: Redux + Custom Hooks + Atomic Components
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 核心指标对比
|
|
||||||
|
|
||||||
| 指标 | 重构前 | 重构后 | 改进 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| **主文件行数** | 1067 行 | 347 行 | ⬇️ **67.5%** (减少 720 行) |
|
|
||||||
| **文件数量** | 1 个 | 12 个 | ➕ 11 个新文件 |
|
|
||||||
| **组件复杂度** | 超高 | 低 | ✅ 单一职责 |
|
|
||||||
| **状态管理** | 20+ 本地 state | 8 个 Redux + 8 个本地 | ✅ 分层清晰 |
|
|
||||||
| **代码复用性** | 无 | 高 | ✅ 可复用组件 |
|
|
||||||
| **可测试性** | 困难 | 容易 | ✅ 独立模块 |
|
|
||||||
| **可维护性** | 低 | 高 | ✅ 关注点分离 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ 架构对比
|
|
||||||
|
|
||||||
### 重构前:单体架构
|
|
||||||
|
|
||||||
```
|
|
||||||
StockDetailPanel.js (1067 行)
|
|
||||||
├── 全局工具函数 (25-113 行)
|
|
||||||
│ ├── getCacheKey
|
|
||||||
│ ├── shouldRefreshData
|
|
||||||
│ └── fetchKlineData
|
|
||||||
├── MiniTimelineChart 组件 (115-274 行)
|
|
||||||
├── StockDetailModal 组件 (276-290 行)
|
|
||||||
├── 主组件 StockDetailPanel (292-1066 行)
|
|
||||||
│ ├── 20+ 个 useState
|
|
||||||
│ ├── 8+ 个 useEffect
|
|
||||||
│ ├── 15+ 个事件处理函数
|
|
||||||
│ ├── stockColumns 表格列定义 (150+ 行)
|
|
||||||
│ ├── tabItems 配置 (200+ 行)
|
|
||||||
│ └── JSX 渲染 (100+ 行)
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- ❌ 单文件超过 1000 行,难以维护
|
|
||||||
- ❌ 所有逻辑耦合在一起
|
|
||||||
- ❌ 组件无法复用
|
|
||||||
- ❌ 难以单元测试
|
|
||||||
- ❌ 协作开发容易冲突
|
|
||||||
|
|
||||||
### 重构后:模块化架构
|
|
||||||
|
|
||||||
```
|
|
||||||
StockDetailPanel/
|
|
||||||
├── StockDetailPanel.js (347 行) ← 主组件
|
|
||||||
│ └── 使用 Redux Hooks + Custom Hooks + UI 组件
|
|
||||||
│
|
|
||||||
├── store/slices/
|
|
||||||
│ └── stockSlice.js (450 行) ← Redux 状态管理
|
|
||||||
│ ├── 8 个 AsyncThunks
|
|
||||||
│ ├── 三层缓存策略
|
|
||||||
│ └── 请求去重机制
|
|
||||||
│
|
|
||||||
├── hooks/ ← 业务逻辑层
|
|
||||||
│ ├── useEventStocks.js (130 行)
|
|
||||||
│ │ └── 统一数据加载,自动合并行情
|
|
||||||
│ ├── useWatchlist.js (110 行)
|
|
||||||
│ │ └── 自选股 CRUD,批量操作
|
|
||||||
│ └── useStockMonitoring.js (150 行)
|
|
||||||
│ └── 实时监控,自动清理
|
|
||||||
│
|
|
||||||
├── utils/ ← 工具层
|
|
||||||
│ └── klineDataCache.js (160 行)
|
|
||||||
│ └── K 线缓存,智能刷新
|
|
||||||
│
|
|
||||||
└── components/ ← UI 组件层
|
|
||||||
├── index.js (6 行)
|
|
||||||
├── MiniTimelineChart.js (175 行)
|
|
||||||
├── StockSearchBar.js (50 行)
|
|
||||||
├── StockTable.js (230 行)
|
|
||||||
├── LockedContent.js (50 行)
|
|
||||||
└── RelatedStocksTab.js (110 行)
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✅ 关注点分离(UI / 业务逻辑 / 数据管理)
|
|
||||||
- ✅ 组件可独立开发和测试
|
|
||||||
- ✅ 代码复用性高
|
|
||||||
- ✅ 便于协作开发
|
|
||||||
- ✅ 易于扩展新功能
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 状态管理对比
|
|
||||||
|
|
||||||
### 重构前:20+ 本地 State
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 全部在 StockDetailPanel 组件内
|
|
||||||
const [activeTab, setActiveTab] = useState('stocks');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
|
||||||
const [stockQuotes, setStockQuotes] = useState({});
|
|
||||||
const [selectedStock, setSelectedStock] = useState(null);
|
|
||||||
const [chartData, setChartData] = useState(null);
|
|
||||||
const [eventDetail, setEventDetail] = useState(null);
|
|
||||||
const [historicalEvents, setHistoricalEvents] = useState([]);
|
|
||||||
const [chainAnalysis, setChainAnalysis] = useState(null);
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [fixedCharts, setFixedCharts] = useState([]);
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [isMonitoring, setIsMonitoring] = useState(false);
|
|
||||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
|
||||||
const [expectationScore, setExpectationScore] = useState(null);
|
|
||||||
const [watchlistStocks, setWatchlistStocks] = useState(new Set());
|
|
||||||
const [discussionModalVisible, setDiscussionModalVisible] = useState(false);
|
|
||||||
const [discussionType, setDiscussionType] = useState('事件讨论');
|
|
||||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
|
||||||
const [upgradeFeature, setUpgradeFeature] = useState('');
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- ❌ 状态分散,难以追踪
|
|
||||||
- ❌ 数据跨组件共享困难
|
|
||||||
- ❌ 没有持久化机制
|
|
||||||
- ❌ 每次重新加载都需要重新请求
|
|
||||||
|
|
||||||
### 重构后:分层状态管理
|
|
||||||
|
|
||||||
#### 1️⃣ Redux State (全局共享数据)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// store/slices/stockSlice.js
|
|
||||||
{
|
|
||||||
eventStocksCache: {}, // { [eventId]: stocks[] }
|
|
||||||
quotes: {}, // { [stockCode]: quote }
|
|
||||||
eventDetailsCache: {}, // { [eventId]: detail }
|
|
||||||
historicalEventsCache: {}, // { [eventId]: events[] }
|
|
||||||
chainAnalysisCache: {}, // { [eventId]: analysis }
|
|
||||||
expectationScores: {}, // { [eventId]: score }
|
|
||||||
watchlist: [], // 自选股列表
|
|
||||||
loading: { ... } // 细粒度加载状态
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✅ 三层缓存:Redux → LocalStorage → API
|
|
||||||
- ✅ 跨组件共享,无需 prop drilling
|
|
||||||
- ✅ 数据持久化到 LocalStorage
|
|
||||||
- ✅ 请求去重,避免重复调用
|
|
||||||
|
|
||||||
#### 2️⃣ Custom Hooks (封装业务逻辑)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// hooks/useEventStocks.js
|
|
||||||
const {
|
|
||||||
stocks, // 从 Redux 获取
|
|
||||||
stocksWithQuotes, // 自动合并行情
|
|
||||||
quotes,
|
|
||||||
eventDetail,
|
|
||||||
loading,
|
|
||||||
refreshAllData // 强制刷新
|
|
||||||
} = useEventStocks(eventId, eventTime);
|
|
||||||
|
|
||||||
// hooks/useWatchlist.js
|
|
||||||
const {
|
|
||||||
watchlistSet, // Set 结构,O(1) 查询
|
|
||||||
toggleWatchlist, // 一键切换
|
|
||||||
isInWatchlist // 快速检查
|
|
||||||
} = useWatchlist();
|
|
||||||
|
|
||||||
// hooks/useStockMonitoring.js
|
|
||||||
const {
|
|
||||||
isMonitoring,
|
|
||||||
toggleMonitoring, // 自动管理定时器
|
|
||||||
manualRefresh
|
|
||||||
} = useStockMonitoring(stocks, eventTime);
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✅ 业务逻辑可复用
|
|
||||||
- ✅ 自动清理副作用
|
|
||||||
- ✅ 易于单元测试
|
|
||||||
|
|
||||||
#### 3️⃣ Local State (UI 临时状态)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// StockDetailPanel.js - 仅 8 个本地状态
|
|
||||||
const [activeTab, setActiveTab] = useState('stocks');
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [filteredStocks, setFilteredStocks] = useState([]);
|
|
||||||
const [fixedCharts, setFixedCharts] = useState([]);
|
|
||||||
const [discussionModalVisible, setDiscussionModalVisible] = useState(false);
|
|
||||||
const [discussionType, setDiscussionType] = useState('事件讨论');
|
|
||||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
|
||||||
const [upgradeFeature, setUpgradeFeature] = useState('');
|
|
||||||
```
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- ✅ 仅存储 UI 临时状态
|
|
||||||
- ✅ 不需要持久化
|
|
||||||
- ✅ 组件卸载即销毁
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔌 数据流对比
|
|
||||||
|
|
||||||
### 重构前:组件内部直接调用 API
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 所有逻辑都在组件内
|
|
||||||
const loadAllData = () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// API 调用 1
|
|
||||||
eventService.getRelatedStocks(event.id)
|
|
||||||
.then(res => {
|
|
||||||
setRelatedStocks(res.data);
|
|
||||||
|
|
||||||
// 连锁调用 API 2
|
|
||||||
stockService.getQuotes(codes, event.created_at)
|
|
||||||
.then(quotes => setStockQuotes(quotes));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
|
|
||||||
// API 调用 3
|
|
||||||
eventService.getEventDetail(event.id)
|
|
||||||
.then(res => setEventDetail(res.data));
|
|
||||||
|
|
||||||
// API 调用 4
|
|
||||||
eventService.getHistoricalEvents(event.id)
|
|
||||||
.then(res => setHistoricalEvents(res.data));
|
|
||||||
|
|
||||||
// API 调用 5
|
|
||||||
eventService.getTransmissionChainAnalysis(event.id)
|
|
||||||
.then(res => setChainAnalysis(res.data));
|
|
||||||
|
|
||||||
// API 调用 6
|
|
||||||
eventService.getExpectationScore(event.id)
|
|
||||||
.then(res => setExpectationScore(res.data));
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- ❌ 没有缓存,每次切换都重新请求
|
|
||||||
- ❌ 没有去重,可能重复请求
|
|
||||||
- ❌ 错误处理分散
|
|
||||||
- ❌ 加载状态管理复杂
|
|
||||||
|
|
||||||
### 重构后:Redux + Hooks 统一管理
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 1️⃣ 组件层:简洁的 Hook 调用
|
|
||||||
const {
|
|
||||||
stocks,
|
|
||||||
quotes,
|
|
||||||
eventDetail,
|
|
||||||
loading,
|
|
||||||
refreshAllData
|
|
||||||
} = useEventStocks(eventId, eventTime);
|
|
||||||
|
|
||||||
// 2️⃣ Hook 层:自动加载和合并
|
|
||||||
useEffect(() => {
|
|
||||||
if (eventId) {
|
|
||||||
dispatch(fetchEventStocks({ eventId }));
|
|
||||||
dispatch(fetchStockQuotes({ codes, eventTime }));
|
|
||||||
dispatch(fetchEventDetail({ eventId }));
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}, [eventId]);
|
|
||||||
|
|
||||||
// 3️⃣ Redux 层:三层缓存 + 去重
|
|
||||||
export const fetchEventStocks = createAsyncThunk(
|
|
||||||
'stock/fetchEventStocks',
|
|
||||||
async ({ eventId, forceRefresh }, { getState }) => {
|
|
||||||
// 检查 Redux 缓存
|
|
||||||
if (!forceRefresh && getState().stock.eventStocksCache[eventId]) {
|
|
||||||
return { eventId, stocks: cached };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查 LocalStorage 缓存
|
|
||||||
const localCached = localCacheManager.get(key);
|
|
||||||
if (!forceRefresh && localCached) {
|
|
||||||
return { eventId, stocks: localCached };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发起 API 请求
|
|
||||||
const res = await eventService.getRelatedStocks(eventId);
|
|
||||||
localCacheManager.set(key, res.data);
|
|
||||||
return { eventId, stocks: res.data };
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✅ 自动缓存,切换 Tab 无需重新请求
|
|
||||||
- ✅ 请求去重,pendingRequests Map
|
|
||||||
- ✅ 统一错误处理
|
|
||||||
- ✅ 细粒度 loading 状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 组件复用性对比
|
|
||||||
|
|
||||||
### 重构前:无复用性
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// MiniTimelineChart 内嵌在 StockDetailPanel.js 中
|
|
||||||
// 无法在其他组件中使用
|
|
||||||
// 表格列定义、Tab 配置都耦合在主组件
|
|
||||||
```
|
|
||||||
|
|
||||||
### 重构后:高度可复用
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 1️⃣ MiniTimelineChart - 可在任何地方使用
|
|
||||||
import { MiniTimelineChart } from './components';
|
|
||||||
|
|
||||||
<MiniTimelineChart
|
|
||||||
stockCode="600000.SH"
|
|
||||||
eventTime="2024-10-30 14:30"
|
|
||||||
/>
|
|
||||||
|
|
||||||
// 2️⃣ StockTable - 可独立使用
|
|
||||||
import { StockTable } from './components';
|
|
||||||
|
|
||||||
<StockTable
|
|
||||||
stocks={stocks}
|
|
||||||
quotes={quotes}
|
|
||||||
watchlistSet={watchlistSet}
|
|
||||||
onWatchlistToggle={handleToggle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// 3️⃣ StockSearchBar - 通用搜索组件
|
|
||||||
import { StockSearchBar } from './components';
|
|
||||||
|
|
||||||
<StockSearchBar
|
|
||||||
searchText={searchText}
|
|
||||||
onSearch={setSearchText}
|
|
||||||
onRefresh={refresh}
|
|
||||||
/>
|
|
||||||
|
|
||||||
// 4️⃣ LockedContent - 权限锁定 UI
|
|
||||||
import { LockedContent } from './components';
|
|
||||||
|
|
||||||
<LockedContent
|
|
||||||
description="高级功能"
|
|
||||||
isProRequired={false}
|
|
||||||
onUpgradeClick={handleUpgrade}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**应用场景**:
|
|
||||||
- ✅ 可用于公司详情页
|
|
||||||
- ✅ 可用于自选股页面
|
|
||||||
- ✅ 可用于行业分析页面
|
|
||||||
- ✅ 可用于其他需要股票列表的地方
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 可测试性对比
|
|
||||||
|
|
||||||
### 重构前:难以测试
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 无法单独测试业务逻辑
|
|
||||||
// 必须挂载整个 1067 行的组件
|
|
||||||
// Mock 复杂度高
|
|
||||||
|
|
||||||
describe('StockDetailPanel', () => {
|
|
||||||
it('should load stocks', () => {
|
|
||||||
// 需要 mock 所有依赖
|
|
||||||
const wrapper = mount(
|
|
||||||
<Provider store={store}>
|
|
||||||
<StockDetailPanel
|
|
||||||
visible={true}
|
|
||||||
event={mockEvent}
|
|
||||||
onClose={mockClose}
|
|
||||||
/>
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 测试逻辑深埋在组件内部,难以验证
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 重构后:易于测试
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ 测试 Hook
|
|
||||||
describe('useEventStocks', () => {
|
|
||||||
it('should fetch stocks on mount', () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useEventStocks('event-123', '2024-10-30')
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.loading.stocks).toBe(true);
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge stocks with quotes', () => {
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ 测试 Redux Slice
|
|
||||||
describe('stockSlice', () => {
|
|
||||||
it('should cache event stocks', () => {
|
|
||||||
const state = stockReducer(
|
|
||||||
initialState,
|
|
||||||
fetchEventStocks.fulfilled({ eventId: '123', stocks: [] })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.eventStocksCache['123']).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ 测试组件
|
|
||||||
describe('StockTable', () => {
|
|
||||||
it('should render stocks', () => {
|
|
||||||
const { getByText } = render(
|
|
||||||
<StockTable
|
|
||||||
stocks={mockStocks}
|
|
||||||
quotes={mockQuotes}
|
|
||||||
watchlistSet={new Set()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(getByText('600000.SH')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ 测试工具函数
|
|
||||||
describe('klineDataCache', () => {
|
|
||||||
it('should return cached data', () => {
|
|
||||||
const key = getCacheKey('600000.SH', '2024-10-30');
|
|
||||||
klineDataCache.set(key, mockData);
|
|
||||||
|
|
||||||
const result = fetchKlineData('600000.SH', '2024-10-30');
|
|
||||||
expect(result).toBe(mockData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚡ 性能优化对比
|
|
||||||
|
|
||||||
### 重构前
|
|
||||||
|
|
||||||
| 场景 | 行为 | 性能问题 |
|
|
||||||
|------|------|---------|
|
|
||||||
| 切换 Tab | 无缓存,重新请求 | ❌ 网络开销大 |
|
|
||||||
| 多次点击同一股票 | 重复请求 K 线数据 | ❌ 重复请求 |
|
|
||||||
| 实时监控 | 定时器可能未清理 | ❌ 内存泄漏 |
|
|
||||||
| 组件卸载 | 可能遗留副作用 | ❌ 内存泄漏 |
|
|
||||||
|
|
||||||
### 重构后
|
|
||||||
|
|
||||||
| 场景 | 行为 | 性能优化 |
|
|
||||||
|------|------|---------|
|
|
||||||
| 切换 Tab | Redux + LocalStorage 缓存 | ✅ 即时响应 |
|
|
||||||
| 多次点击同一股票 | pendingRequests 去重 | ✅ 单次请求 |
|
|
||||||
| 实时监控 | Hook 自动清理定时器 | ✅ 无泄漏 |
|
|
||||||
| 组件卸载 | useEffect 清理函数 | ✅ 完全清理 |
|
|
||||||
| K 线缓存 | 智能刷新(交易时段 30s) | ✅ 减少请求 |
|
|
||||||
| 行情更新 | 批量请求,单次返回 | ✅ 减少请求次数 |
|
|
||||||
|
|
||||||
**性能提升**:
|
|
||||||
- 🚀 页面切换速度提升 **80%**(缓存命中)
|
|
||||||
- 🚀 API 请求减少 **60%**(缓存 + 去重)
|
|
||||||
- 🚀 内存占用降低 **40%**(及时清理)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 维护性对比
|
|
||||||
|
|
||||||
### 重构前:维护困难
|
|
||||||
|
|
||||||
**场景 1: 修改自选股逻辑**
|
|
||||||
```javascript
|
|
||||||
// 需要在 1067 行中找到相关代码
|
|
||||||
// handleWatchlistToggle 函数在 417-467 行
|
|
||||||
// 表格列定义在 606-757 行
|
|
||||||
// UI 渲染在 741-752 行
|
|
||||||
// 分散在 3 个位置,容易遗漏
|
|
||||||
```
|
|
||||||
|
|
||||||
**场景 2: 添加新功能**
|
|
||||||
```javascript
|
|
||||||
// 需要在庞大的组件中添加代码
|
|
||||||
// 容易破坏现有逻辑
|
|
||||||
// Git 冲突概率高
|
|
||||||
```
|
|
||||||
|
|
||||||
**场景 3: 代码审查**
|
|
||||||
```javascript
|
|
||||||
// Pull Request 显示 1067 行 diff
|
|
||||||
// 审查者难以理解上下文
|
|
||||||
// 容易遗漏问题
|
|
||||||
```
|
|
||||||
|
|
||||||
### 重构后:易于维护
|
|
||||||
|
|
||||||
**场景 1: 修改自选股逻辑**
|
|
||||||
```javascript
|
|
||||||
// 直接打开 hooks/useWatchlist.js (110 行)
|
|
||||||
// 所有自选股逻辑集中在此文件
|
|
||||||
// 修改后只需测试这一个 Hook
|
|
||||||
```
|
|
||||||
|
|
||||||
**场景 2: 添加新功能**
|
|
||||||
```javascript
|
|
||||||
// 创建新的 Hook 或组件
|
|
||||||
// 在主组件中引入即可
|
|
||||||
// 不影响现有代码
|
|
||||||
```
|
|
||||||
|
|
||||||
**场景 3: 代码审查**
|
|
||||||
```javascript
|
|
||||||
// Pull Request 每个文件独立 diff
|
|
||||||
// components/NewFeature.js (+150 行)
|
|
||||||
// 审查者可专注单一功能
|
|
||||||
// 容易发现问题
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 代码质量对比
|
|
||||||
|
|
||||||
### 代码行数分布
|
|
||||||
|
|
||||||
| 文件类型 | 重构前 | 重构后 | 说明 |
|
|
||||||
|---------|--------|--------|------|
|
|
||||||
| **主组件** | 1067 行 | 347 行 | 67.5% 减少 |
|
|
||||||
| **Redux Slice** | 0 行 | 450 行 | 状态管理层 |
|
|
||||||
| **Custom Hooks** | 0 行 | 390 行 | 业务逻辑层 |
|
|
||||||
| **UI 组件** | 0 行 | 615 行 | 可复用组件 |
|
|
||||||
| **工具模块** | 0 行 | 160 行 | 缓存工具 |
|
|
||||||
| **总计** | 1067 行 | 1962 行 | +895 行(但模块化) |
|
|
||||||
|
|
||||||
**说明**: 虽然总行数增加,但代码质量显著提升:
|
|
||||||
- ✅ 每个文件职责单一
|
|
||||||
- ✅ 可读性大幅提高
|
|
||||||
- ✅ 可维护性显著增强
|
|
||||||
- ✅ 可复用性从 0 到 100%
|
|
||||||
|
|
||||||
### ESLint / 代码规范
|
|
||||||
|
|
||||||
| 指标 | 重构前 | 重构后 |
|
|
||||||
|------|--------|--------|
|
|
||||||
| **函数平均行数** | ~50 行 | ~15 行 |
|
|
||||||
| **最大函数行数** | 200+ 行 | 60 行 |
|
|
||||||
| **嵌套层级** | 最深 6 层 | 最深 3 层 |
|
|
||||||
| **循环复杂度** | 高 | 低 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 业务逻辑保留验证
|
|
||||||
|
|
||||||
### 权限控制 ✅ 完全保留
|
|
||||||
|
|
||||||
| 功能 | 重构前 | 重构后 | 状态 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| `hasFeatureAccess` 检查 | ✅ | ✅ | 保留 |
|
|
||||||
| `getUpgradeRecommendation` | ✅ | ✅ | 保留 |
|
|
||||||
| Tab 锁定图标显示 | ✅ | ✅ | 保留 |
|
|
||||||
| LockedContent UI | ✅ | ✅ | 提取为组件 |
|
|
||||||
| SubscriptionUpgradeModal | ✅ | ✅ | 保留 |
|
|
||||||
|
|
||||||
### 数据加载 ✅ 完全保留
|
|
||||||
|
|
||||||
| API 调用 | 重构前 | 重构后 | 状态 |
|
|
||||||
|---------|--------|--------|------|
|
|
||||||
| getRelatedStocks | ✅ | ✅ | 移至 Redux |
|
|
||||||
| getStockQuotes | ✅ | ✅ | 移至 Redux |
|
|
||||||
| getEventDetail | ✅ | ✅ | 移至 Redux |
|
|
||||||
| getHistoricalEvents | ✅ | ✅ | 移至 Redux |
|
|
||||||
| getTransmissionChainAnalysis | ✅ | ✅ | 移至 Redux |
|
|
||||||
| getExpectationScore | ✅ | ✅ | 移至 Redux |
|
|
||||||
|
|
||||||
### K 线缓存 ✅ 完全保留
|
|
||||||
|
|
||||||
| 功能 | 重构前 | 重构后 | 状态 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| klineDataCache Map | ✅ | ✅ | 移至 utils/ |
|
|
||||||
| pendingRequests 去重 | ✅ | ✅ | 移至 utils/ |
|
|
||||||
| 智能刷新策略 | ✅ | ✅ | 移至 utils/ |
|
|
||||||
| 交易时段检测 | ✅ | ✅ | 移至 utils/ |
|
|
||||||
|
|
||||||
### 自选股管理 ✅ 完全保留
|
|
||||||
|
|
||||||
| 功能 | 重构前 | 重构后 | 状态 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| loadWatchlist | ✅ | ✅ | 移至 Hook |
|
|
||||||
| handleWatchlistToggle | ✅ | ✅ | 移至 Hook |
|
|
||||||
| API: GET /watchlist | ✅ | ✅ | 保留 |
|
|
||||||
| API: POST /watchlist | ✅ | ✅ | 保留 |
|
|
||||||
| API: DELETE /watchlist/:code | ✅ | ✅ | 保留 |
|
|
||||||
| credentials: 'include' | ✅ | ✅ | 保留 |
|
|
||||||
|
|
||||||
### 实时监控 ✅ 完全保留
|
|
||||||
|
|
||||||
| 功能 | 重构前 | 重构后 | 状态 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 5 秒定时刷新 | ✅ | ✅ | 移至 Hook |
|
|
||||||
| 定时器清理 | ✅ | ✅ | Hook 自动清理 |
|
|
||||||
| 监控开关 | ✅ | ✅ | 保留 |
|
|
||||||
| 立即执行一次 | ✅ | ✅ | 保留 |
|
|
||||||
|
|
||||||
### UI 交互 ✅ 完全保留
|
|
||||||
|
|
||||||
| 功能 | 重构前 | 重构后 | 状态 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| Tab 切换 | ✅ | ✅ | 保留 |
|
|
||||||
| 搜索过滤 | ✅ | ✅ | 保留 |
|
|
||||||
| 行点击固定图表 | ✅ | ✅ | 保留 |
|
|
||||||
| 关联描述展开/收起 | ✅ | ✅ | 移至 StockTable |
|
|
||||||
| 讨论模态框 | ✅ | ✅ | 保留 |
|
|
||||||
| 升级模态框 | ✅ | ✅ | 保留 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 重构收益总结
|
|
||||||
|
|
||||||
### 技术收益
|
|
||||||
|
|
||||||
| 维度 | 收益 | 量化指标 |
|
|
||||||
|------|------|---------|
|
|
||||||
| **代码质量** | 显著提升 | 主文件行数 ⬇️ 67.5% |
|
|
||||||
| **可维护性** | 显著提升 | 模块化,单一职责 |
|
|
||||||
| **可测试性** | 从困难到容易 | 可独立测试每个模块 |
|
|
||||||
| **可复用性** | 从 0 到 100% | 5 个可复用组件 |
|
|
||||||
| **性能** | 提升 60-80% | 缓存命中率高 |
|
|
||||||
| **开发效率** | 提升 40% | 并行开发,减少冲突 |
|
|
||||||
|
|
||||||
### 业务收益
|
|
||||||
|
|
||||||
| 维度 | 收益 |
|
|
||||||
|------|------|
|
|
||||||
| **功能完整性** | ✅ 100% 保留原有功能 |
|
|
||||||
| **用户体验** | ✅ 页面响应速度提升 |
|
|
||||||
| **稳定性** | ✅ 减少内存泄漏风险 |
|
|
||||||
| **扩展性** | ✅ 易于添加新功能 |
|
|
||||||
|
|
||||||
### 团队收益
|
|
||||||
|
|
||||||
| 维度 | 收益 |
|
|
||||||
|------|------|
|
|
||||||
| **协作效率** | ✅ 减少代码冲突 |
|
|
||||||
| **代码审查** | ✅ 更容易 review |
|
|
||||||
| **知识传递** | ✅ 新人易于理解 |
|
|
||||||
| **长期维护** | ✅ 降低维护成本 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 重构最佳实践总结
|
|
||||||
|
|
||||||
本次重构遵循的原则:
|
|
||||||
|
|
||||||
### 1. **关注点分离** (Separation of Concerns)
|
|
||||||
- ✅ UI 组件只负责渲染
|
|
||||||
- ✅ Custom Hooks 负责业务逻辑
|
|
||||||
- ✅ Redux 负责状态管理
|
|
||||||
- ✅ Utils 负责工具函数
|
|
||||||
|
|
||||||
### 2. **单一职责** (Single Responsibility)
|
|
||||||
- ✅ 每个文件只做一件事
|
|
||||||
- ✅ 每个函数只有一个职责
|
|
||||||
- ✅ 组件职责清晰
|
|
||||||
|
|
||||||
### 3. **开闭原则** (Open-Closed)
|
|
||||||
- ✅ 对扩展开放:易于添加新功能
|
|
||||||
- ✅ 对修改封闭:不破坏现有功能
|
|
||||||
|
|
||||||
### 4. **DRY 原则** (Don't Repeat Yourself)
|
|
||||||
- ✅ 提取可复用组件
|
|
||||||
- ✅ 封装通用逻辑
|
|
||||||
- ✅ 避免代码重复
|
|
||||||
|
|
||||||
### 5. **可测试性优先**
|
|
||||||
- ✅ 每个模块独立可测
|
|
||||||
- ✅ 纯函数易于测试
|
|
||||||
- ✅ Mock 依赖简单
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 后续优化建议
|
|
||||||
|
|
||||||
虽然本次重构已大幅改善代码质量,但仍有优化空间:
|
|
||||||
|
|
||||||
### 短期优化 (1-2 周)
|
|
||||||
|
|
||||||
1. **添加单元测试**
|
|
||||||
- [ ] useEventStocks 测试覆盖率 > 80%
|
|
||||||
- [ ] stockSlice 测试覆盖率 > 90%
|
|
||||||
- [ ] 组件快照测试
|
|
||||||
|
|
||||||
2. **性能监控**
|
|
||||||
- [ ] 添加 React.memo 优化渲染
|
|
||||||
- [ ] 监控 API 调用次数
|
|
||||||
- [ ] 监控缓存命中率
|
|
||||||
|
|
||||||
3. **文档完善**
|
|
||||||
- [ ] 组件 API 文档
|
|
||||||
- [ ] Hook 使用指南
|
|
||||||
- [ ] Storybook 示例
|
|
||||||
|
|
||||||
### 中期优化 (1-2 月)
|
|
||||||
|
|
||||||
1. **TypeScript 迁移**
|
|
||||||
- [ ] 添加类型定义
|
|
||||||
- [ ] 提升类型安全
|
|
||||||
|
|
||||||
2. **Error Boundary**
|
|
||||||
- [ ] 添加错误边界
|
|
||||||
- [ ] 优雅降级
|
|
||||||
|
|
||||||
3. **国际化支持**
|
|
||||||
- [ ] 提取文案
|
|
||||||
- [ ] 支持多语言
|
|
||||||
|
|
||||||
### 长期优化 (3-6 月)
|
|
||||||
|
|
||||||
1. **微前端拆分**
|
|
||||||
- [ ] 股票模块独立部署
|
|
||||||
- [ ] 按需加载
|
|
||||||
|
|
||||||
2. **性能极致优化**
|
|
||||||
- [ ] 虚拟滚动
|
|
||||||
- [ ] Web Worker 计算
|
|
||||||
- [ ] Service Worker 缓存
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档结束**
|
|
||||||
|
|
||||||
> 本次重构是一次成功的工程实践,在保持 100% 功能完整性的前提下,实现了代码质量的质的飞跃。
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,484 +0,0 @@
|
|||||||
# PostHog 事件追踪验证清单
|
|
||||||
|
|
||||||
## 📋 验证目的
|
|
||||||
|
|
||||||
本清单用于验证所有PostHog事件追踪是否正常工作。建议在以下场景使用:
|
|
||||||
- ✅ 开发环境集成后的验证
|
|
||||||
- ✅ 上线前的最终检查
|
|
||||||
- ✅ 定期追踪健康度检查
|
|
||||||
- ✅ 新功能上线后的验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 验证准备
|
|
||||||
|
|
||||||
### 1. 环境检查
|
|
||||||
- [ ] PostHog已正确配置(检查.env文件)
|
|
||||||
- [ ] PostHog控制台可以访问
|
|
||||||
- [ ] 开发者工具Network面板可以看到PostHog请求
|
|
||||||
- [ ] 浏览器Console没有PostHog相关错误
|
|
||||||
|
|
||||||
### 2. 验证工具
|
|
||||||
- [ ] 打开浏览器开发者工具(F12)
|
|
||||||
- [ ] 切换到Network标签
|
|
||||||
- [ ] 过滤器设置为:`posthog` 或 `api/events`
|
|
||||||
- [ ] 打开Console标签查看logger.debug输出
|
|
||||||
|
|
||||||
### 3. PostHog控制台
|
|
||||||
- [ ] 登录 https://app.posthog.com
|
|
||||||
- [ ] 进入项目
|
|
||||||
- [ ] 打开 "Live events" 视图
|
|
||||||
- [ ] 准备监控实时事件
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 功能模块验证
|
|
||||||
|
|
||||||
### 🔐 认证模块(useAuthEvents)
|
|
||||||
|
|
||||||
#### 注册流程
|
|
||||||
- [ ] 打开注册页面
|
|
||||||
- [ ] 填写手机号和密码
|
|
||||||
- [ ] 点击注册按钮
|
|
||||||
- [ ] **验证事件**: `USER_SIGNED_UP`
|
|
||||||
- 检查属性:`signup_method`, `user_id`
|
|
||||||
|
|
||||||
#### 登录流程
|
|
||||||
- [ ] 打开登录页面
|
|
||||||
- [ ] 使用密码登录
|
|
||||||
- [ ] **验证事件**: `USER_LOGGED_IN`
|
|
||||||
- 检查属性:`login_method: 'password'`
|
|
||||||
- [ ] 退出登录
|
|
||||||
- [ ] 使用微信登录
|
|
||||||
- [ ] **验证事件**: `USER_LOGGED_IN`
|
|
||||||
- 检查属性:`login_method: 'wechat'`
|
|
||||||
|
|
||||||
#### 登出
|
|
||||||
- [ ] 点击退出登录
|
|
||||||
- [ ] **验证事件**: `USER_LOGGED_OUT`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🏠 社区模块(useCommunityEvents)
|
|
||||||
|
|
||||||
#### 页面浏览
|
|
||||||
- [ ] 访问社区页面 `/community`
|
|
||||||
- [ ] **验证事件**: `Community Page Viewed`
|
|
||||||
- [ ] **验证事件**: `News List Viewed`
|
|
||||||
- 检查属性:`total_count`, `sort_by`, `importance_filter`
|
|
||||||
|
|
||||||
#### 新闻点击
|
|
||||||
- [ ] 点击任一新闻事件
|
|
||||||
- [ ] **验证事件**: `NEWS_ARTICLE_CLICKED`
|
|
||||||
- 检查属性:`event_id`, `event_title`, `importance`
|
|
||||||
|
|
||||||
#### 搜索功能
|
|
||||||
- [ ] 在搜索框输入关键词
|
|
||||||
- [ ] 点击搜索
|
|
||||||
- [ ] **验证事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
- 检查属性:`query`, `result_count`, `context: 'community'`
|
|
||||||
|
|
||||||
#### 筛选功能
|
|
||||||
- [ ] 切换重要性筛选
|
|
||||||
- [ ] **验证事件**: `SEARCH_FILTER_APPLIED`
|
|
||||||
- 检查属性:`filter_type: 'importance'`
|
|
||||||
- [ ] 切换排序方式
|
|
||||||
- [ ] **验证事件**: `SEARCH_FILTER_APPLIED`
|
|
||||||
- 检查属性:`filter_type: 'sort'`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📰 事件详情模块(useEventDetailEvents)
|
|
||||||
|
|
||||||
#### 页面浏览
|
|
||||||
- [ ] 点击任一事件进入详情页
|
|
||||||
- [ ] **验证事件**: `EVENT_DETAIL_VIEWED`
|
|
||||||
- 检查属性:`event_id`, `event_title`, `importance`
|
|
||||||
|
|
||||||
#### 分析查看
|
|
||||||
- [ ] 页面加载完成后
|
|
||||||
- [ ] **验证事件**: `EVENT_ANALYSIS_VIEWED`
|
|
||||||
- 检查属性:`analysis_type`, `related_stock_count`
|
|
||||||
|
|
||||||
#### 标签切换
|
|
||||||
- [ ] 点击"相关股票"标签
|
|
||||||
- [ ] **验证事件**: `NEWS_TAB_CLICKED`
|
|
||||||
- 检查属性:`tab_name: 'related_stocks'`
|
|
||||||
|
|
||||||
#### 相关股票点击
|
|
||||||
- [ ] 点击任一相关股票
|
|
||||||
- [ ] **验证事件**: `STOCK_CLICKED`
|
|
||||||
- 检查属性:`stock_code`, `source: 'event_detail_related_stocks'`
|
|
||||||
|
|
||||||
#### 社交互动
|
|
||||||
- [ ] 点击评论点赞按钮
|
|
||||||
- [ ] **验证事件**: `Comment Liked` 或 `Comment Unliked`
|
|
||||||
- 检查属性:`comment_id`, `event_id`, `action`
|
|
||||||
- [ ] 输入评论内容
|
|
||||||
- [ ] 点击发表评论
|
|
||||||
- [ ] **验证事件**: `Comment Added`
|
|
||||||
- 检查属性:`comment_id`, `event_id`, `content_length`
|
|
||||||
- [ ] 删除自己的评论(如果有)
|
|
||||||
- [ ] **验证事件**: `Comment Deleted`
|
|
||||||
- 检查属性:`comment_id`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📊 仪表板模块(useDashboardEvents)
|
|
||||||
|
|
||||||
#### 页面浏览
|
|
||||||
- [ ] 访问个人中心 `/dashboard/center`
|
|
||||||
- [ ] **验证事件**: `DASHBOARD_CENTER_VIEWED`
|
|
||||||
- 检查属性:`page_type: 'center'`
|
|
||||||
|
|
||||||
#### 自选股
|
|
||||||
- [ ] 查看自选股列表
|
|
||||||
- [ ] **验证事件**: `Watchlist Viewed`
|
|
||||||
- 检查属性:`stock_count`, `has_stocks`
|
|
||||||
|
|
||||||
#### 关注的事件
|
|
||||||
- [ ] 查看关注的事件列表
|
|
||||||
- [ ] **验证事件**: `Following Events Viewed`
|
|
||||||
- 检查属性:`event_count`
|
|
||||||
|
|
||||||
#### 评论管理
|
|
||||||
- [ ] 查看我的评论
|
|
||||||
- [ ] **验证事件**: `Comments Viewed`
|
|
||||||
- 检查属性:`comment_count`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 💹 模拟盘模块(useTradingSimulationEvents)
|
|
||||||
|
|
||||||
#### 进入模拟盘
|
|
||||||
- [ ] 访问模拟盘页面 `/trading-simulation`
|
|
||||||
- [ ] **验证事件**: `TRADING_SIMULATION_ENTERED`
|
|
||||||
- 检查属性:`total_value`, `available_cash`, `holdings_count`
|
|
||||||
|
|
||||||
#### 搜索股票
|
|
||||||
- [ ] 在搜索框输入股票代码/名称
|
|
||||||
- [ ] **验证事件**: `Simulation Stock Searched`
|
|
||||||
- 检查属性:`query`
|
|
||||||
|
|
||||||
#### 下单操作
|
|
||||||
- [ ] 选择一只股票
|
|
||||||
- [ ] 输入数量和价格
|
|
||||||
- [ ] 点击买入/卖出
|
|
||||||
- [ ] **验证事件**: `Simulation Order Placed`
|
|
||||||
- 检查属性:`stock_code`, `order_type`, `quantity`, `price`
|
|
||||||
|
|
||||||
#### 持仓查看
|
|
||||||
- [ ] 切换到持仓标签
|
|
||||||
- [ ] **验证事件**: `Simulation Holdings Viewed`
|
|
||||||
- 检查属性:`holdings_count`, `total_value`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔍 搜索模块(useSearchEvents)
|
|
||||||
|
|
||||||
#### 搜索发起
|
|
||||||
- [ ] 点击搜索框获得焦点
|
|
||||||
- [ ] **验证事件**: `SEARCH_INITIATED`
|
|
||||||
- 检查属性:`context: 'community'`
|
|
||||||
|
|
||||||
#### 搜索提交
|
|
||||||
- [ ] 输入搜索词
|
|
||||||
- [ ] 按回车或点击搜索
|
|
||||||
- [ ] **验证事件**: `SEARCH_QUERY_SUBMITTED`
|
|
||||||
- 检查属性:`query`, `result_count`, `has_results`
|
|
||||||
|
|
||||||
#### 无结果追踪
|
|
||||||
- [ ] 搜索一个不存在的词
|
|
||||||
- [ ] **验证事件**: `SEARCH_NO_RESULTS`
|
|
||||||
- 检查属性:`query`, `context`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🧭 导航模块(useNavigationEvents)
|
|
||||||
|
|
||||||
#### Logo点击
|
|
||||||
- [ ] 点击页面左上角Logo
|
|
||||||
- [ ] **验证事件**: `Logo Clicked`
|
|
||||||
- 检查属性:`component: 'main_navbar'`
|
|
||||||
|
|
||||||
#### 主题切换
|
|
||||||
- [ ] 点击主题切换按钮
|
|
||||||
- [ ] **验证事件**: `Theme Changed`
|
|
||||||
- 检查属性:`from_theme`, `to_theme`
|
|
||||||
|
|
||||||
#### 顶部导航
|
|
||||||
- [ ] 点击"高频跟踪"下拉菜单
|
|
||||||
- [ ] 点击"事件中心"
|
|
||||||
- [ ] **验证事件**: `MENU_ITEM_CLICKED`
|
|
||||||
- 检查属性:`item_name: '事件中心'`, `menu_type: 'dropdown'`
|
|
||||||
|
|
||||||
#### 二级导航
|
|
||||||
- [ ] 在二级导航栏点击任一菜单
|
|
||||||
- [ ] **验证事件**: `SIDEBAR_MENU_CLICKED`
|
|
||||||
- 检查属性:`item_name`, `path`, `level: 2`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 👤 个人资料模块(useProfileEvents)
|
|
||||||
|
|
||||||
#### 个人资料页面
|
|
||||||
- [ ] 访问个人资料页 `/profile`
|
|
||||||
- [ ] 点击编辑按钮
|
|
||||||
- [ ] **验证事件**: `Profile Field Edit Started`
|
|
||||||
|
|
||||||
#### 更新资料
|
|
||||||
- [ ] 修改昵称或其他信息
|
|
||||||
- [ ] 点击保存
|
|
||||||
- [ ] **验证事件**: `PROFILE_UPDATED`
|
|
||||||
- 检查属性:`updated_fields`, `field_count`
|
|
||||||
|
|
||||||
#### 上传头像
|
|
||||||
- [ ] 点击头像上传
|
|
||||||
- [ ] 选择图片
|
|
||||||
- [ ] **验证事件**: `Avatar Uploaded`
|
|
||||||
- 检查属性:`upload_method`, `file_size`
|
|
||||||
|
|
||||||
#### 设置页面
|
|
||||||
- [ ] 访问设置页 `/settings`
|
|
||||||
- [ ] 点击修改密码
|
|
||||||
- [ ] 输入当前密码和新密码
|
|
||||||
- [ ] 提交
|
|
||||||
- [ ] **验证事件**: `Password Changed`
|
|
||||||
- 检查属性:`success: true`
|
|
||||||
|
|
||||||
#### 通知设置
|
|
||||||
- [ ] 切换通知开关
|
|
||||||
- [ ] 点击保存
|
|
||||||
- [ ] **验证事件**: `Notification Preferences Changed`
|
|
||||||
- 检查属性:`email_enabled`, `push_enabled`, `sms_enabled`
|
|
||||||
|
|
||||||
#### 账号绑定
|
|
||||||
- [ ] 输入邮箱地址
|
|
||||||
- [ ] 获取验证码
|
|
||||||
- [ ] 输入验证码绑定
|
|
||||||
- [ ] **验证事件**: `Account Bound`
|
|
||||||
- 检查属性:`account_type: 'email'`, `success: true`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 💳 订阅支付模块(useSubscriptionEvents)
|
|
||||||
|
|
||||||
#### 订阅页面查看
|
|
||||||
- [ ] 打开订阅管理页面
|
|
||||||
- [ ] **验证事件**: `SUBSCRIPTION_PAGE_VIEWED`
|
|
||||||
- 检查属性:`current_plan`, `subscription_status`
|
|
||||||
|
|
||||||
#### 定价方案查看
|
|
||||||
- [ ] 浏览不同的定价方案
|
|
||||||
- [ ] **验证事件**: `Pricing Plan Viewed`
|
|
||||||
- 检查属性:`plan_name`, `price`
|
|
||||||
|
|
||||||
#### 选择方案
|
|
||||||
- [ ] 选择月付/年付
|
|
||||||
- [ ] 点击"立即订阅"
|
|
||||||
- [ ] **验证事件**: `Pricing Plan Selected`
|
|
||||||
- 检查属性:`plan_name`, `billing_cycle`, `price`
|
|
||||||
|
|
||||||
#### 查看支付页面
|
|
||||||
- [ ] 进入支付页面
|
|
||||||
- [ ] **验证事件**: `PAYMENT_PAGE_VIEWED`
|
|
||||||
- 检查属性:`plan_name`, `amount`
|
|
||||||
|
|
||||||
#### 支付流程
|
|
||||||
- [ ] 选择支付方式(微信支付)
|
|
||||||
- [ ] **验证事件**: `PAYMENT_METHOD_SELECTED`
|
|
||||||
- 检查属性:`payment_method: 'wechat_pay'`
|
|
||||||
- [ ] 点击创建订单
|
|
||||||
- [ ] **验证事件**: `PAYMENT_INITIATED`
|
|
||||||
- 检查属性:`plan_name`, `amount`, `payment_method`
|
|
||||||
|
|
||||||
#### 支付成功(需要完成支付)
|
|
||||||
- [ ] 完成微信支付
|
|
||||||
- [ ] **验证事件**: `PAYMENT_SUCCESSFUL`
|
|
||||||
- 检查属性:`order_id`, `transaction_id`
|
|
||||||
- [ ] **验证事件**: `SUBSCRIPTION_CREATED`
|
|
||||||
- 检查属性:`plan`, `billing_cycle`, `start_date`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 关键漏斗验证
|
|
||||||
|
|
||||||
### 注册激活漏斗
|
|
||||||
1. [ ] `PAGE_VIEWED` (注册页)
|
|
||||||
2. [ ] `USER_SIGNED_UP`
|
|
||||||
3. [ ] `USER_LOGGED_IN`
|
|
||||||
4. [ ] `PROFILE_UPDATED` (完善资料)
|
|
||||||
|
|
||||||
### 内容消费漏斗
|
|
||||||
1. [ ] `Community Page Viewed`
|
|
||||||
2. [ ] `News List Viewed`
|
|
||||||
3. [ ] `NEWS_ARTICLE_CLICKED`
|
|
||||||
4. [ ] `EVENT_DETAIL_VIEWED`
|
|
||||||
5. [ ] `Comment Added` (深度互动)
|
|
||||||
|
|
||||||
### 付费转化漏斗
|
|
||||||
1. [ ] `PAYWALL_SHOWN` (触发付费墙)
|
|
||||||
2. [ ] `SUBSCRIPTION_PAGE_VIEWED`
|
|
||||||
3. [ ] `Pricing Plan Selected`
|
|
||||||
4. [ ] `PAYMENT_INITIATED`
|
|
||||||
5. [ ] `PAYMENT_SUCCESSFUL`
|
|
||||||
6. [ ] `SUBSCRIPTION_CREATED`
|
|
||||||
|
|
||||||
### 模拟盘转化漏斗
|
|
||||||
1. [ ] `TRADING_SIMULATION_ENTERED`
|
|
||||||
2. [ ] `Simulation Stock Searched`
|
|
||||||
3. [ ] `Simulation Order Placed`
|
|
||||||
4. [ ] `Simulation Holdings Viewed`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 错误场景验证
|
|
||||||
|
|
||||||
### 失败追踪验证
|
|
||||||
- [ ] 密码修改失败
|
|
||||||
- **验证事件**: `Password Changed` (success: false)
|
|
||||||
- [ ] 支付失败
|
|
||||||
- **验证事件**: `PAYMENT_FAILED`
|
|
||||||
- 检查属性:`error_reason`
|
|
||||||
- [ ] 个人资料更新失败
|
|
||||||
- **验证事件**: `Profile Update Failed`
|
|
||||||
- 检查属性:`attempted_fields`, `error_message`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 PostHog控制台验证
|
|
||||||
|
|
||||||
### 实时事件检查
|
|
||||||
- [ ] 登录PostHog控制台
|
|
||||||
- [ ] 进入 "Live events" 页面
|
|
||||||
- [ ] 执行上述操作
|
|
||||||
- [ ] 确认每个操作都有对应事件出现
|
|
||||||
- [ ] 检查事件属性完整性
|
|
||||||
|
|
||||||
### 用户属性检查
|
|
||||||
- [ ] 进入 "Persons" 页面
|
|
||||||
- [ ] 找到测试用户
|
|
||||||
- [ ] 验证用户属性:
|
|
||||||
- [ ] `user_id`
|
|
||||||
- [ ] `email` (如果有)
|
|
||||||
- [ ] `subscription_tier`
|
|
||||||
- [ ] `role`
|
|
||||||
|
|
||||||
### 事件属性检查
|
|
||||||
对于每个验证的事件,确认以下属性存在:
|
|
||||||
- [ ] `timestamp` - 时间戳
|
|
||||||
- [ ] 事件特定属性(如 event_id, stock_code 等)
|
|
||||||
- [ ] 上下文属性(如 context, page_type 等)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 开发者工具验证
|
|
||||||
|
|
||||||
### Network 面板
|
|
||||||
- [ ] 找到 PostHog API 请求
|
|
||||||
- [ ] 检查请求URL: `https://app.posthog.com/e/`
|
|
||||||
- [ ] 检查请求Method: POST
|
|
||||||
- [ ] 检查Response Status: 200
|
|
||||||
- [ ] 检查Request Payload包含事件数据
|
|
||||||
|
|
||||||
### Console 面板
|
|
||||||
- [ ] 查找 logger.debug 输出
|
|
||||||
- [ ] 格式如:`[useFeatureEvents] 📊 Action Tracked`
|
|
||||||
- [ ] 验证输出的事件名称和参数正确
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 验证通过标准
|
|
||||||
|
|
||||||
### 单个事件验证通过
|
|
||||||
- ✅ Network面板能看到PostHog请求
|
|
||||||
- ✅ Console能看到logger.debug输出
|
|
||||||
- ✅ PostHog Live events能看到事件
|
|
||||||
- ✅ 事件名称正确
|
|
||||||
- ✅ 事件属性完整且准确
|
|
||||||
|
|
||||||
### 整体验证通过
|
|
||||||
- ✅ 所有核心功能模块至少验证了主要流程
|
|
||||||
- ✅ 关键漏斗的每一步都能追踪到
|
|
||||||
- ✅ 成功和失败场景都有追踪
|
|
||||||
- ✅ 没有JavaScript错误
|
|
||||||
- ✅ 所有事件在PostHog控制台可见
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 验证记录
|
|
||||||
|
|
||||||
### 验证信息
|
|
||||||
- **验证日期**: _______________
|
|
||||||
- **验证人员**: _______________
|
|
||||||
- **验证环境**: [ ] 开发环境 [ ] 测试环境 [ ] 生产环境
|
|
||||||
- **PostHog项目**: _______________
|
|
||||||
|
|
||||||
### 验证结果
|
|
||||||
- **总验证项**: _____
|
|
||||||
- **通过项**: _____
|
|
||||||
- **失败项**: _____
|
|
||||||
- **通过率**: _____%
|
|
||||||
|
|
||||||
### 发现的问题
|
|
||||||
| 问题描述 | 严重程度 | 状态 | 备注 |
|
|
||||||
|---------|---------|------|------|
|
|
||||||
| | | | |
|
|
||||||
| | | | |
|
|
||||||
|
|
||||||
### 验证结论
|
|
||||||
- [ ] ✅ 全部通过,可以上线
|
|
||||||
- [ ] ⚠️ 有轻微问题,可以上线但需修复
|
|
||||||
- [ ] ❌ 有严重问题,需要修复后重新验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 常见问题排查
|
|
||||||
|
|
||||||
### 问题1: 看不到PostHog请求
|
|
||||||
**可能原因**:
|
|
||||||
- PostHog未正确初始化
|
|
||||||
- API Key配置错误
|
|
||||||
- 网络被拦截
|
|
||||||
|
|
||||||
**排查步骤**:
|
|
||||||
1. 检查 `.env` 文件中的 `REACT_APP_POSTHOG_KEY`
|
|
||||||
2. 检查浏览器Console是否有错误
|
|
||||||
3. 检查网络代理设置
|
|
||||||
|
|
||||||
### 问题2: 事件属性缺失
|
|
||||||
**可能原因**:
|
|
||||||
- 传参时属性名拼写错误
|
|
||||||
- 某些数据为undefined
|
|
||||||
- Hook未正确初始化
|
|
||||||
|
|
||||||
**排查步骤**:
|
|
||||||
1. 查看Console的logger.debug输出
|
|
||||||
2. 检查Hook初始化时传入的参数
|
|
||||||
3. 检查调用追踪方法时的参数
|
|
||||||
|
|
||||||
### 问题3: 事件未在PostHog显示
|
|
||||||
**可能原因**:
|
|
||||||
- 数据同步延迟(通常<1分钟)
|
|
||||||
- PostHog项目选择错误
|
|
||||||
- 事件被过滤
|
|
||||||
|
|
||||||
**排查步骤**:
|
|
||||||
1. 等待1-2分钟后刷新
|
|
||||||
2. 确认选择了正确的项目
|
|
||||||
3. 检查PostHog的事件过滤器设置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关资源
|
|
||||||
|
|
||||||
- [PostHog 官方文档](https://posthog.com/docs)
|
|
||||||
- [POSTHOG_TRACKING_GUIDE.md](./POSTHOG_TRACKING_GUIDE.md) - 开发者指南
|
|
||||||
- [POSTHOG_INTEGRATION.md](./POSTHOG_INTEGRATION.md) - 集成说明
|
|
||||||
- [constants.js](./src/lib/constants.js) - 事件常量定义
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档版本**: v1.0
|
|
||||||
**最后更新**: 2025-10-29
|
|
||||||
**维护者**: 开发团队
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**完成!🎉** 现在你的前端可以实时接收事件推送了!
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,26 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "src",
|
"baseUrl": "src",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"*": ["src/*"]
|
||||||
"@assets/*": ["assets/*"],
|
|
||||||
"@components/*": ["components/*"],
|
|
||||||
"@constants/*": ["constants/*"],
|
|
||||||
"@contexts/*": ["contexts/*"],
|
|
||||||
"@data/*": ["data/*"],
|
|
||||||
"@hooks/*": ["hooks/*"],
|
|
||||||
"@layouts/*": ["layouts/*"],
|
|
||||||
"@lib/*": ["lib/*"],
|
|
||||||
"@mocks/*": ["mocks/*"],
|
|
||||||
"@providers/*": ["providers/*"],
|
|
||||||
"@routes/*": ["routes/*"],
|
|
||||||
"@services/*": ["services/*"],
|
|
||||||
"@store/*": ["store/*"],
|
|
||||||
"@styles/*": ["styles/*"],
|
|
||||||
"@theme/*": ["theme/*"],
|
|
||||||
"@utils/*": ["utils/*"],
|
|
||||||
"@variables/*": ["variables/*"],
|
|
||||||
"@views/*": ["views/*"]
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"exclude": ["node_modules", "build", "dist"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
108
mcp_config.py
108
mcp_config.py
@@ -1,108 +0,0 @@
|
|||||||
"""
|
|
||||||
MCP服务器配置文件
|
|
||||||
集中管理所有配置项
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict
|
|
||||||
from pydantic import BaseSettings
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""应用配置"""
|
|
||||||
|
|
||||||
# 服务器配置
|
|
||||||
SERVER_HOST: str = "0.0.0.0"
|
|
||||||
SERVER_PORT: int = 8900
|
|
||||||
DEBUG: bool = True
|
|
||||||
|
|
||||||
# 后端API服务端点
|
|
||||||
NEWS_API_URL: str = "http://222.128.1.157:21891"
|
|
||||||
ROADSHOW_API_URL: str = "http://222.128.1.157:19800"
|
|
||||||
CONCEPT_API_URL: str = "http://222.128.1.157:16801"
|
|
||||||
STOCK_ANALYSIS_API_URL: str = "http://222.128.1.157:8811"
|
|
||||||
|
|
||||||
# HTTP客户端配置
|
|
||||||
HTTP_TIMEOUT: float = 60.0
|
|
||||||
HTTP_MAX_RETRIES: int = 3
|
|
||||||
|
|
||||||
# 日志配置
|
|
||||||
LOG_LEVEL: str = "INFO"
|
|
||||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
|
|
||||||
# CORS配置
|
|
||||||
CORS_ORIGINS: list = ["*"]
|
|
||||||
CORS_CREDENTIALS: bool = True
|
|
||||||
CORS_METHODS: list = ["*"]
|
|
||||||
CORS_HEADERS: list = ["*"]
|
|
||||||
|
|
||||||
# LLM配置(如果需要集成)
|
|
||||||
LLM_PROVIDER: str = "openai" # openai, anthropic, etc.
|
|
||||||
LLM_API_KEY: str = ""
|
|
||||||
LLM_MODEL: str = "gpt-4"
|
|
||||||
LLM_BASE_URL: str = ""
|
|
||||||
|
|
||||||
# 速率限制
|
|
||||||
RATE_LIMIT_ENABLED: bool = False
|
|
||||||
RATE_LIMIT_PER_MINUTE: int = 60
|
|
||||||
|
|
||||||
# 缓存配置
|
|
||||||
CACHE_ENABLED: bool = True
|
|
||||||
CACHE_TTL: int = 300 # 秒
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
case_sensitive = True
|
|
||||||
|
|
||||||
|
|
||||||
# 全局设置实例
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
|
|
||||||
# 工具类别映射(用于组织和展示)
|
|
||||||
TOOL_CATEGORIES: Dict[str, list] = {
|
|
||||||
"新闻搜索": [
|
|
||||||
"search_news",
|
|
||||||
"search_china_news",
|
|
||||||
"search_medical_news"
|
|
||||||
],
|
|
||||||
"公司研究": [
|
|
||||||
"search_roadshows",
|
|
||||||
"search_research_reports"
|
|
||||||
],
|
|
||||||
"概念板块": [
|
|
||||||
"search_concepts",
|
|
||||||
"get_concept_details",
|
|
||||||
"get_stock_concepts",
|
|
||||||
"get_concept_statistics"
|
|
||||||
],
|
|
||||||
"股票分析": [
|
|
||||||
"search_limit_up_stocks",
|
|
||||||
"get_daily_stock_analysis"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 工具优先级(用于LLM选择工具时的提示)
|
|
||||||
TOOL_PRIORITIES: Dict[str, int] = {
|
|
||||||
"search_china_news": 10, # 最常用
|
|
||||||
"search_concepts": 9,
|
|
||||||
"search_limit_up_stocks": 8,
|
|
||||||
"search_research_reports": 8,
|
|
||||||
"get_stock_concepts": 7,
|
|
||||||
"search_news": 6,
|
|
||||||
"get_daily_stock_analysis": 5,
|
|
||||||
"get_concept_statistics": 5,
|
|
||||||
"search_medical_news": 4,
|
|
||||||
"search_roadshows": 4,
|
|
||||||
"get_concept_details": 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 默认参数配置
|
|
||||||
DEFAULT_PARAMS = {
|
|
||||||
"top_k": 20,
|
|
||||||
"page_size": 20,
|
|
||||||
"size": 10,
|
|
||||||
"sort_by": "change_pct",
|
|
||||||
"mode": "hybrid",
|
|
||||||
"exact_match": False,
|
|
||||||
}
|
|
||||||
783
mcp_database.py
783
mcp_database.py
@@ -1,783 +0,0 @@
|
|||||||
"""
|
|
||||||
MySQL数据库查询模块
|
|
||||||
提供股票财务数据查询功能
|
|
||||||
"""
|
|
||||||
|
|
||||||
import aiomysql
|
|
||||||
import logging
|
|
||||||
from typing import Dict, List, Any, Optional
|
|
||||||
from datetime import datetime, date
|
|
||||||
from decimal import Decimal
|
|
||||||
import json
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# MySQL连接配置
|
|
||||||
MYSQL_CONFIG = {
|
|
||||||
'host': '222.128.1.157',
|
|
||||||
'port': 33060,
|
|
||||||
'user': 'root',
|
|
||||||
'password': 'Zzl5588161!',
|
|
||||||
'db': 'stock',
|
|
||||||
'charset': 'utf8mb4',
|
|
||||||
'autocommit': True
|
|
||||||
}
|
|
||||||
|
|
||||||
# 全局连接池
|
|
||||||
_pool = None
|
|
||||||
|
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
|
||||||
"""JSON编码器,处理datetime和Decimal类型"""
|
|
||||||
def default(self, obj):
|
|
||||||
if isinstance(obj, (datetime, date)):
|
|
||||||
return obj.isoformat()
|
|
||||||
if isinstance(obj, Decimal):
|
|
||||||
return float(obj)
|
|
||||||
return super().default(obj)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_pool():
|
|
||||||
"""获取MySQL连接池"""
|
|
||||||
global _pool
|
|
||||||
if _pool is None:
|
|
||||||
_pool = await aiomysql.create_pool(
|
|
||||||
host=MYSQL_CONFIG['host'],
|
|
||||||
port=MYSQL_CONFIG['port'],
|
|
||||||
user=MYSQL_CONFIG['user'],
|
|
||||||
password=MYSQL_CONFIG['password'],
|
|
||||||
db=MYSQL_CONFIG['db'],
|
|
||||||
charset=MYSQL_CONFIG['charset'],
|
|
||||||
autocommit=MYSQL_CONFIG['autocommit'],
|
|
||||||
minsize=1,
|
|
||||||
maxsize=10
|
|
||||||
)
|
|
||||||
logger.info("MySQL connection pool created")
|
|
||||||
return _pool
|
|
||||||
|
|
||||||
|
|
||||||
async def close_pool():
|
|
||||||
"""关闭MySQL连接池"""
|
|
||||||
global _pool
|
|
||||||
if _pool:
|
|
||||||
_pool.close()
|
|
||||||
await _pool.wait_closed()
|
|
||||||
_pool = None
|
|
||||||
logger.info("MySQL connection pool closed")
|
|
||||||
|
|
||||||
|
|
||||||
def convert_row(row: Dict) -> Dict:
|
|
||||||
"""转换数据库行,处理特殊类型"""
|
|
||||||
if not row:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
result = {}
|
|
||||||
for key, value in row.items():
|
|
||||||
if isinstance(value, Decimal):
|
|
||||||
result[key] = float(value)
|
|
||||||
elif isinstance(value, (datetime, date)):
|
|
||||||
result[key] = value.isoformat()
|
|
||||||
else:
|
|
||||||
result[key] = value
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stock_basic_info(seccode: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取股票基本信息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seccode: 股票代码
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
股票基本信息字典
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
SECCODE, SECNAME, ORGNAME,
|
|
||||||
F001V as english_name,
|
|
||||||
F003V as legal_representative,
|
|
||||||
F004V as registered_address,
|
|
||||||
F005V as office_address,
|
|
||||||
F010D as establishment_date,
|
|
||||||
F011V as website,
|
|
||||||
F012V as email,
|
|
||||||
F013V as phone,
|
|
||||||
F015V as main_business,
|
|
||||||
F016V as business_scope,
|
|
||||||
F017V as company_profile,
|
|
||||||
F030V as industry_level1,
|
|
||||||
F032V as industry_level2,
|
|
||||||
F034V as sw_industry_level1,
|
|
||||||
F036V as sw_industry_level2,
|
|
||||||
F026V as province,
|
|
||||||
F028V as city,
|
|
||||||
F041V as chairman,
|
|
||||||
F042V as general_manager,
|
|
||||||
UPDATE_DATE as update_date
|
|
||||||
FROM ea_baseinfo
|
|
||||||
WHERE SECCODE = %s
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
await cursor.execute(query, (seccode,))
|
|
||||||
result = await cursor.fetchone()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
return convert_row(result)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stock_financial_index(
|
|
||||||
seccode: str,
|
|
||||||
start_date: Optional[str] = None,
|
|
||||||
end_date: Optional[str] = None,
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取股票财务指标
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seccode: 股票代码
|
|
||||||
start_date: 开始日期 YYYY-MM-DD
|
|
||||||
end_date: 结束日期 YYYY-MM-DD
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
财务指标列表
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
# 构建查询
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
SECCODE, SECNAME, ENDDATE, STARTDATE,
|
|
||||||
F069D as report_year,
|
|
||||||
F003N as eps, -- 每股收益
|
|
||||||
F004N as basic_eps,
|
|
||||||
F008N as bps, -- 每股净资产
|
|
||||||
F014N as roe, -- 净资产收益率
|
|
||||||
F016N as roa, -- 总资产报酬率
|
|
||||||
F017N as net_profit_margin, -- 净利润率
|
|
||||||
F022N as receivable_turnover, -- 应收账款周转率
|
|
||||||
F023N as inventory_turnover, -- 存货周转率
|
|
||||||
F025N as total_asset_turnover, -- 总资产周转率
|
|
||||||
F041N as debt_ratio, -- 资产负债率
|
|
||||||
F042N as current_ratio, -- 流动比率
|
|
||||||
F043N as quick_ratio, -- 速动比率
|
|
||||||
F052N as revenue_growth, -- 营业收入增长率
|
|
||||||
F053N as profit_growth, -- 净利润增长率
|
|
||||||
F089N as revenue, -- 营业收入
|
|
||||||
F090N as operating_cost, -- 营业成本
|
|
||||||
F101N as net_profit, -- 净利润
|
|
||||||
F102N as net_profit_parent -- 归母净利润
|
|
||||||
FROM ea_financialindex
|
|
||||||
WHERE SECCODE = %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = [seccode]
|
|
||||||
|
|
||||||
if start_date:
|
|
||||||
query += " AND ENDDATE >= %s"
|
|
||||||
params.append(start_date)
|
|
||||||
|
|
||||||
if end_date:
|
|
||||||
query += " AND ENDDATE <= %s"
|
|
||||||
params.append(end_date)
|
|
||||||
|
|
||||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
await cursor.execute(query, params)
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stock_trade_data(
|
|
||||||
seccode: str,
|
|
||||||
start_date: Optional[str] = None,
|
|
||||||
end_date: Optional[str] = None,
|
|
||||||
limit: int = 30
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取股票交易数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seccode: 股票代码
|
|
||||||
start_date: 开始日期 YYYY-MM-DD
|
|
||||||
end_date: 结束日期 YYYY-MM-DD
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
交易数据列表
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
SECCODE, SECNAME, TRADEDATE,
|
|
||||||
F002N as prev_close, -- 昨日收盘价
|
|
||||||
F003N as open_price, -- 开盘价
|
|
||||||
F005N as high_price, -- 最高价
|
|
||||||
F006N as low_price, -- 最低价
|
|
||||||
F007N as close_price, -- 收盘价
|
|
||||||
F004N as volume, -- 成交量
|
|
||||||
F011N as turnover, -- 成交金额
|
|
||||||
F009N as change_amount, -- 涨跌额
|
|
||||||
F010N as change_pct, -- 涨跌幅
|
|
||||||
F012N as turnover_rate, -- 换手率
|
|
||||||
F013N as amplitude, -- 振幅
|
|
||||||
F026N as pe_ratio, -- 市盈率
|
|
||||||
F020N as total_shares, -- 总股本
|
|
||||||
F021N as circulating_shares -- 流通股本
|
|
||||||
FROM ea_trade
|
|
||||||
WHERE SECCODE = %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = [seccode]
|
|
||||||
|
|
||||||
if start_date:
|
|
||||||
query += " AND TRADEDATE >= %s"
|
|
||||||
params.append(start_date)
|
|
||||||
|
|
||||||
if end_date:
|
|
||||||
query += " AND TRADEDATE <= %s"
|
|
||||||
params.append(end_date)
|
|
||||||
|
|
||||||
query += " ORDER BY TRADEDATE DESC LIMIT %s"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
await cursor.execute(query, params)
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stock_balance_sheet(
|
|
||||||
seccode: str,
|
|
||||||
start_date: Optional[str] = None,
|
|
||||||
end_date: Optional[str] = None,
|
|
||||||
limit: int = 8
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取资产负债表数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seccode: 股票代码
|
|
||||||
start_date: 开始日期
|
|
||||||
end_date: 结束日期
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
资产负债表数据列表
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
SECCODE, SECNAME, ENDDATE,
|
|
||||||
F001D as report_year,
|
|
||||||
F006N as cash, -- 货币资金
|
|
||||||
F009N as receivables, -- 应收账款
|
|
||||||
F015N as inventory, -- 存货
|
|
||||||
F019N as current_assets, -- 流动资产合计
|
|
||||||
F023N as long_term_investment, -- 长期股权投资
|
|
||||||
F025N as fixed_assets, -- 固定资产
|
|
||||||
F037N as noncurrent_assets, -- 非流动资产合计
|
|
||||||
F038N as total_assets, -- 资产总计
|
|
||||||
F039N as short_term_loan, -- 短期借款
|
|
||||||
F042N as payables, -- 应付账款
|
|
||||||
F052N as current_liabilities, -- 流动负债合计
|
|
||||||
F053N as long_term_loan, -- 长期借款
|
|
||||||
F060N as noncurrent_liabilities, -- 非流动负债合计
|
|
||||||
F061N as total_liabilities, -- 负债合计
|
|
||||||
F062N as share_capital, -- 股本
|
|
||||||
F063N as capital_reserve, -- 资本公积
|
|
||||||
F065N as retained_earnings, -- 未分配利润
|
|
||||||
F070N as total_equity -- 所有者权益合计
|
|
||||||
FROM ea_asset
|
|
||||||
WHERE SECCODE = %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = [seccode]
|
|
||||||
|
|
||||||
if start_date:
|
|
||||||
query += " AND ENDDATE >= %s"
|
|
||||||
params.append(start_date)
|
|
||||||
|
|
||||||
if end_date:
|
|
||||||
query += " AND ENDDATE <= %s"
|
|
||||||
params.append(end_date)
|
|
||||||
|
|
||||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
await cursor.execute(query, params)
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stock_cashflow(
|
|
||||||
seccode: str,
|
|
||||||
start_date: Optional[str] = None,
|
|
||||||
end_date: Optional[str] = None,
|
|
||||||
limit: int = 8
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取现金流量表数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seccode: 股票代码
|
|
||||||
start_date: 开始日期
|
|
||||||
end_date: 结束日期
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
现金流量表数据列表
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
SECCODE, SECNAME, ENDDATE, STARTDATE,
|
|
||||||
F001D as report_year,
|
|
||||||
F009N as operating_cash_inflow, -- 经营活动现金流入
|
|
||||||
F014N as operating_cash_outflow, -- 经营活动现金流出
|
|
||||||
F015N as net_operating_cashflow, -- 经营活动现金流量净额
|
|
||||||
F021N as investing_cash_inflow, -- 投资活动现金流入
|
|
||||||
F026N as investing_cash_outflow, -- 投资活动现金流出
|
|
||||||
F027N as net_investing_cashflow, -- 投资活动现金流量净额
|
|
||||||
F031N as financing_cash_inflow, -- 筹资活动现金流入
|
|
||||||
F035N as financing_cash_outflow, -- 筹资活动现金流出
|
|
||||||
F036N as net_financing_cashflow, -- 筹资活动现金流量净额
|
|
||||||
F039N as net_cash_increase, -- 现金及现金等价物净增加额
|
|
||||||
F044N as net_profit, -- 净利润
|
|
||||||
F046N as depreciation, -- 固定资产折旧
|
|
||||||
F060N as net_operating_cashflow_adjusted -- 经营活动现金流量净额(补充)
|
|
||||||
FROM ea_cashflow
|
|
||||||
WHERE SECCODE = %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = [seccode]
|
|
||||||
|
|
||||||
if start_date:
|
|
||||||
query += " AND ENDDATE >= %s"
|
|
||||||
params.append(start_date)
|
|
||||||
|
|
||||||
if end_date:
|
|
||||||
query += " AND ENDDATE <= %s"
|
|
||||||
params.append(end_date)
|
|
||||||
|
|
||||||
query += " ORDER BY ENDDATE DESC LIMIT %s"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
await cursor.execute(query, params)
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def search_stocks_by_criteria(
|
|
||||||
industry: Optional[str] = None,
|
|
||||||
province: Optional[str] = None,
|
|
||||||
min_market_cap: Optional[float] = None,
|
|
||||||
max_market_cap: Optional[float] = None,
|
|
||||||
limit: int = 50
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
按条件搜索股票
|
|
||||||
|
|
||||||
Args:
|
|
||||||
industry: 行业名称
|
|
||||||
province: 省份
|
|
||||||
min_market_cap: 最小市值(亿元)
|
|
||||||
max_market_cap: 最大市值(亿元)
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
股票列表
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
SELECT DISTINCT
|
|
||||||
b.SECCODE,
|
|
||||||
b.SECNAME,
|
|
||||||
b.F030V as industry_level1,
|
|
||||||
b.F032V as industry_level2,
|
|
||||||
b.F034V as sw_industry_level1,
|
|
||||||
b.F026V as province,
|
|
||||||
b.F028V as city,
|
|
||||||
b.F015V as main_business,
|
|
||||||
t.F007N as latest_price,
|
|
||||||
t.F010N as change_pct,
|
|
||||||
t.F026N as pe_ratio,
|
|
||||||
t.TRADEDATE as latest_trade_date
|
|
||||||
FROM ea_baseinfo b
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
|
||||||
FROM ea_trade
|
|
||||||
GROUP BY SECCODE
|
|
||||||
) latest ON b.SECCODE = latest.SECCODE
|
|
||||||
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
|
|
||||||
AND t.TRADEDATE = latest.max_date
|
|
||||||
WHERE 1=1
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if industry:
|
|
||||||
query += " AND (b.F030V LIKE %s OR b.F032V LIKE %s OR b.F034V LIKE %s)"
|
|
||||||
pattern = f"%{industry}%"
|
|
||||||
params.extend([pattern, pattern, pattern])
|
|
||||||
|
|
||||||
if province:
|
|
||||||
query += " AND b.F026V = %s"
|
|
||||||
params.append(province)
|
|
||||||
|
|
||||||
if min_market_cap or max_market_cap:
|
|
||||||
# 市值 = 最新价 * 总股本 / 100000000(转换为亿元)
|
|
||||||
if min_market_cap:
|
|
||||||
query += " AND (t.F007N * t.F020N / 100000000) >= %s"
|
|
||||||
params.append(min_market_cap)
|
|
||||||
|
|
||||||
if max_market_cap:
|
|
||||||
query += " AND (t.F007N * t.F020N / 100000000) <= %s"
|
|
||||||
params.append(max_market_cap)
|
|
||||||
|
|
||||||
query += " ORDER BY t.TRADEDATE DESC LIMIT %s"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
await cursor.execute(query, params)
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_stock_comparison(
|
|
||||||
seccodes: List[str],
|
|
||||||
metric: str = "financial"
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
股票对比分析
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seccodes: 股票代码列表
|
|
||||||
metric: 对比指标类型 (financial/trade)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
对比数据
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
if not seccodes or len(seccodes) < 2:
|
|
||||||
return {"error": "至少需要2个股票代码进行对比"}
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
placeholders = ','.join(['%s'] * len(seccodes))
|
|
||||||
|
|
||||||
if metric == "financial":
|
|
||||||
# 对比最新财务指标
|
|
||||||
query = f"""
|
|
||||||
SELECT
|
|
||||||
f.SECCODE, f.SECNAME, f.ENDDATE,
|
|
||||||
f.F003N as eps,
|
|
||||||
f.F008N as bps,
|
|
||||||
f.F014N as roe,
|
|
||||||
f.F017N as net_profit_margin,
|
|
||||||
f.F041N as debt_ratio,
|
|
||||||
f.F052N as revenue_growth,
|
|
||||||
f.F053N as profit_growth,
|
|
||||||
f.F089N as revenue,
|
|
||||||
f.F101N as net_profit
|
|
||||||
FROM ea_financialindex f
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT SECCODE, MAX(ENDDATE) as max_date
|
|
||||||
FROM ea_financialindex
|
|
||||||
WHERE SECCODE IN ({placeholders})
|
|
||||||
GROUP BY SECCODE
|
|
||||||
) latest ON f.SECCODE = latest.SECCODE
|
|
||||||
AND f.ENDDATE = latest.max_date
|
|
||||||
"""
|
|
||||||
else: # trade
|
|
||||||
# 对比最新交易数据
|
|
||||||
query = f"""
|
|
||||||
SELECT
|
|
||||||
t.SECCODE, t.SECNAME, t.TRADEDATE,
|
|
||||||
t.F007N as close_price,
|
|
||||||
t.F010N as change_pct,
|
|
||||||
t.F012N as turnover_rate,
|
|
||||||
t.F026N as pe_ratio,
|
|
||||||
t.F020N as total_shares,
|
|
||||||
t.F021N as circulating_shares
|
|
||||||
FROM ea_trade t
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
|
||||||
FROM ea_trade
|
|
||||||
WHERE SECCODE IN ({placeholders})
|
|
||||||
GROUP BY SECCODE
|
|
||||||
) latest ON t.SECCODE = latest.SECCODE
|
|
||||||
AND t.TRADEDATE = latest.max_date
|
|
||||||
"""
|
|
||||||
|
|
||||||
await cursor.execute(query, seccodes)
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"comparison_type": metric,
|
|
||||||
"stocks": [convert_row(row) for row in results]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取用户自选股列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
自选股列表(包含最新行情数据)
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
# 查询用户自选股(假设有 user_favorites 表)
|
|
||||||
# 如果没有此表,可以根据实际情况调整
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
f.user_id,
|
|
||||||
f.stock_code,
|
|
||||||
b.SECNAME as stock_name,
|
|
||||||
b.F030V as industry,
|
|
||||||
t.F007N as current_price,
|
|
||||||
t.F010N as change_pct,
|
|
||||||
t.F012N as turnover_rate,
|
|
||||||
t.F026N as pe_ratio,
|
|
||||||
t.TRADEDATE as latest_trade_date,
|
|
||||||
f.created_at as favorite_time
|
|
||||||
FROM user_favorites f
|
|
||||||
INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT SECCODE, MAX(TRADEDATE) as max_date
|
|
||||||
FROM ea_trade
|
|
||||||
GROUP BY SECCODE
|
|
||||||
) latest ON b.SECCODE = latest.SECCODE
|
|
||||||
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
|
|
||||||
AND t.TRADEDATE = latest.max_date
|
|
||||||
WHERE f.user_id = %s AND f.is_deleted = 0
|
|
||||||
ORDER BY f.created_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
await cursor.execute(query, [user_id, limit])
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取用户自选事件列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
limit: 返回条数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
自选事件列表
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
# 查询用户自选事件(假设有 user_event_favorites 表)
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
f.user_id,
|
|
||||||
f.event_id,
|
|
||||||
e.title,
|
|
||||||
e.description,
|
|
||||||
e.event_date,
|
|
||||||
e.importance,
|
|
||||||
e.related_stocks,
|
|
||||||
e.category,
|
|
||||||
f.created_at as favorite_time
|
|
||||||
FROM user_event_favorites f
|
|
||||||
INNER JOIN events e ON f.event_id = e.id
|
|
||||||
WHERE f.user_id = %s AND f.is_deleted = 0
|
|
||||||
ORDER BY e.event_date DESC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
await cursor.execute(query, [user_id, limit])
|
|
||||||
results = await cursor.fetchall()
|
|
||||||
|
|
||||||
return [convert_row(row) for row in results]
|
|
||||||
|
|
||||||
|
|
||||||
async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
添加自选股
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
stock_code: 股票代码
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
操作结果
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
# 检查是否已存在
|
|
||||||
check_query = """
|
|
||||||
SELECT id, is_deleted
|
|
||||||
FROM user_favorites
|
|
||||||
WHERE user_id = %s AND stock_code = %s
|
|
||||||
"""
|
|
||||||
await cursor.execute(check_query, [user_id, stock_code])
|
|
||||||
existing = await cursor.fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
if existing['is_deleted'] == 1:
|
|
||||||
# 恢复已删除的记录
|
|
||||||
update_query = """
|
|
||||||
UPDATE user_favorites
|
|
||||||
SET is_deleted = 0, updated_at = NOW()
|
|
||||||
WHERE id = %s
|
|
||||||
"""
|
|
||||||
await cursor.execute(update_query, [existing['id']])
|
|
||||||
return {"success": True, "message": "已恢复自选股"}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "该股票已在自选中"}
|
|
||||||
|
|
||||||
# 插入新记录
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted)
|
|
||||||
VALUES (%s, %s, NOW(), NOW(), 0)
|
|
||||||
"""
|
|
||||||
await cursor.execute(insert_query, [user_id, stock_code])
|
|
||||||
return {"success": True, "message": "添加自选股成功"}
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
删除自选股
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
stock_code: 股票代码
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
操作结果
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
UPDATE user_favorites
|
|
||||||
SET is_deleted = 1, updated_at = NOW()
|
|
||||||
WHERE user_id = %s AND stock_code = %s AND is_deleted = 0
|
|
||||||
"""
|
|
||||||
result = await cursor.execute(query, [user_id, stock_code])
|
|
||||||
|
|
||||||
if result > 0:
|
|
||||||
return {"success": True, "message": "删除自选股成功"}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "未找到该自选股"}
|
|
||||||
|
|
||||||
|
|
||||||
async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
添加自选事件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
event_id: 事件ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
操作结果
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
# 检查是否已存在
|
|
||||||
check_query = """
|
|
||||||
SELECT id, is_deleted
|
|
||||||
FROM user_event_favorites
|
|
||||||
WHERE user_id = %s AND event_id = %s
|
|
||||||
"""
|
|
||||||
await cursor.execute(check_query, [user_id, event_id])
|
|
||||||
existing = await cursor.fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
if existing['is_deleted'] == 1:
|
|
||||||
# 恢复已删除的记录
|
|
||||||
update_query = """
|
|
||||||
UPDATE user_event_favorites
|
|
||||||
SET is_deleted = 0, updated_at = NOW()
|
|
||||||
WHERE id = %s
|
|
||||||
"""
|
|
||||||
await cursor.execute(update_query, [existing['id']])
|
|
||||||
return {"success": True, "message": "已恢复自选事件"}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "该事件已在自选中"}
|
|
||||||
|
|
||||||
# 插入新记录
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted)
|
|
||||||
VALUES (%s, %s, NOW(), NOW(), 0)
|
|
||||||
"""
|
|
||||||
await cursor.execute(insert_query, [user_id, event_id])
|
|
||||||
return {"success": True, "message": "添加自选事件成功"}
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
删除自选事件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
event_id: 事件ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
操作结果
|
|
||||||
"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
query = """
|
|
||||||
UPDATE user_event_favorites
|
|
||||||
SET is_deleted = 1, updated_at = NOW()
|
|
||||||
WHERE user_id = %s AND event_id = %s AND is_deleted = 0
|
|
||||||
"""
|
|
||||||
result = await cursor.execute(query, [user_id, event_id])
|
|
||||||
|
|
||||||
if result > 0:
|
|
||||||
return {"success": True, "message": "删除自选事件成功"}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "未找到该自选事件"}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
"""
|
|
||||||
Elasticsearch 连接和工具模块
|
|
||||||
用于聊天记录存储和向量搜索
|
|
||||||
"""
|
|
||||||
|
|
||||||
from elasticsearch import Elasticsearch, helpers
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import openai
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ==================== 配置 ====================
|
|
||||||
|
|
||||||
# ES 配置
|
|
||||||
ES_CONFIG = {
|
|
||||||
"host": "http://222.128.1.157:19200",
|
|
||||||
"index_chat_history": "agent_chat_history", # 聊天记录索引
|
|
||||||
}
|
|
||||||
|
|
||||||
# Embedding 配置
|
|
||||||
EMBEDDING_CONFIG = {
|
|
||||||
"api_key": "dummy",
|
|
||||||
"base_url": "http://222.128.1.157:18008/v1",
|
|
||||||
"model": "qwen3-embedding-8b",
|
|
||||||
"dims": 4096, # 向量维度
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==================== ES 客户端 ====================
|
|
||||||
|
|
||||||
class ESClient:
|
|
||||||
"""Elasticsearch 客户端封装"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60)
|
|
||||||
self.chat_index = ES_CONFIG["index_chat_history"]
|
|
||||||
|
|
||||||
# 初始化 OpenAI 客户端用于 embedding
|
|
||||||
self.embedding_client = openai.OpenAI(
|
|
||||||
api_key=EMBEDDING_CONFIG["api_key"],
|
|
||||||
base_url=EMBEDDING_CONFIG["base_url"],
|
|
||||||
)
|
|
||||||
self.embedding_model = EMBEDDING_CONFIG["model"]
|
|
||||||
|
|
||||||
# 初始化索引
|
|
||||||
self.create_chat_history_index()
|
|
||||||
|
|
||||||
def create_chat_history_index(self):
|
|
||||||
"""创建聊天记录索引"""
|
|
||||||
if self.es.indices.exists(index=self.chat_index):
|
|
||||||
logger.info(f"索引 {self.chat_index} 已存在")
|
|
||||||
return
|
|
||||||
|
|
||||||
mappings = {
|
|
||||||
"properties": {
|
|
||||||
"session_id": {"type": "keyword"}, # 会话ID
|
|
||||||
"user_id": {"type": "keyword"}, # 用户ID
|
|
||||||
"user_nickname": {"type": "text"}, # 用户昵称
|
|
||||||
"user_avatar": {"type": "keyword"}, # 用户头像URL
|
|
||||||
"message_type": {"type": "keyword"}, # user / assistant
|
|
||||||
"message": {"type": "text"}, # 消息内容
|
|
||||||
"message_embedding": { # 消息向量
|
|
||||||
"type": "dense_vector",
|
|
||||||
"dims": EMBEDDING_CONFIG["dims"],
|
|
||||||
"index": True,
|
|
||||||
"similarity": "cosine"
|
|
||||||
},
|
|
||||||
"plan": {"type": "text"}, # 执行计划(仅 assistant)
|
|
||||||
"steps": {"type": "text"}, # 执行步骤(仅 assistant)
|
|
||||||
"timestamp": {"type": "date"}, # 时间戳
|
|
||||||
"created_at": {"type": "date"}, # 创建时间
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.es.indices.create(index=self.chat_index, body={"mappings": mappings})
|
|
||||||
logger.info(f"创建索引: {self.chat_index}")
|
|
||||||
|
|
||||||
def generate_embedding(self, text: str) -> List[float]:
|
|
||||||
"""生成文本向量"""
|
|
||||||
try:
|
|
||||||
if not text or len(text.strip()) == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 截断过长文本
|
|
||||||
text = text[:16000] if len(text) > 16000 else text
|
|
||||||
|
|
||||||
response = self.embedding_client.embeddings.create(
|
|
||||||
model=self.embedding_model,
|
|
||||||
input=[text]
|
|
||||||
)
|
|
||||||
return response.data[0].embedding
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Embedding 生成失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def save_chat_message(
|
|
||||||
self,
|
|
||||||
session_id: str,
|
|
||||||
user_id: str,
|
|
||||||
user_nickname: str,
|
|
||||||
user_avatar: str,
|
|
||||||
message_type: str, # "user" or "assistant"
|
|
||||||
message: str,
|
|
||||||
plan: Optional[str] = None,
|
|
||||||
steps: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
保存聊天消息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: 会话ID
|
|
||||||
user_id: 用户ID
|
|
||||||
user_nickname: 用户昵称
|
|
||||||
user_avatar: 用户头像URL
|
|
||||||
message_type: 消息类型 (user/assistant)
|
|
||||||
message: 消息内容
|
|
||||||
plan: 执行计划(可选)
|
|
||||||
steps: 执行步骤(可选)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
文档ID
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 生成向量
|
|
||||||
embedding = self.generate_embedding(message)
|
|
||||||
|
|
||||||
doc = {
|
|
||||||
"session_id": session_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"user_nickname": user_nickname,
|
|
||||||
"user_avatar": user_avatar,
|
|
||||||
"message_type": message_type,
|
|
||||||
"message": message,
|
|
||||||
"message_embedding": embedding if embedding else None,
|
|
||||||
"plan": plan,
|
|
||||||
"steps": steps,
|
|
||||||
"timestamp": datetime.now(),
|
|
||||||
"created_at": datetime.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
result = self.es.index(index=self.chat_index, body=doc)
|
|
||||||
logger.info(f"保存聊天记录: {result['_id']}")
|
|
||||||
return result["_id"]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"保存聊天记录失败: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取用户的聊天会话列表
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
limit: 返回数量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
会话列表,每个会话包含:session_id, last_message, last_timestamp
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
|
|
||||||
query = {
|
|
||||||
"query": {
|
|
||||||
"term": {"user_id": user_id}
|
|
||||||
},
|
|
||||||
"aggs": {
|
|
||||||
"sessions": {
|
|
||||||
"terms": {
|
|
||||||
"field": "session_id",
|
|
||||||
"size": limit,
|
|
||||||
"order": {"last_message": "desc"}
|
|
||||||
},
|
|
||||||
"aggs": {
|
|
||||||
"last_message": {
|
|
||||||
"max": {"field": "timestamp"}
|
|
||||||
},
|
|
||||||
"last_message_content": {
|
|
||||||
"top_hits": {
|
|
||||||
"size": 1,
|
|
||||||
"sort": [{"timestamp": {"order": "desc"}}],
|
|
||||||
"_source": ["message", "timestamp", "message_type"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"size": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
result = self.es.search(index=self.chat_index, body=query)
|
|
||||||
|
|
||||||
sessions = []
|
|
||||||
for bucket in result["aggregations"]["sessions"]["buckets"]:
|
|
||||||
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
|
|
||||||
sessions.append({
|
|
||||||
"session_id": bucket["key"],
|
|
||||||
"last_message": session_data["message"],
|
|
||||||
"last_timestamp": session_data["timestamp"],
|
|
||||||
"message_count": bucket["doc_count"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return sessions
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取会话列表失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_chat_history(
|
|
||||||
self,
|
|
||||||
session_id: str,
|
|
||||||
limit: int = 100
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取指定会话的聊天历史
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_id: 会话ID
|
|
||||||
limit: 返回数量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
聊天记录列表
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
query = {
|
|
||||||
"query": {
|
|
||||||
"term": {"session_id": session_id}
|
|
||||||
},
|
|
||||||
"sort": [{"timestamp": {"order": "asc"}}],
|
|
||||||
"size": limit
|
|
||||||
}
|
|
||||||
|
|
||||||
result = self.es.search(index=self.chat_index, body=query)
|
|
||||||
|
|
||||||
messages = []
|
|
||||||
for hit in result["hits"]["hits"]:
|
|
||||||
doc = hit["_source"]
|
|
||||||
messages.append({
|
|
||||||
"message_type": doc["message_type"],
|
|
||||||
"message": doc["message"],
|
|
||||||
"plan": doc.get("plan"),
|
|
||||||
"steps": doc.get("steps"),
|
|
||||||
"timestamp": doc["timestamp"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return messages
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取聊天历史失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def search_chat_history(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
query_text: str,
|
|
||||||
top_k: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
向量搜索聊天历史
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: 用户ID
|
|
||||||
query_text: 查询文本
|
|
||||||
top_k: 返回数量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
相关聊天记录列表
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 生成查询向量
|
|
||||||
query_embedding = self.generate_embedding(query_text)
|
|
||||||
if not query_embedding:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 向量搜索
|
|
||||||
query = {
|
|
||||||
"query": {
|
|
||||||
"bool": {
|
|
||||||
"must": [
|
|
||||||
{"term": {"user_id": user_id}},
|
|
||||||
{
|
|
||||||
"script_score": {
|
|
||||||
"query": {"match_all": {}},
|
|
||||||
"script": {
|
|
||||||
"source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0",
|
|
||||||
"params": {"query_vector": query_embedding}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"size": top_k
|
|
||||||
}
|
|
||||||
|
|
||||||
result = self.es.search(index=self.chat_index, body=query)
|
|
||||||
|
|
||||||
messages = []
|
|
||||||
for hit in result["hits"]["hits"]:
|
|
||||||
doc = hit["_source"]
|
|
||||||
messages.append({
|
|
||||||
"session_id": doc["session_id"],
|
|
||||||
"message_type": doc["message_type"],
|
|
||||||
"message": doc["message"],
|
|
||||||
"timestamp": doc["timestamp"],
|
|
||||||
"score": hit["_score"],
|
|
||||||
})
|
|
||||||
|
|
||||||
return messages
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"向量搜索失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 全局实例 ====================
|
|
||||||
|
|
||||||
# 创建全局 ES 客户端
|
|
||||||
es_client = ESClient()
|
|
||||||
2383
mcp_server.py
2383
mcp_server.py
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
|||||||
-- 数据库迁移脚本:添加优惠码和订阅升级相关表
|
|
||||||
-- 执行时间:2025-xx-xx
|
|
||||||
-- 作者:Claude Code
|
|
||||||
-- 说明:此脚本添加了优惠码、优惠码使用记录和订阅升级记录三张新表,并扩展了 payment_orders 表
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 1. 创建优惠码表
|
|
||||||
-- ============================================
|
|
||||||
CREATE TABLE IF NOT EXISTS `promo_codes` (
|
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
`code` VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码(唯一)',
|
|
||||||
`description` VARCHAR(200) DEFAULT NULL COMMENT '优惠码描述',
|
|
||||||
|
|
||||||
-- 折扣类型和值
|
|
||||||
`discount_type` VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比) 或 fixed_amount(固定金额)',
|
|
||||||
`discount_value` DECIMAL(10, 2) NOT NULL COMMENT '折扣值',
|
|
||||||
|
|
||||||
-- 适用范围
|
|
||||||
`applicable_plans` VARCHAR(200) DEFAULT NULL COMMENT '适用套餐(JSON格式),如 ["pro", "max"],null表示全部适用',
|
|
||||||
`applicable_cycles` VARCHAR(50) DEFAULT NULL COMMENT '适用周期(JSON格式),如 ["monthly", "yearly"],null表示全部适用',
|
|
||||||
`min_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '最低消费金额',
|
|
||||||
|
|
||||||
-- 使用限制
|
|
||||||
`max_uses` INT DEFAULT NULL COMMENT '最大使用次数,null表示无限制',
|
|
||||||
`max_uses_per_user` INT DEFAULT 1 COMMENT '每个用户最多使用次数',
|
|
||||||
`current_uses` INT DEFAULT 0 COMMENT '当前已使用次数',
|
|
||||||
|
|
||||||
-- 有效期
|
|
||||||
`valid_from` DATETIME NOT NULL COMMENT '生效时间',
|
|
||||||
`valid_until` DATETIME NOT NULL COMMENT '失效时间',
|
|
||||||
|
|
||||||
-- 状态
|
|
||||||
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
|
||||||
`created_by` INT DEFAULT NULL COMMENT '创建人(管理员ID)',
|
|
||||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
INDEX idx_code (`code`),
|
|
||||||
INDEX idx_valid_dates (`valid_from`, `valid_until`),
|
|
||||||
INDEX idx_is_active (`is_active`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 2. 创建优惠码使用记录表
|
|
||||||
-- ============================================
|
|
||||||
CREATE TABLE IF NOT EXISTS `promo_code_usage` (
|
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
`promo_code_id` INT NOT NULL COMMENT '优惠码ID',
|
|
||||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
|
||||||
`order_id` INT NOT NULL COMMENT '订单ID',
|
|
||||||
|
|
||||||
`original_amount` DECIMAL(10, 2) NOT NULL COMMENT '原价',
|
|
||||||
`discount_amount` DECIMAL(10, 2) NOT NULL COMMENT '优惠金额',
|
|
||||||
`final_amount` DECIMAL(10, 2) NOT NULL COMMENT '实付金额',
|
|
||||||
|
|
||||||
`used_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '使用时间',
|
|
||||||
|
|
||||||
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
INDEX idx_user_id (`user_id`),
|
|
||||||
INDEX idx_promo_code_id (`promo_code_id`),
|
|
||||||
INDEX idx_order_id (`order_id`),
|
|
||||||
INDEX idx_used_at (`used_at`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 3. 创建订阅升级记录表
|
|
||||||
-- ============================================
|
|
||||||
CREATE TABLE IF NOT EXISTS `subscription_upgrades` (
|
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
|
||||||
`order_id` INT NOT NULL COMMENT '订单ID',
|
|
||||||
|
|
||||||
-- 原订阅信息
|
|
||||||
`from_plan` VARCHAR(20) NOT NULL COMMENT '原套餐',
|
|
||||||
`from_cycle` VARCHAR(10) NOT NULL COMMENT '原周期',
|
|
||||||
`from_end_date` DATETIME DEFAULT NULL COMMENT '原到期日',
|
|
||||||
|
|
||||||
-- 新订阅信息
|
|
||||||
`to_plan` VARCHAR(20) NOT NULL COMMENT '新套餐',
|
|
||||||
`to_cycle` VARCHAR(10) NOT NULL COMMENT '新周期',
|
|
||||||
`to_end_date` DATETIME NOT NULL COMMENT '新到期日',
|
|
||||||
|
|
||||||
-- 价格计算
|
|
||||||
`remaining_value` DECIMAL(10, 2) NOT NULL COMMENT '剩余价值',
|
|
||||||
`upgrade_amount` DECIMAL(10, 2) NOT NULL COMMENT '升级应付金额',
|
|
||||||
`actual_amount` DECIMAL(10, 2) NOT NULL COMMENT '实际支付金额',
|
|
||||||
|
|
||||||
`upgrade_type` VARCHAR(20) NOT NULL COMMENT '升级类型: plan_upgrade(套餐升级), cycle_change(周期变更), both(都变更)',
|
|
||||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
INDEX idx_user_id (`user_id`),
|
|
||||||
INDEX idx_order_id (`order_id`),
|
|
||||||
INDEX idx_created_at (`created_at`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅升级/降级记录表';
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 4. 扩展 payment_orders 表(添加新字段)
|
|
||||||
-- ============================================
|
|
||||||
-- 注意:这些字段是可选的扩展,用于记录优惠码和升级信息
|
|
||||||
-- 如果字段已存在会报错,可以忽略
|
|
||||||
|
|
||||||
ALTER TABLE `payment_orders`
|
|
||||||
ADD COLUMN `promo_code_id` INT DEFAULT NULL COMMENT '使用的优惠码ID' AFTER `remark`,
|
|
||||||
ADD COLUMN `original_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '原价(使用优惠码前)' AFTER `promo_code_id`,
|
|
||||||
ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0 COMMENT '优惠金额' AFTER `original_amount`,
|
|
||||||
ADD COLUMN `is_upgrade` BOOLEAN DEFAULT FALSE COMMENT '是否为升级订单' AFTER `discount_amount`,
|
|
||||||
ADD COLUMN `upgrade_from_plan` VARCHAR(20) DEFAULT NULL COMMENT '从哪个套餐升级' AFTER `is_upgrade`;
|
|
||||||
|
|
||||||
-- 添加外键约束
|
|
||||||
ALTER TABLE `payment_orders`
|
|
||||||
ADD CONSTRAINT `fk_payment_orders_promo_code`
|
|
||||||
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 5. 插入示例优惠码(供测试使用)
|
|
||||||
-- ============================================
|
|
||||||
-- 10% 折扣优惠码,适用所有套餐和周期
|
|
||||||
INSERT INTO `promo_codes`
|
|
||||||
(`code`, `description`, `discount_type`, `discount_value`, `applicable_plans`, `applicable_cycles`, `min_amount`, `max_uses`, `max_uses_per_user`, `valid_from`, `valid_until`, `is_active`)
|
|
||||||
VALUES
|
|
||||||
('WELCOME10', '新用户欢迎优惠 - 10%折扣', 'percentage', 10.00, NULL, NULL, NULL, NULL, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
|
|
||||||
('ANNUAL20', '年付专享 - 20%折扣', 'percentage', 20.00, NULL, '["yearly"]', NULL, 100, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
|
|
||||||
('SUMMER50', '夏季促销 - 减免50元', 'fixed_amount', 50.00, '["max"]', NULL, 100.00, 50, 1, NOW(), DATE_ADD(NOW(), INTERVAL 3 MONTH), TRUE);
|
|
||||||
|
|
||||||
-- 完成
|
|
||||||
SELECT 'Migration completed successfully!' AS status;
|
|
||||||
35
package.json
35
package.json
@@ -18,9 +18,9 @@
|
|||||||
"@fullcalendar/daygrid": "^5.9.0",
|
"@fullcalendar/daygrid": "^5.9.0",
|
||||||
"@fullcalendar/interaction": "^5.9.0",
|
"@fullcalendar/interaction": "^5.9.0",
|
||||||
"@fullcalendar/react": "^5.9.0",
|
"@fullcalendar/react": "^5.9.0",
|
||||||
"@reduxjs/toolkit": "^2.9.2",
|
"@react-three/drei": "^9.11.3",
|
||||||
|
"@react-three/fiber": "^8.0.27",
|
||||||
"@splidejs/react-splide": "^0.7.12",
|
"@splidejs/react-splide": "^0.7.12",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@visx/visx": "^3.12.0",
|
"@visx/visx": "^3.12.0",
|
||||||
"antd": "^5.27.4",
|
"antd": "^5.27.4",
|
||||||
@@ -37,11 +37,11 @@
|
|||||||
"fullcalendar": "^5.9.0",
|
"fullcalendar": "^5.9.0",
|
||||||
"globalize": "^1.7.0",
|
"globalize": "^1.7.0",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"match-sorter": "6.3.0",
|
"match-sorter": "6.3.0",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"nouislider": "15.0.0",
|
"nouislider": "15.0.0",
|
||||||
"posthog-js": "^1.281.0",
|
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-apexcharts": "^1.3.9",
|
"react-apexcharts": "^1.3.9",
|
||||||
"react-big-calendar": "^0.33.2",
|
"react-big-calendar": "^0.33.2",
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
"react-input-pin-code": "^1.1.5",
|
"react-input-pin-code": "^1.1.5",
|
||||||
"react-just-parallax": "^3.1.16",
|
"react-just-parallax": "^3.1.16",
|
||||||
"react-jvectormap": "0.0.16",
|
"react-jvectormap": "0.0.16",
|
||||||
|
"react-leaflet": "^3.2.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-quill": "^2.0.0-beta.4",
|
"react-quill": "^2.0.0-beta.4",
|
||||||
"react-redux": "^9.2.0",
|
|
||||||
"react-responsive": "^10.0.1",
|
"react-responsive": "^10.0.1",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
"styled-components": "^5.3.11",
|
"styled-components": "^5.3.11",
|
||||||
"stylis": "^4.0.10",
|
"stylis": "^4.0.10",
|
||||||
"stylis-plugin-rtl": "^2.1.1",
|
"stylis-plugin-rtl": "^2.1.1",
|
||||||
|
"three": "^0.142.0",
|
||||||
"tsparticles-slim": "^2.12.0"
|
"tsparticles-slim": "^2.12.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
@@ -89,33 +90,22 @@
|
|||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prestart": "kill-port 3000",
|
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco start",
|
||||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
"start:mock": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||||
"prestart:real": "kill-port 3000",
|
|
||||||
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
|
|
||||||
"prestart:dev": "kill-port 3000",
|
|
||||||
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development 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.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses",
|
||||||
"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.py",
|
|
||||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
|
|
||||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||||
"test": "craco test --env=jsdom",
|
"test": "craco test --env=jsdom",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"deploy": "bash scripts/deploy-from-local.sh",
|
"deploy": "npm run build",
|
||||||
"deploy:setup": "bash scripts/setup-deployment.sh",
|
|
||||||
"rollback": "bash scripts/rollback-from-local.sh",
|
|
||||||
"lint:check": "eslint . --ext=js,jsx; exit 0",
|
"lint:check": "eslint . --ext=js,jsx; exit 0",
|
||||||
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
|
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
|
||||||
"clean": "rm -rf node_modules/ package-lock.json",
|
"install:clean": "rm -rf node_modules/ && rm -rf package-lock.json && npm install && npm start"
|
||||||
"reinstall": "npm run clean && npm install"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@craco/craco": "^7.1.0",
|
"@craco/craco": "^7.1.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"concurrently": "^8.2.2",
|
|
||||||
"env-cmd": "^11.0.0",
|
"env-cmd": "^11.0.0",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.4.0",
|
"eslint-plugin-prettier": "3.4.0",
|
||||||
@@ -124,12 +114,12 @@
|
|||||||
"imagemin": "^9.0.1",
|
"imagemin": "^9.0.1",
|
||||||
"imagemin-mozjpeg": "^10.0.0",
|
"imagemin-mozjpeg": "^10.0.0",
|
||||||
"imagemin-pngquant": "^10.0.0",
|
"imagemin-pngquant": "^10.0.0",
|
||||||
"kill-port": "^2.0.1",
|
|
||||||
"msw": "^2.11.5",
|
"msw": "^2.11.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"react-error-overlay": "6.0.9",
|
"react-error-overlay": "6.0.9",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"webpack-bundle-analyzer": "^4.10.2",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"yn": "^5.1.0"
|
"yn": "^5.1.0"
|
||||||
@@ -150,8 +140,5 @@
|
|||||||
"workerDirectory": [
|
"workerDirectory": [
|
||||||
"public"
|
"public"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "^2.3.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/badge.png
BIN
public/badge.png
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -65,9 +65,6 @@
|
|||||||
To begin the development, run `npm start` or `yarn start`.
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
-->
|
-->
|
||||||
<!-- ============================================
|
|
||||||
Dify 机器人配置 - 只在 /home 页面显示
|
|
||||||
============================================ -->
|
|
||||||
<script>
|
<script>
|
||||||
window.difyChatbotConfig = {
|
window.difyChatbotConfig = {
|
||||||
token: 'DwN8qAKtYFQtWskM',
|
token: 'DwN8qAKtYFQtWskM',
|
||||||
@@ -88,44 +85,6 @@
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Dify 机器人显示控制脚本 -->
|
|
||||||
<script>
|
|
||||||
// 控制 Dify 机器人只在 /home 页面显示
|
|
||||||
function controlDifyVisibility() {
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
const difyChatButton = document.getElementById('dify-chatbot-bubble-button');
|
|
||||||
|
|
||||||
if (difyChatButton) {
|
|
||||||
// 只在 /home 页面显示
|
|
||||||
if (currentPath === '/home') {
|
|
||||||
difyChatButton.style.display = 'none';
|
|
||||||
console.log('[Dify] 显示机器人(当前路径: /home)');
|
|
||||||
} else {
|
|
||||||
difyChatButton.style.display = 'none';
|
|
||||||
console.log('[Dify] 隐藏机器人(当前路径:', currentPath, ')');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载完成后执行
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
console.log('[Dify] 初始化显示控制');
|
|
||||||
|
|
||||||
// 初始检查(延迟执行,等待 Dify 按钮渲染)
|
|
||||||
setTimeout(controlDifyVisibility, 500);
|
|
||||||
setTimeout(controlDifyVisibility, 1500);
|
|
||||||
|
|
||||||
// 监听路由变化(React Router 使用 pushState)
|
|
||||||
const observer = setInterval(controlDifyVisibility, 1000);
|
|
||||||
|
|
||||||
// 清理函数(可选)
|
|
||||||
window.addEventListener('beforeunload', function() {
|
|
||||||
clearInterval(observer);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script
|
<script
|
||||||
src="https://app.valuefrontier.cn/embed.min.js"
|
src="https://app.valuefrontier.cn/embed.min.js"
|
||||||
id="DwN8qAKtYFQtWskM"
|
id="DwN8qAKtYFQtWskM"
|
||||||
@@ -207,7 +166,7 @@
|
|||||||
bottom: 80px !important;
|
bottom: 80px !important;
|
||||||
left: 10px !important;
|
left: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dify-chatbot-bubble-button {
|
#dify-chatbot-bubble-button {
|
||||||
width: 56px !important;
|
width: 56px !important;
|
||||||
height: 56px !important;
|
height: 56px !important;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -4,24 +4,8 @@
|
|||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.png",
|
"src": "favicon.png",
|
||||||
"sizes": "32x32",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/png"
|
"type": "image/x-icon"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "badge.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "badge"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "apple-icon.png",
|
|
||||||
"sizes": "32x32",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "./index.html",
|
"start_url": "./index.html",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Please do NOT modify this file.
|
* - Please do NOT modify this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PACKAGE_VERSION = '2.12.0'
|
const PACKAGE_VERSION = '2.11.5'
|
||||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
const activeClientIds = new Set()
|
const activeClientIds = new Set()
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
// public/service-worker.js
|
|
||||||
/**
|
|
||||||
* Service Worker for Browser Notifications
|
|
||||||
* 主要功能:支持浏览器通知的稳定运行
|
|
||||||
*/
|
|
||||||
|
|
||||||
const CACHE_NAME = 'valuefrontier-v1';
|
|
||||||
|
|
||||||
// Service Worker 安装事件
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
console.log('[Service Worker] Installing...');
|
|
||||||
// 跳过等待,立即激活
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Service Worker 激活事件
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
console.log('[Service Worker] Activating...');
|
|
||||||
// 立即接管所有页面
|
|
||||||
event.waitUntil(self.clients.claim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 通知点击事件
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
console.log('[Service Worker] Notification clicked:', event.notification.tag);
|
|
||||||
|
|
||||||
event.notification.close();
|
|
||||||
|
|
||||||
// 获取通知数据中的链接
|
|
||||||
const urlToOpen = event.notification.data?.link;
|
|
||||||
|
|
||||||
if (urlToOpen) {
|
|
||||||
event.waitUntil(
|
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
||||||
.then((windowClients) => {
|
|
||||||
// 查找是否已有打开的窗口
|
|
||||||
for (let client of windowClients) {
|
|
||||||
if (client.url.includes(window.location.origin) && 'focus' in client) {
|
|
||||||
// 聚焦现有窗口并导航到目标页面
|
|
||||||
return client.focus().then(client => {
|
|
||||||
return client.navigate(urlToOpen);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果没有打开的窗口,打开新窗口
|
|
||||||
if (clients.openWindow) {
|
|
||||||
return clients.openWindow(urlToOpen);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 通知关闭事件
|
|
||||||
self.addEventListener('notificationclose', (event) => {
|
|
||||||
console.log('[Service Worker] Notification closed:', event.notification.tag);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch 事件 - 基础的网络优先策略
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
// 对于通知相关的资源,使用网络优先策略
|
|
||||||
event.respondWith(
|
|
||||||
fetch(event.request)
|
|
||||||
.catch(() => {
|
|
||||||
// 网络失败时,尝试从缓存获取
|
|
||||||
return caches.match(event.request);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 推送消息事件(预留,用于未来的 Push API 集成)
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
console.log('[Service Worker] Push message received:', event);
|
|
||||||
|
|
||||||
if (event.data) {
|
|
||||||
const data = event.data.json();
|
|
||||||
const options = {
|
|
||||||
body: data.body || '您有新消息',
|
|
||||||
icon: data.icon || '/favicon.png',
|
|
||||||
badge: '/favicon.png',
|
|
||||||
data: data.data || {},
|
|
||||||
requireInteraction: data.requireInteraction || false,
|
|
||||||
tag: data.tag || `notification_${Date.now()}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(data.title || '价值前沿', options)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[Service Worker] Loaded successfully');
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
228
src/App.js
228
src/App.js
@@ -9,55 +9,199 @@
|
|||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware.
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { Suspense, useEffect } from "react";
|
||||||
import { useDispatch } from 'react-redux';
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
|
||||||
// Routes
|
// Chakra imports
|
||||||
import AppRoutes from './routes';
|
import { Box, useColorMode } from '@chakra-ui/react';
|
||||||
|
|
||||||
// Providers
|
// Core Components
|
||||||
import AppProviders from './providers/AppProviders';
|
import theme from "theme/theme.js";
|
||||||
|
|
||||||
|
// Loading Component
|
||||||
|
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";
|
||||||
|
|
||||||
|
// ⚡ 使用 React.lazy() 实现路由懒加载
|
||||||
|
// 首屏不需要的组件按需加载,大幅减少初始 JS 包大小
|
||||||
|
const Community = React.lazy(() => import("views/Community"));
|
||||||
|
const LimitAnalyse = React.lazy(() => import("views/LimitAnalyse"));
|
||||||
|
const ForecastReport = React.lazy(() => import("views/Company/ForecastReport"));
|
||||||
|
const ConceptCenter = React.lazy(() => import("views/Concept"));
|
||||||
|
const FinancialPanorama = React.lazy(() => import("views/Company/FinancialPanorama"));
|
||||||
|
const CompanyIndex = React.lazy(() => import("views/Company"));
|
||||||
|
const MarketDataView = React.lazy(() => import("views/Company/MarketDataView"));
|
||||||
|
const StockOverview = React.lazy(() => import("views/StockOverview"));
|
||||||
|
const EventDetail = React.lazy(() => import("views/EventDetail"));
|
||||||
|
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||||
|
|
||||||
|
// Contexts
|
||||||
|
import { AuthProvider } from "contexts/AuthContext";
|
||||||
|
import { AuthModalProvider } from "contexts/AuthModalContext";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import GlobalComponents from './components/GlobalComponents';
|
import ProtectedRoute from "components/ProtectedRoute";
|
||||||
|
import ErrorBoundary from "components/ErrorBoundary";
|
||||||
|
import AuthModalManager from "components/Auth/AuthModalManager";
|
||||||
|
import ScrollToTop from "components/ScrollToTop";
|
||||||
|
|
||||||
// Hooks
|
|
||||||
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
|
|
||||||
|
|
||||||
// Redux
|
|
||||||
import { initializePostHog } from './store/slices/posthogSlice';
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { logger } from './utils/logger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AppContent - 应用核心内容
|
|
||||||
* 负责 PostHog 初始化和渲染路由
|
|
||||||
*/
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const dispatch = useDispatch();
|
const { colorMode } = useColorMode();
|
||||||
|
|
||||||
// 🎯 PostHog Redux 初始化
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(initializePostHog());
|
|
||||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return <AppRoutes />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App - 应用根组件
|
|
||||||
* 设置全局错误处理,提供 Provider 和全局组件
|
|
||||||
*/
|
|
||||||
export default function App() {
|
|
||||||
// 全局错误处理
|
|
||||||
useGlobalErrorHandler();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppProviders>
|
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||||
<AppContent />
|
{/* 路由切换时自动滚动到顶部 */}
|
||||||
<GlobalComponents />
|
<ScrollToTop />
|
||||||
</AppProviders>
|
<Routes>
|
||||||
|
{/* 带导航栏的主布局 - 所有需要导航栏的页面都在这里 */}
|
||||||
|
{/* MainLayout 内部有 Suspense,确保导航栏始终可见 */}
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
{/* 首页路由 */}
|
||||||
|
<Route path="home/*" element={<HomeLayout />} />
|
||||||
|
|
||||||
|
{/* Community页面路由 - 需要登录 */}
|
||||||
|
<Route
|
||||||
|
path="community"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Community />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 概念中心路由 - 需要登录 */}
|
||||||
|
<Route
|
||||||
|
path="concepts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ConceptCenter />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="concept"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ConceptCenter />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 股票概览页面路由 - 需要登录 */}
|
||||||
|
<Route
|
||||||
|
path="stocks"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<StockOverview />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="stock-overview"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<StockOverview />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Limit Analyse页面路由 - 需要登录 */}
|
||||||
|
<Route
|
||||||
|
path="limit-analyse"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LimitAnalyse />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 模拟盘交易系统路由 - 需要登录 */}
|
||||||
|
<Route
|
||||||
|
path="trading-simulation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TradingSimulation />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 事件详情独立页面路由 (不经 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/*"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<PageLoader message="加载中..." />}>
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Admin />
|
||||||
|
</ProtectedRoute>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 认证页面路由 - 不使用 MainLayout */}
|
||||||
|
<Route path="auth/*" element={<Auth />} />
|
||||||
|
|
||||||
|
{/* 默认重定向到首页 */}
|
||||||
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||||
|
|
||||||
|
{/* 404 页面 */}
|
||||||
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
// 全局错误处理:捕获未处理的 Promise rejection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUnhandledRejection = (event) => {
|
||||||
|
console.error('未捕获的 Promise rejection:', event.reason);
|
||||||
|
// 阻止默认的错误处理(防止崩溃)
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (event) => {
|
||||||
|
console.error('全局错误:', event.error);
|
||||||
|
// 阻止默认的错误处理(防止崩溃)
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
window.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
window.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraProvider theme={theme}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthModalProvider>
|
||||||
|
<AppContent />
|
||||||
|
<AuthModalManager />
|
||||||
|
</AuthModalProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</ChakraProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user