Compare commits
301 Commits
feature_bu
...
before-rec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be357a1c5 | ||
|
|
9f907b3cba | ||
|
|
bb878c5346 | ||
|
|
1bc3241596 | ||
|
|
cb46971e0e | ||
| 6679d99cf9 | |||
| 2c55a53c3a | |||
| 6ad56b9882 | |||
| b9eddbe752 | |||
|
|
cb9f927e3e | ||
|
|
b9a587bac4 | ||
|
|
86259793cb | ||
| f76bd17160 | |||
| ce0e91a5fb | |||
| f873fdb9a6 | |||
| cc446fc0da | |||
| de30755271 | |||
| a2f33c2a8a | |||
| 761fe5d2f0 | |||
| 3677217fce | |||
| 177c1d6401 | |||
| fb066aa6b8 | |||
| 96bedb8439 | |||
| 83d7c19fed | |||
| e80d2cfcec | |||
| 412f2a3d79 | |||
| 4a0e156bec | |||
| 7743a8a26a | |||
| 72e3e56a63 | |||
| 388e9eb235 | |||
| bd23100192 | |||
|
|
887525197a | ||
|
|
f8bb46ae64 | ||
| 810c878a1e | |||
| 2607028f4f | |||
| ea166d59c4 | |||
|
|
982d8135e7 | ||
|
|
e61090810b | ||
|
|
2d49af3bea | ||
|
|
3a0898634f | ||
|
|
44ecf7e5c7 | ||
|
|
5183473557 | ||
|
|
41f1bbab1b | ||
|
|
f536d68753 | ||
|
|
9475027c0d | ||
|
|
851c148f7d | ||
|
|
ef7f91ba77 | ||
|
|
80084d607b | ||
|
|
dc789f57f7 | ||
|
|
528e61b961 | ||
|
|
e201f35b18 | ||
|
|
13040b5df8 | ||
|
|
9b068fd69f | ||
|
|
2f125a9207 | ||
| b4dcbd1db9 | |||
| c594650aa4 | |||
| 8c372bbc89 | |||
| 4054e2e106 | |||
| 0a149eaa0f | |||
| 3c7b55226c | |||
| 69d05b664e | |||
| ce2226793f | |||
| 07a4cdb357 | |||
| d9a169d2e0 | |||
| 76bf560b36 | |||
| 4a411c6d44 | |||
| dca70074c0 | |||
| 1f1aa896d1 | |||
| 134897c3aa | |||
| 19db421f9f | |||
| 1c290e0da2 | |||
| 15def1c931 | |||
| 7538f2d935 | |||
| 3fa3e52d65 | |||
| 2fb12e0cc7 | |||
| 13f8e2a4f1 | |||
| 7b3907a3bd | |||
| b582de9bc2 | |||
| acb7862789 | |||
| a778f94b68 | |||
| 23a94d5ab2 | |||
| d5250f7d3c | |||
| ae92f333c4 | |||
| 82146f7365 | |||
| 96346977ae | |||
|
|
0f410c55a5 | ||
|
|
a4b8a13e6d | ||
| f578969ee6 | |||
| 4da1d580fc | |||
| af362f3ceb | |||
| e01092365e | |||
| ad7c180e11 | |||
| 2111b1d25b | |||
| ddcbbc9da4 | |||
| 6515a47a42 | |||
| 0bcf6a93f7 | |||
| 5857144180 | |||
| 1ea001fa3d | |||
| 09420963d5 | |||
| d8a1dd7a03 | |||
|
|
098107f38e | ||
|
|
c2b80a727d | ||
|
|
745b9caeee | ||
| b1d042d0e3 | |||
| 04c13f3a6c | |||
| 173ddb985d | |||
| c487c33617 | |||
| 9251531eb7 | |||
| 738cc9cb87 | |||
| 7b9bb153cc | |||
| 33ae9e63a1 | |||
| c4efebdbda | |||
| 602888bbeb | |||
| 6a1e861977 | |||
| 31a3e429d7 | |||
| bbc2493ecd | |||
| eef1dbfe8d | |||
| aaef2272f1 | |||
| 9f2fd60228 | |||
| 2fc0cca482 | |||
| 2668affe88 | |||
| 32b4b772c5 | |||
| 115300a4e3 | |||
|
|
2964b4331a | ||
| cbc231a2b6 | |||
| a158319717 | |||
|
|
f361cb55f4 | ||
|
|
bcd67ed410 | ||
|
|
c391c4c980 | ||
|
|
7b2f5a18bc | ||
|
|
06916cdde5 | ||
|
|
5bb8a17588 | ||
|
|
ad2a374069 | ||
| f28bba6326 | |||
| 69a2c83bd0 | |||
| c5f21a517d | |||
| 6b9be7dad0 | |||
| 3526c8c51c | |||
| 13609163a7 | |||
| e4961a21ee | |||
| 4fcc3e1054 | |||
| b2c116cef4 | |||
| 1ad68bca6c | |||
| 4879121d2b | |||
| cde849b3a4 | |||
| 6c99cb83bf | |||
| 97fd1645d4 | |||
| a66d55237f | |||
| 1f7308a512 | |||
| cab5cc5d7b | |||
| 47e2380bd3 | |||
| 357c03aee2 | |||
| 75e7e7e19c | |||
| f56df0e956 | |||
| 75696b9e52 | |||
| 5e333ad7e7 | |||
| 70376b3544 | |||
| a15830c97e | |||
| a8d38e85d2 | |||
| dce6b5701f | |||
| 0fcb7322ed | |||
| 8e16d3cd3a | |||
| 9b436523ff | |||
| 59a5a03637 | |||
| 70af97e9ad | |||
| ebf7ddda6a | |||
| 68fa1d0717 | |||
| 8fb6992cf6 | |||
| 8f3e2bed70 | |||
| 8a87cd1b74 | |||
| 244968a1cb | |||
| 47be4584f9 | |||
| 42b7d2ee63 | |||
| d8e4c737c5 | |||
| a4b634abff | |||
| 15d521dd59 | |||
| 40b57c1a81 | |||
| 71f3834b79 | |||
| 20c6356842 | |||
| cd926bb42d | |||
| feb08dc746 | |||
| cddf82ce51 | |||
| eceb2e7da0 | |||
| 092c86f3d2 | |||
| 7498e87d31 | |||
| e778742590 | |||
| 990ca3663e | |||
| b9ed0f5449 | |||
| 077f8d9120 | |||
| 97371ae16a | |||
| aa3fe0d806 | |||
| e68acfe7d1 | |||
| c336be5cd7 | |||
| 1a845f54e9 | |||
| 781710ae53 | |||
| b5a0b7094a | |||
| 22bb57b52f | |||
| cd315a718f | |||
| ff2ad14246 | |||
|
|
baf4ca1ed4 | ||
|
|
3cd34d93c8 | ||
|
|
c9084ebb33 | ||
|
|
ed584b72d4 | ||
|
|
2dec587d37 | ||
|
|
7f021dcfa0 | ||
|
|
e34f5593b4 | ||
|
|
5f76530e80 | ||
|
|
d6c7d64e59 | ||
|
|
ceed71eca4 | ||
|
|
9669d5709e | ||
|
|
34bae35858 | ||
|
|
bc50d9fe3e | ||
|
|
39978c57d5 | ||
| b197d62c31 | |||
|
|
834067f679 | ||
| 564caa08c2 | |||
| 0aa050b95f | |||
| e22e8339a6 | |||
|
|
e8b3d13c0a | ||
| 8c787a8915 | |||
|
|
796c623197 | ||
| 690754e416 | |||
| 12d104cc22 | |||
|
|
a1c1a36f6a | ||
| 2b30d10451 | |||
| 8dfd344806 | |||
| 7c8310eeb6 | |||
| 30108b297c | |||
| 161bcec55e | |||
| 34f2d7dabd | |||
|
|
3e4b47dbfe | ||
|
|
e2861b994b | ||
| 6b9291a4f9 | |||
| 0818eeedf1 | |||
| 2a8d7438c8 | |||
| fdd58634e6 | |||
|
|
53fbda44e6 | ||
|
|
540b938525 | ||
|
|
8fe11efcd7 | ||
|
|
e753437b86 | ||
|
|
a6f69418f6 | ||
|
|
dfdd2f4134 | ||
|
|
4c79871ab4 | ||
| f8eb268341 | |||
| 665f5e8416 | |||
| be2da54d82 | |||
| 8bf4a0b6c6 | |||
| 412b2c03ed | |||
| 899500007d | |||
| d3879b3840 | |||
| 80fe74c041 | |||
| 78f7dca1f6 | |||
| 03aee75235 | |||
| 8eff6b1a95 | |||
| 80676dd622 | |||
| 082e644534 | |||
| b0b227a5ef | |||
| 691c4f6eb1 | |||
| d5a55c4e02 | |||
| 27cdf0aecd | |||
| 4a1157c0b6 | |||
| f515dc94f4 | |||
| 683e261756 | |||
| 8bdfd0389c | |||
| eae495ac34 | |||
| 958cedefb8 | |||
|
|
1fc9f4790f | ||
| b48ff99658 | |||
| ae558996b6 | |||
| 71742c0116 | |||
| 2ead50c37c | |||
| 9e8519bb94 | |||
| a4d16e7686 | |||
|
|
3eb31c99dc | ||
|
|
5f6b4b083b | ||
|
|
905023c056 | ||
|
|
25cc28e03b | ||
|
|
5f9901a098 | ||
|
|
28643d7c4a | ||
|
|
bb28e141e6 | ||
|
|
8fa273c8d4 | ||
|
|
17c04211bb | ||
|
|
c9419d3c14 | ||
|
|
dfc13c5737 | ||
|
|
de8d0ef1c3 | ||
|
|
65c16d65ac | ||
|
|
13a291b979 | ||
|
|
4d6da77aeb | ||
|
|
fc1f667700 | ||
|
|
46639030bb | ||
|
|
f747a0bdb2 | ||
|
|
9b55610167 | ||
|
|
a93fcfa9b9 | ||
|
|
8914a46c40 | ||
|
|
678eb6838e | ||
|
|
c06d3a88ae | ||
|
|
307c308739 | ||
|
|
cbb6517bb1 | ||
|
|
f33489f5d7 | ||
|
|
9ff77b570d | ||
|
|
de37546ddb |
@@ -19,9 +19,7 @@ REACT_APP_ENABLE_MOCK=false
|
||||
# 开发环境标识
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# PostHog 配置(开发环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
# 性能监控配置
|
||||
REACT_APP_ENABLE_PERFORMANCE_MONITOR=true
|
||||
REACT_APP_ENABLE_PERFORMANCE_PANEL=true
|
||||
REACT_APP_REPORT_TO_POSTHOG=false
|
||||
|
||||
15
.env.mock
15
.env.mock
@@ -29,20 +29,13 @@ NODE_OPTIONS=--max_old_space_size=4096
|
||||
# MSW 会在浏览器层拦截这些请求,不需要真实的后端地址
|
||||
REACT_APP_API_URL=
|
||||
|
||||
# Socket.IO 连接地址(Mock 模式下连接生产环境)
|
||||
# 注意:WebSocket 不被 MSW 拦截,可以独立配置
|
||||
REACT_APP_SOCKET_URL=https://valuefrontier.cn
|
||||
|
||||
# 启用 Mock 数据(核心配置)
|
||||
# 此配置会触发 src/index.js 中的 MSW 初始化
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
|
||||
# Mock 环境标识
|
||||
REACT_APP_ENV=mock
|
||||
|
||||
# PostHog 配置(Mock 环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# PostHog Debug 模式(Mock 环境永久启用)
|
||||
# 在浏览器 Console 中打印详细的事件追踪日志
|
||||
REACT_APP_POSTHOG_DEBUG=true
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
# ========================================
|
||||
# 生产环境配置
|
||||
# ========================================
|
||||
# 使用方式: npm run build
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. 此文件专门用于生产环境构建
|
||||
# 2. 构建时会将环境变量嵌入到打包文件中
|
||||
# 3. 确保 PostHog 等服务使用正确的生产配置
|
||||
# ========================================
|
||||
|
||||
# 环境标识
|
||||
REACT_APP_ENV=production
|
||||
@@ -17,13 +10,8 @@ NODE_ENV=production
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 🔧 调试模式(生产环境临时调试用)
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
|
||||
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
|
||||
# 使用方法:
|
||||
# 1. 设置为 true 并重新构建
|
||||
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
|
||||
# 3. 调试完成后设置为 false 并重新构建
|
||||
REACT_APP_ENABLE_DEBUG=true
|
||||
# 开启后会在全局暴露 window.__DEBUG__
|
||||
REACT_APP_ENABLE_DEBUG=false
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
@@ -50,19 +38,10 @@ IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
# Node.js 内存限制(适用于大型项目)
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
# ========================================
|
||||
# Bytedesk 客服系统配置
|
||||
# ========================================
|
||||
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
|
||||
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
|
||||
# Nginx 配置:location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
|
||||
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
|
||||
|
||||
# 组织 UUID(从管理后台 -> 设置 -> 组织信息 -> 组织UUID)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组 UUID(从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
|
||||
# 客服类型(2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
# 性能监控配置(生产环境)
|
||||
# 启用性能监控
|
||||
REACT_APP_ENABLE_PERFORMANCE_MONITOR=true
|
||||
# 禁用性能面板(仅开发环境)
|
||||
REACT_APP_ENABLE_PERFORMANCE_PANEL=false
|
||||
# 启用 PostHog 性能数据上报
|
||||
REACT_APP_REPORT_TO_POSTHOG=true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,6 +48,8 @@ Thumbs.db
|
||||
*.md
|
||||
!README.md
|
||||
!CLAUDE.md
|
||||
!docs/**/*.md
|
||||
|
||||
# 忽略 docs 目录(开发文档不提交到 Git)
|
||||
docs/
|
||||
|
||||
src/assets/img/original-backup/
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
**前端**
|
||||
- **核心框架**: React 18.3.1
|
||||
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
|
||||
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **UI 组件库**:
|
||||
- Chakra UI 2.10.9(主要,全局使用)
|
||||
- Ant Design 5.27.4(表格/表单)
|
||||
- **HeroUI 3.0.0-beta**(AgentChat 专用,2025-11-22 升级)
|
||||
- **状态管理**: Redux Toolkit 2.9.2
|
||||
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
|
||||
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化
|
||||
@@ -59,6 +62,8 @@
|
||||
- **虚拟化**: @tanstack/react-virtual 3.13.12(性能优化)
|
||||
- **其他**: Draft.js(富文本编辑)、React Markdown、React Quill
|
||||
|
||||
**注意**: HeroUI v3 文档参考 https://v3.heroui.com/llms.txt,详细升级说明见 [HEROUI_V3_UPGRADE_GUIDE.md](./HEROUI_V3_UPGRADE_GUIDE.md)
|
||||
|
||||
**后端**
|
||||
- Flask + SQLAlchemy ORM
|
||||
- ClickHouse(分析型数据库)+ MySQL/PostgreSQL(事务型数据库)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!--
|
||||
IMPORTANT: Please use the following link to create a new issue:
|
||||
|
||||
https://www.creative-tim.com/new-issue/argon-dashboard-chakra-pro
|
||||
|
||||
**If your issue was not created using the app above, it will be closed immediately.**
|
||||
-->
|
||||
|
||||
<!--
|
||||
Love Creative Tim? Do you need Angular, React, Vuejs or HTML? You can visit:
|
||||
👉 https://www.creative-tim.com/bundles
|
||||
👉 https://www.creative-tim.com
|
||||
-->
|
||||
198
README.md
198
README.md
@@ -1,198 +0,0 @@
|
||||
# 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): 使用组件化架构替换内联渲染函数`
|
||||
|
||||
---
|
||||
BIN
__pycache__/app.cpython-310.pyc
Normal file
BIN
__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
BIN
__pycache__/app_vx.cpython-310.pyc
Normal file
BIN
__pycache__/app_vx.cpython-310.pyc
Normal file
Binary file not shown.
367
app_vx.py
367
app_vx.py
@@ -2463,16 +2463,16 @@ def api_get_events():
|
||||
order_func = desc if order.lower() == 'desc' else asc
|
||||
|
||||
if sort_by == 'hot':
|
||||
query = query.order_by(order_func(Event.hot_score))
|
||||
query = query.order_by(order_func(Event.hot_score), desc(Event.created_at))
|
||||
elif sort_by == 'new':
|
||||
query = query.order_by(order_func(Event.created_at))
|
||||
elif sort_by == 'returns':
|
||||
if return_type == 'avg':
|
||||
query = query.order_by(order_func(Event.related_avg_chg))
|
||||
query = query.order_by(order_func(Event.related_avg_chg), desc(Event.created_at))
|
||||
elif return_type == 'max':
|
||||
query = query.order_by(order_func(Event.related_max_chg))
|
||||
query = query.order_by(order_func(Event.related_max_chg), desc(Event.created_at))
|
||||
elif return_type == 'week':
|
||||
query = query.order_by(order_func(Event.related_week_chg))
|
||||
query = query.order_by(order_func(Event.related_week_chg), desc(Event.created_at))
|
||||
elif sort_by == 'importance':
|
||||
importance_order = case(
|
||||
(Event.importance == 'S', 1),
|
||||
@@ -2482,287 +2482,29 @@ def api_get_events():
|
||||
else_=5
|
||||
)
|
||||
if order.lower() == 'desc':
|
||||
query = query.order_by(importance_order)
|
||||
query = query.order_by(importance_order, desc(Event.created_at))
|
||||
else:
|
||||
query = query.order_by(desc(importance_order))
|
||||
query = query.order_by(desc(importance_order), desc(Event.created_at))
|
||||
elif sort_by == 'view_count':
|
||||
query = query.order_by(order_func(Event.view_count))
|
||||
query = query.order_by(order_func(Event.view_count), desc(Event.created_at))
|
||||
elif sort_by == 'follow' and hasattr(request, 'user') and request.user.is_authenticated:
|
||||
# 关注的事件排序
|
||||
query = query.join(EventFollow).filter(
|
||||
EventFollow.user_id == request.user.id
|
||||
).order_by(order_func(Event.created_at))
|
||||
else:
|
||||
# 兜底排序:始终按时间倒序
|
||||
query = query.order_by(desc(Event.created_at))
|
||||
|
||||
# ==================== 分页查询 ====================
|
||||
|
||||
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
|
||||
# ==================== 批量获取股票行情数据(优化版) ====================
|
||||
|
||||
# 1. 收集当前页所有事件的ID
|
||||
event_ids = [event.id for event in paginated.items]
|
||||
|
||||
# 2. 获取所有相关股票
|
||||
all_related_stocks = {}
|
||||
if event_ids:
|
||||
related_stocks = RelatedStock.query.filter(
|
||||
RelatedStock.event_id.in_(event_ids)
|
||||
).all()
|
||||
|
||||
# 按事件ID分组
|
||||
for stock in related_stocks:
|
||||
if stock.event_id not in all_related_stocks:
|
||||
all_related_stocks[stock.event_id] = []
|
||||
all_related_stocks[stock.event_id].append(stock)
|
||||
|
||||
# 3. 收集所有股票代码
|
||||
all_stock_codes = []
|
||||
stock_code_mapping = {} # 清理后的代码 -> 原始代码的映射
|
||||
|
||||
for stocks in all_related_stocks.values():
|
||||
for stock in stocks:
|
||||
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
|
||||
all_stock_codes.append(clean_code)
|
||||
stock_code_mapping[clean_code] = stock.stock_code
|
||||
|
||||
# 去重
|
||||
all_stock_codes = list(set(all_stock_codes))
|
||||
|
||||
# 4. 批量查询最近7个交易日的数据(用于计算日涨跌和周涨跌)
|
||||
stock_price_data = {}
|
||||
|
||||
if all_stock_codes:
|
||||
# 构建SQL查询 - 获取最近7个交易日的数据
|
||||
codes_str = "'" + "', '".join(all_stock_codes) + "'"
|
||||
|
||||
# 获取最近7个交易日的数据
|
||||
recent_trades_sql = f"""
|
||||
SELECT
|
||||
SECCODE,
|
||||
SECNAME,
|
||||
F007N as close_price,
|
||||
F010N as daily_change,
|
||||
TRADEDATE,
|
||||
ROW_NUMBER() OVER (PARTITION BY SECCODE ORDER BY TRADEDATE DESC) as rn
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({codes_str})
|
||||
AND F007N IS NOT NULL
|
||||
AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 10 DAY)
|
||||
ORDER BY SECCODE, TRADEDATE DESC
|
||||
"""
|
||||
|
||||
result = db.session.execute(text(recent_trades_sql))
|
||||
|
||||
# 整理数据
|
||||
for row in result.fetchall():
|
||||
sec_code = row[0]
|
||||
if sec_code not in stock_price_data:
|
||||
stock_price_data[sec_code] = {
|
||||
'stock_name': row[1],
|
||||
'prices': []
|
||||
}
|
||||
|
||||
stock_price_data[sec_code]['prices'].append({
|
||||
'close_price': float(row[2]) if row[2] else 0,
|
||||
'daily_change': float(row[3]) if row[3] else 0,
|
||||
'trade_date': row[4],
|
||||
'rank': row[5]
|
||||
})
|
||||
|
||||
# 5. 计算日涨跌和周涨跌
|
||||
stock_changes = {}
|
||||
|
||||
for sec_code, data in stock_price_data.items():
|
||||
prices = data['prices']
|
||||
|
||||
# 最新日涨跌(第1条记录)
|
||||
daily_change = 0
|
||||
if prices and prices[0]['rank'] == 1:
|
||||
daily_change = prices[0]['daily_change']
|
||||
|
||||
# 计算周涨跌(最新价 vs 5个交易日前的价格)
|
||||
week_change = 0
|
||||
if len(prices) >= 2:
|
||||
latest_price = prices[0]['close_price']
|
||||
# 找到第5个交易日的数据(如果有)
|
||||
week_ago_price = None
|
||||
for price_data in prices:
|
||||
if price_data['rank'] >= 5:
|
||||
week_ago_price = price_data['close_price']
|
||||
break
|
||||
|
||||
# 如果没有第5天的数据,使用最早的数据
|
||||
if week_ago_price is None and len(prices) > 1:
|
||||
week_ago_price = prices[-1]['close_price']
|
||||
|
||||
if week_ago_price and week_ago_price > 0:
|
||||
week_change = (latest_price - week_ago_price) / week_ago_price * 100
|
||||
|
||||
stock_changes[sec_code] = {
|
||||
'stock_name': data['stock_name'],
|
||||
'daily_change': daily_change,
|
||||
'week_change': week_change
|
||||
}
|
||||
|
||||
# ==================== 获取整体统计信息(应用所有筛选条件) ====================
|
||||
|
||||
overall_distribution = {
|
||||
'limit_down': 0,
|
||||
'down_over_5': 0,
|
||||
'down_5_to_1': 0,
|
||||
'down_within_1': 0,
|
||||
'flat': 0,
|
||||
'up_within_1': 0,
|
||||
'up_1_to_5': 0,
|
||||
'up_over_5': 0,
|
||||
'limit_up': 0
|
||||
}
|
||||
|
||||
# 使用当前筛选条件的query,但不应用分页限制,获取所有符合条件的事件
|
||||
# 这样统计数据会跟随用户的筛选条件变化
|
||||
all_filtered_events = query.limit(1000).all() # 限制最多1000个事件,避免查询过慢
|
||||
week_event_ids = [e.id for e in all_filtered_events]
|
||||
|
||||
if week_event_ids:
|
||||
# 获取这些事件的所有关联股票
|
||||
week_related_stocks = RelatedStock.query.filter(
|
||||
RelatedStock.event_id.in_(week_event_ids)
|
||||
).all()
|
||||
|
||||
# 按事件ID分组
|
||||
week_stocks_by_event = {}
|
||||
for stock in week_related_stocks:
|
||||
if stock.event_id not in week_stocks_by_event:
|
||||
week_stocks_by_event[stock.event_id] = []
|
||||
week_stocks_by_event[stock.event_id].append(stock)
|
||||
|
||||
# 收集所有股票代码(用于批量查询行情)
|
||||
week_stock_codes = []
|
||||
week_code_mapping = {}
|
||||
for stocks in week_stocks_by_event.values():
|
||||
for stock in stocks:
|
||||
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
|
||||
week_stock_codes.append(clean_code)
|
||||
week_code_mapping[clean_code] = stock.stock_code
|
||||
|
||||
week_stock_codes = list(set(week_stock_codes))
|
||||
|
||||
# 批量查询这些股票的最新行情数据
|
||||
week_stock_changes = {}
|
||||
if week_stock_codes:
|
||||
codes_str = "'" + "', '".join(week_stock_codes) + "'"
|
||||
recent_trades_sql = f"""
|
||||
SELECT
|
||||
SECCODE,
|
||||
SECNAME,
|
||||
F010N as daily_change,
|
||||
ROW_NUMBER() OVER (PARTITION BY SECCODE ORDER BY TRADEDATE DESC) as rn
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({codes_str})
|
||||
AND F010N IS NOT NULL
|
||||
AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 3 DAY)
|
||||
ORDER BY SECCODE, TRADEDATE DESC
|
||||
"""
|
||||
|
||||
result = db.session.execute(text(recent_trades_sql))
|
||||
|
||||
for row in result.fetchall():
|
||||
sec_code = row[0]
|
||||
if row[3] == 1: # 只取最新的数据(rn=1)
|
||||
week_stock_changes[sec_code] = {
|
||||
'stock_name': row[1],
|
||||
'daily_change': float(row[2]) if row[2] else 0
|
||||
}
|
||||
|
||||
# 按事件统计平均涨跌幅分布
|
||||
event_avg_changes = {}
|
||||
|
||||
for event in all_filtered_events:
|
||||
event_stocks = week_stocks_by_event.get(event.id, [])
|
||||
if not event_stocks:
|
||||
continue
|
||||
|
||||
total_change = 0
|
||||
valid_count = 0
|
||||
|
||||
for stock in event_stocks:
|
||||
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
|
||||
if clean_code in week_stock_changes:
|
||||
daily_change = week_stock_changes[clean_code]['daily_change']
|
||||
total_change += daily_change
|
||||
valid_count += 1
|
||||
|
||||
if valid_count > 0:
|
||||
avg_change = total_change / valid_count
|
||||
event_avg_changes[event.id] = avg_change
|
||||
|
||||
# 统计事件平均涨跌幅的分布
|
||||
for event_id, avg_change in event_avg_changes.items():
|
||||
# 对于事件平均涨幅,不使用涨跌停分类,使用通用分类
|
||||
if avg_change <= -10:
|
||||
overall_distribution['limit_down'] += 1
|
||||
elif avg_change >= 10:
|
||||
overall_distribution['limit_up'] += 1
|
||||
elif avg_change > 5:
|
||||
overall_distribution['up_over_5'] += 1
|
||||
elif avg_change > 1:
|
||||
overall_distribution['up_1_to_5'] += 1
|
||||
elif avg_change > 0.1:
|
||||
overall_distribution['up_within_1'] += 1
|
||||
elif avg_change >= -0.1:
|
||||
overall_distribution['flat'] += 1
|
||||
elif avg_change > -1:
|
||||
overall_distribution['down_within_1'] += 1
|
||||
elif avg_change > -5:
|
||||
overall_distribution['down_5_to_1'] += 1
|
||||
else:
|
||||
overall_distribution['down_over_5'] += 1
|
||||
|
||||
# ==================== 构建响应数据 ====================
|
||||
|
||||
events_data = []
|
||||
for event in paginated.items:
|
||||
event_stocks = all_related_stocks.get(event.id, [])
|
||||
stocks_data = []
|
||||
|
||||
total_daily_change = 0
|
||||
max_daily_change = -999
|
||||
total_week_change = 0
|
||||
max_week_change = -999
|
||||
valid_stocks_count = 0
|
||||
|
||||
# 处理每个股票的数据
|
||||
for stock in event_stocks:
|
||||
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
|
||||
stock_info = stock_changes.get(clean_code, {})
|
||||
|
||||
daily_change = stock_info.get('daily_change', 0)
|
||||
week_change = stock_info.get('week_change', 0)
|
||||
|
||||
if stock_info:
|
||||
total_daily_change += daily_change
|
||||
max_daily_change = max(max_daily_change, daily_change)
|
||||
total_week_change += week_change
|
||||
max_week_change = max(max_week_change, week_change)
|
||||
valid_stocks_count += 1
|
||||
|
||||
stocks_data.append({
|
||||
"stock_code": stock.stock_code,
|
||||
"stock_name": stock.stock_name,
|
||||
"sector": stock.sector,
|
||||
"week_change": round(week_change, 2),
|
||||
"daily_change": round(daily_change, 2)
|
||||
})
|
||||
|
||||
avg_daily_change = total_daily_change / valid_stocks_count if valid_stocks_count > 0 else 0
|
||||
avg_week_change = total_week_change / valid_stocks_count if valid_stocks_count > 0 else 0
|
||||
|
||||
if max_daily_change == -999:
|
||||
max_daily_change = 0
|
||||
if max_week_change == -999:
|
||||
max_week_change = 0
|
||||
|
||||
# 构建事件数据
|
||||
# 构建事件数据(保持原有结构,个股信息和统计置空)
|
||||
event_dict = {
|
||||
'id': event.id,
|
||||
'title': event.title,
|
||||
@@ -2774,20 +2516,21 @@ def api_get_events():
|
||||
'updated_at': event.updated_at.isoformat() if event.updated_at else None,
|
||||
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||
'related_stocks': stocks_data,
|
||||
# 个股信息(置空)
|
||||
'related_stocks': [],
|
||||
# 股票统计(置空或使用数据库字段)
|
||||
'stocks_stats': {
|
||||
'stocks_count': len(event_stocks),
|
||||
'valid_stocks_count': valid_stocks_count,
|
||||
# 周涨跌统计
|
||||
'avg_week_change': round(avg_week_change, 2),
|
||||
'max_week_change': round(max_week_change, 2),
|
||||
# 日涨跌统计
|
||||
'avg_daily_change': round(avg_daily_change, 2),
|
||||
'max_daily_change': round(max_daily_change, 2)
|
||||
'stocks_count': 10,
|
||||
'valid_stocks_count': 0,
|
||||
# 使用数据库字段的涨跌幅
|
||||
'avg_week_change': round(event.related_week_chg, 2) if event.related_week_chg else 0,
|
||||
'max_week_change': round(event.related_max_chg, 2) if event.related_max_chg else 0,
|
||||
'avg_daily_change': round(event.related_avg_chg, 2) if event.related_avg_chg else 0,
|
||||
'max_daily_change': round(event.related_max_chg, 2) if event.related_max_chg else 0
|
||||
}
|
||||
}
|
||||
|
||||
# 统计信息
|
||||
# 统计信息(可选)
|
||||
if include_stats:
|
||||
event_dict.update({
|
||||
'hot_score': event.hot_score,
|
||||
@@ -2801,7 +2544,7 @@ def api_get_events():
|
||||
'trending_score': event.trending_score,
|
||||
})
|
||||
|
||||
# 创建者信息
|
||||
# 创建者信息(可选)
|
||||
if include_creator:
|
||||
event_dict['creator'] = {
|
||||
'id': event.creator.id if event.creator else None,
|
||||
@@ -2815,24 +2558,18 @@ def api_get_events():
|
||||
event_dict['keywords'] = event.keywords if isinstance(event.keywords, list) else []
|
||||
event_dict['related_industries'] = event.related_industries
|
||||
|
||||
# 包含统计信息
|
||||
# 包含统计信息(可选,置空)
|
||||
if include_stats:
|
||||
event_dict['stats'] = {
|
||||
'related_stocks_count': len(event_stocks),
|
||||
'historical_events_count': 0, # 需要额外查询
|
||||
'related_data_count': 0, # 需要额外查询
|
||||
'related_concepts_count': 0 # 需要额外查询
|
||||
'related_stocks_count': 10,
|
||||
'historical_events_count': 0,
|
||||
'related_data_count': 0,
|
||||
'related_concepts_count': 0
|
||||
}
|
||||
|
||||
# 包含关联数据
|
||||
# 包含关联数据(可选,已置空)
|
||||
if include_related_data:
|
||||
event_dict['related_stocks'] = [{
|
||||
'id': stock.id,
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'correlation': float(stock.correlation) if stock.correlation else 0
|
||||
} for stock in event_stocks[:5]] # 限制返回5个
|
||||
event_dict['related_stocks'] = []
|
||||
|
||||
events_data.append(event_dict)
|
||||
|
||||
@@ -2860,7 +2597,7 @@ def api_get_events():
|
||||
applied_filters['search_query'] = search_query
|
||||
applied_filters['search_type'] = search_type
|
||||
|
||||
# ==================== 返回结果(保持完全兼容) ====================
|
||||
# ==================== 返回结果(保持完全兼容,统计数据置空) ====================
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -2885,12 +2622,30 @@ def api_get_events():
|
||||
'order': order
|
||||
}
|
||||
},
|
||||
# 整体股票涨跌幅分布统计
|
||||
# 整体股票涨跌幅分布统计(置空)
|
||||
'overall_stats': {
|
||||
'total_stocks': len(all_stocks_for_stats) if 'all_stocks_for_stats' in locals() else 0,
|
||||
'change_distribution': overall_distribution,
|
||||
'total_stocks': 0,
|
||||
'change_distribution': {
|
||||
'limit_down': 0,
|
||||
'down_over_5': 0,
|
||||
'down_5_to_1': 0,
|
||||
'down_within_1': 0,
|
||||
'flat': 0,
|
||||
'up_within_1': 0,
|
||||
'up_1_to_5': 0,
|
||||
'up_over_5': 0,
|
||||
'limit_up': 0
|
||||
},
|
||||
'change_distribution_percentages': {
|
||||
k: v for k, v in overall_distribution.items()
|
||||
'limit_down': 0,
|
||||
'down_over_5': 0,
|
||||
'down_5_to_1': 0,
|
||||
'down_within_1': 0,
|
||||
'flat': 0,
|
||||
'up_within_1': 0,
|
||||
'up_1_to_5': 0,
|
||||
'up_over_5': 0,
|
||||
'limit_up': 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2964,13 +2719,12 @@ def get_calendar_event_counts():
|
||||
|
||||
# 修改查询以仅统计type为event的事件数量
|
||||
query = """
|
||||
SELECT DATE (calendar_time) as date, COUNT (*) as count
|
||||
FROM future_events
|
||||
WHERE calendar_time BETWEEN :start_date \
|
||||
AND :end_date
|
||||
AND type = 'event'
|
||||
GROUP BY DATE (calendar_time) \
|
||||
"""
|
||||
SELECT DATE(calendar_time) as date, COUNT(*) as count
|
||||
FROM future_events
|
||||
WHERE calendar_time BETWEEN :start_date AND :end_date
|
||||
AND type = 'event'
|
||||
GROUP BY DATE(calendar_time)
|
||||
"""
|
||||
|
||||
result = db.session.execute(text(query), {
|
||||
'start_date': start_date,
|
||||
@@ -2987,7 +2741,8 @@ def get_calendar_event_counts():
|
||||
return jsonify(events)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
app.logger.error(f"获取日历事件统计出错: {str(e)}", exc_info=True)
|
||||
return jsonify({'error': str(e), 'error_type': type(e).__name__}), 500
|
||||
|
||||
|
||||
def get_full_avatar_url(avatar_url):
|
||||
|
||||
6352
app_vx_copy1.py
Normal file
6352
app_vx_copy1.py
Normal file
File diff suppressed because it is too large
Load Diff
1028
category_tree_openapi.json
Normal file
1028
category_tree_openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,7 @@ class SearchRequest(BaseModel):
|
||||
semantic_weight: Optional[float] = Field(None, ge=0.0, le=1.0, description="语义搜索权重(0-1),None表示自动计算")
|
||||
filter_stocks: Optional[List[str]] = Field(None, description="过滤特定股票代码或名称")
|
||||
trade_date: Optional[date] = Field(None, description="交易日期,格式:YYYY-MM-DD,默认返回最新日期数据")
|
||||
sort_by: str = Field("change_pct", description="排序方式: change_pct, _score, stock_count, concept_name")
|
||||
sort_by: str = Field("change_pct", description="排序方式: change_pct, _score, stock_count, concept_name, added_date")
|
||||
use_knn: bool = Field(True, description="是否使用KNN搜索优化语义搜索")
|
||||
|
||||
|
||||
@@ -548,12 +548,12 @@ async def search_concepts(request: SearchRequest):
|
||||
# 已经在generate_embedding中记录了详细日志,这里只调整语义权重
|
||||
semantic_weight = 0
|
||||
|
||||
# 【关键修改】:如果按涨跌幅排序,需要获取更多结果
|
||||
# 【关键修改】:如果按涨跌幅或添加日期排序,需要获取更多结果
|
||||
effective_search_size = request.search_size
|
||||
if request.sort_by == "change_pct":
|
||||
# 按涨跌幅排序时,获取更多结果以确保排序准确性
|
||||
if request.sort_by in ["change_pct", "added_date"]:
|
||||
# 按涨跌幅或添加日期排序时,获取更多结果以确保排序准确性
|
||||
effective_search_size = min(1000, request.search_size * 10) # 最多获取1000个
|
||||
logger.info(f"Using expanded search size {effective_search_size} for change_pct sorting")
|
||||
logger.info(f"Using expanded search size {effective_search_size} for {request.sort_by} sorting")
|
||||
|
||||
# 构建查询体
|
||||
search_body = {}
|
||||
@@ -721,6 +721,14 @@ async def search_concepts(request: SearchRequest):
|
||||
all_results.sort(key=lambda x: x.stock_count, reverse=True)
|
||||
elif request.sort_by == "concept_name":
|
||||
all_results.sort(key=lambda x: x.concept)
|
||||
elif request.sort_by == "added_date":
|
||||
# 按添加日期排序(降序 - 最新的在前)
|
||||
all_results.sort(
|
||||
key=lambda x: (
|
||||
x.happened_times[0] if x.happened_times and len(x.happened_times) > 0 else '1900-01-01'
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
# _score排序已经由ES处理
|
||||
|
||||
# 计算分页
|
||||
|
||||
@@ -39,6 +39,13 @@ module.exports = {
|
||||
priority: 30,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// TradingView Lightweight Charts 单独分离(避免被压缩破坏)
|
||||
lightweightCharts: {
|
||||
test: /[\\/]node_modules[\\/]lightweight-charts[\\/]/,
|
||||
name: 'lightweight-charts',
|
||||
priority: 26,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// 大型图表库分离(echarts, d3, apexcharts 等)
|
||||
charts: {
|
||||
test: /[\\/]node_modules[\\/](echarts|echarts-for-react|apexcharts|react-apexcharts|recharts|d3|d3-.*)[\\/]/,
|
||||
@@ -69,7 +76,7 @@ module.exports = {
|
||||
},
|
||||
// 日期/日历库
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar)[\\/]/,
|
||||
name: 'calendar-lib',
|
||||
priority: 18,
|
||||
reuseExistingChunk: true,
|
||||
@@ -96,8 +103,43 @@ module.exports = {
|
||||
moduleIds: 'deterministic',
|
||||
// 最小化配置
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
...webpackConfig.optimization.minimizer,
|
||||
],
|
||||
};
|
||||
|
||||
// 配置 Terser 插件,保留 lightweight-charts 的方法名
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map(plugin => {
|
||||
if (plugin.constructor.name === 'TerserPlugin') {
|
||||
const originalOptions = plugin.options || {};
|
||||
const originalTerserOptions = originalOptions.terserOptions || {};
|
||||
const originalMangle = originalTerserOptions.mangle || {};
|
||||
|
||||
// 只保留 TerserPlugin 有效的配置项
|
||||
const validOptions = {
|
||||
test: originalOptions.test,
|
||||
include: originalOptions.include,
|
||||
exclude: originalOptions.exclude,
|
||||
extractComments: originalOptions.extractComments,
|
||||
parallel: originalOptions.parallel,
|
||||
minify: originalOptions.minify,
|
||||
terserOptions: {
|
||||
...originalTerserOptions,
|
||||
keep_classnames: /^(IChartApi|ISeriesApi|Re)$/, // 保留 lightweight-charts 的类名
|
||||
keep_fnames: /^(createChart|addLineSeries|addSeries)$/, // 保留关键方法名
|
||||
mangle: {
|
||||
...originalMangle,
|
||||
reserved: ['createChart', 'addLineSeries', 'addSeries', 'IChartApi', 'ISeriesApi'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new TerserPlugin(validOptions);
|
||||
}
|
||||
return plugin;
|
||||
});
|
||||
|
||||
// 生产环境禁用 source map 以加快构建(可节省 40-60% 时间)
|
||||
webpackConfig.devtool = false;
|
||||
} else {
|
||||
@@ -161,13 +203,8 @@ module.exports = {
|
||||
);
|
||||
}
|
||||
|
||||
// 忽略 moment 的语言包(如果项目使用了 moment)
|
||||
webpackConfig.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^\.\/locale$/,
|
||||
contextRegExp: /moment$/,
|
||||
})
|
||||
);
|
||||
// Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
|
||||
// 如果需要优化,可以只导入需要的语言包
|
||||
|
||||
// ============== Loader 优化 ==============
|
||||
const babelLoaderRule = webpackConfig.module.rules.find(
|
||||
@@ -247,9 +284,19 @@ module.exports = {
|
||||
},
|
||||
|
||||
// 代理配置:将 /api 请求代理到后端服务器
|
||||
// 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求
|
||||
...(isMockMode() ? {} : {
|
||||
proxy: {
|
||||
// 注意:Mock 模式下禁用 /api 和 /concept-api,让 MSW 拦截请求
|
||||
// 但 /bytedesk 始终启用(客服系统不走 Mock)
|
||||
proxy: {
|
||||
'/bytedesk': {
|
||||
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
||||
changeOrigin: true,
|
||||
secure: false, // 开发环境禁用 HTTPS 严格验证
|
||||
logLevel: 'debug',
|
||||
ws: true, // 支持 WebSocket
|
||||
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
||||
},
|
||||
// Mock 模式下禁用其他代理
|
||||
...(isMockMode() ? {} : {
|
||||
'/api': {
|
||||
target: 'http://49.232.185.254:5001',
|
||||
changeOrigin: true,
|
||||
@@ -263,15 +310,7 @@ module.exports = {
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/concept-api': '' },
|
||||
},
|
||||
'/bytedesk': {
|
||||
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
|
||||
changeOrigin: true,
|
||||
secure: false, // 开发环境禁用 HTTPS 严格验证
|
||||
logLevel: 'debug',
|
||||
ws: true, // 支持 WebSocket
|
||||
// 不使用 pathRewrite,保留 /bytedesk 前缀,让生产 Nginx 处理
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
918
docs/BYTEDESK_INTEGRATION_GUIDE.md
Normal file
918
docs/BYTEDESK_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# Bytedesk客服系统 - 前端工程师集成手册
|
||||
|
||||
**版本**: v1.0
|
||||
**最后更新**: 2025-01-07
|
||||
**适用项目**: vf_react
|
||||
**后端服务器**: http://43.143.189.195
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [1. 集成概述](#1-集成概述)
|
||||
- [2. 快速开始(5分钟集成)](#2-快速开始5分钟集成)
|
||||
- [3. 详细集成步骤](#3-详细集成步骤)
|
||||
- [4. 配置说明](#4-配置说明)
|
||||
- [5. 高级功能](#5-高级功能)
|
||||
- [6. 样式定制](#6-样式定制)
|
||||
- [7. 故障排查](#7-故障排查)
|
||||
- [8. 常见问题FAQ](#8-常见问题faq)
|
||||
- [9. 性能优化](#9-性能优化)
|
||||
- [10. 安全注意事项](#10-安全注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 1. 集成概述
|
||||
|
||||
### 1.1 什么是Bytedesk客服系统?
|
||||
|
||||
Bytedesk是一个开源的在线客服系统,为您的网站提供实时客户服务功能。本手册将指导您将Bytedesk客服Widget集成到vf_react项目中。
|
||||
|
||||
### 1.2 集成架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ vf_react前端项目 │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ App.jsx │ │
|
||||
│ │ ┌──────────────────────────────────────────────┐ │ │
|
||||
│ │ │ BytedeskWidget组件 │ │ │
|
||||
│ │ │ - 动态加载客服脚本 │ │ │
|
||||
│ │ │ - 显示悬浮客服图标 │ │ │
|
||||
│ │ │ - 处理用户交互 │ │ │
|
||||
│ │ └──────────────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP/WebSocket
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Bytedesk后端服务 (43.143.189.195) │
|
||||
│ - API接口: :9003 │
|
||||
│ - WebSocket: :9885 │
|
||||
│ - Nginx反向代理: :80 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 集成特点
|
||||
|
||||
- ✅ **零侵入**: 不修改vf_react原有代码逻辑
|
||||
- ✅ **即插即用**: 复制文件 + 修改配置即可使用
|
||||
- ✅ **样式隔离**: 使用Shadow DOM,不影响全局样式
|
||||
- ✅ **异步加载**: 不阻塞页面渲染
|
||||
- ✅ **跨页面**: 在所有页面显示客服图标
|
||||
- ✅ **响应式**: 自动适配移动端和PC端
|
||||
|
||||
---
|
||||
|
||||
## 2. 快速开始(5分钟集成)
|
||||
|
||||
### 步骤1: 复制集成文件
|
||||
|
||||
将`bytedesk-integration`文件夹复制到vf_react项目的`src/`目录下:
|
||||
|
||||
```bash
|
||||
# 在vf_react项目根目录执行
|
||||
cd D:\【Git】\vf_react
|
||||
cp -r bytedesk-integration src/
|
||||
```
|
||||
|
||||
文件结构:
|
||||
```
|
||||
vf_react/
|
||||
├── src/
|
||||
│ ├── bytedesk-integration/ # 客服集成文件夹
|
||||
│ │ ├── components/
|
||||
│ │ │ └── BytedeskWidget.jsx # 客服Widget组件
|
||||
│ │ ├── config/
|
||||
│ │ │ └── bytedesk.config.js # 配置文件
|
||||
│ │ ├── App.jsx.example # 集成示例代码
|
||||
│ │ ├── .env.bytedesk.example # 环境变量示例
|
||||
│ │ └── 前端工程师集成手册.md # 本手册
|
||||
│ ├── App.jsx # 您的主App文件
|
||||
│ └── ...
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 步骤2: 配置环境变量
|
||||
|
||||
复制环境变量模板到项目根目录并配置:
|
||||
|
||||
```bash
|
||||
# 复制模板
|
||||
cp src/bytedesk-integration/.env.bytedesk.example .env.local
|
||||
|
||||
# 编辑配置文件
|
||||
vim .env.local
|
||||
```
|
||||
|
||||
**必需配置项**(在.env.local中):
|
||||
```bash
|
||||
# Bytedesk服务器地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织ID(由管理员提供)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID(由管理员提供)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
```
|
||||
|
||||
> **注意**: ORG和SID需要从管理员处获取,或登录后台http://43.143.189.195/admin/查看。
|
||||
|
||||
### 步骤3: 集成到App.jsx
|
||||
|
||||
打开`src/App.jsx`,参考`App.jsx.example`添加以下代码:
|
||||
|
||||
```jsx
|
||||
// 1. 导入组件和配置(在文件顶部添加)
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
// 2. 获取配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的原有代码保持不变 */}
|
||||
|
||||
{/* 3. 添加客服Widget(在return的JSX最后添加) */}
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 步骤4: 启动项目测试
|
||||
|
||||
```bash
|
||||
# 安装依赖(如果需要)
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm start
|
||||
```
|
||||
|
||||
打开浏览器,您应该在页面右下角看到客服图标(💬)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细集成步骤
|
||||
|
||||
### 3.1 文件说明
|
||||
|
||||
#### BytedeskWidget.jsx
|
||||
React组件,负责加载和管理Bytedesk客服Widget。
|
||||
|
||||
**主要功能**:
|
||||
- 动态加载客服脚本(https://www.weiyuai.cn/embed/bytedesk-web.js)
|
||||
- 初始化客服Widget
|
||||
- 生命周期管理(加载、卸载、清理)
|
||||
- 错误处理
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface BytedeskWidgetProps {
|
||||
config: Object; // 配置对象(必需)
|
||||
autoLoad?: boolean; // 是否自动加载(默认true)
|
||||
onLoad?: (bytedesk) => void; // 加载成功回调
|
||||
onError?: (error) => void; // 加载失败回调
|
||||
}
|
||||
```
|
||||
|
||||
#### bytedesk.config.js
|
||||
配置文件,包含客服系统的所有配置项。
|
||||
|
||||
**主要函数**:
|
||||
- `getBytedeskConfig()`: 获取基础配置
|
||||
- `getBytedeskConfigWithUser(user)`: 获取带用户信息的配置
|
||||
- `shouldShowCustomerService(pathname)`: 判断是否在当前页面显示客服
|
||||
|
||||
### 3.2 集成方式选择
|
||||
|
||||
根据您的需求,选择合适的集成方式:
|
||||
|
||||
#### 方式一: 全局集成(推荐)
|
||||
|
||||
**适用场景**: 所有页面都需要客服功能
|
||||
|
||||
```jsx
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 方式二: 按页面显示
|
||||
|
||||
**适用场景**: 只在特定页面显示客服(如排除登录页、支付页)
|
||||
|
||||
```jsx
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
自定义页面规则(修改`bytedesk.config.js`):
|
||||
|
||||
```javascript
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面显示客服
|
||||
const allowedPages = [
|
||||
'/',
|
||||
'/home',
|
||||
'/products',
|
||||
'/pricing',
|
||||
];
|
||||
|
||||
// 在以下页面隐藏客服
|
||||
const blockedPages = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/payment',
|
||||
];
|
||||
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
};
|
||||
```
|
||||
|
||||
#### 方式三: 带用户信息集成
|
||||
|
||||
**适用场景**: 需要将登录用户信息传递给客服端
|
||||
|
||||
```jsx
|
||||
import { useContext } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext';
|
||||
|
||||
function App() {
|
||||
const { user } = useContext(AuthContext);
|
||||
const bytedeskConfig = getBytedeskConfigWithUser(user);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
用户信息格式:
|
||||
```javascript
|
||||
const user = {
|
||||
id: '12345', // 用户ID(必需)
|
||||
name: '张三', // 用户名
|
||||
email: 'user@example.com', // 邮箱
|
||||
mobile: '13800138000', // 手机号
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置说明
|
||||
|
||||
### 4.1 环境变量配置
|
||||
|
||||
在`.env.local`文件中配置(项目根目录):
|
||||
|
||||
```bash
|
||||
# ========== 必需配置 ==========
|
||||
|
||||
# 后端服务地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织ID
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
|
||||
# ========== 可选配置 ==========
|
||||
|
||||
# 客服类型 (2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
# 语言 (zh-cn, en, ja, ko)
|
||||
REACT_APP_BYTEDESK_LOCALE=zh-cn
|
||||
|
||||
# 图标位置 (bottom-right, bottom-left, top-right, top-left)
|
||||
REACT_APP_BYTEDESK_PLACEMENT=bottom-right
|
||||
|
||||
# 图标边距(像素)
|
||||
REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
|
||||
REACT_APP_BYTEDESK_MARGIN_SIDE=20
|
||||
|
||||
# 主题模式 (system, light, dark)
|
||||
REACT_APP_BYTEDESK_THEME_MODE=system
|
||||
|
||||
# 主题色
|
||||
REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
|
||||
|
||||
# 自动弹出(不推荐)
|
||||
REACT_APP_BYTEDESK_AUTO_POPUP=false
|
||||
```
|
||||
|
||||
### 4.2 代码配置
|
||||
|
||||
在`bytedesk.config.js`中直接修改:
|
||||
|
||||
```javascript
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
htmlUrl: 'http://43.143.189.195/chat/',
|
||||
|
||||
// 客服图标位置
|
||||
placement: 'bottom-right',
|
||||
|
||||
// 边距设置
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
|
||||
// 自动弹出
|
||||
autoPopup: false,
|
||||
|
||||
// 语言设置
|
||||
locale: 'zh-cn',
|
||||
|
||||
// 客服图标配置
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: '💬', // 可以使用emoji或图片URL
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
backgroundColor: '#0066FF',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
|
||||
// 聊天配置
|
||||
chatConfig: {
|
||||
org: 'df_org_uid',
|
||||
t: '2', // 2=人工客服, 1=机器人
|
||||
sid: 'df_wg_aftersales',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 高级功能
|
||||
|
||||
### 5.1 多工作组支持
|
||||
|
||||
根据页面显示不同工作组的客服:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
export const getBytedeskConfigByPath = (pathname) => {
|
||||
const config = getBytedeskConfig();
|
||||
|
||||
// 根据路径选择工作组
|
||||
if (pathname.startsWith('/sales')) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
sid: 'df_wg_sales', // 销售组
|
||||
},
|
||||
};
|
||||
} else if (pathname.startsWith('/support')) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
sid: 'df_wg_support', // 技术支持组
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config; // 默认售后组
|
||||
};
|
||||
```
|
||||
|
||||
使用示例:
|
||||
```jsx
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getBytedeskConfigByPath } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const bytedeskConfig = getBytedeskConfigByPath(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 条件性显示
|
||||
|
||||
根据用户登录状态或角色显示客服:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const { user } = useContext(AuthContext);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 只为普通用户显示客服(管理员不显示)
|
||||
const showBytedesk = user && user.role === 'customer';
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 事件回调
|
||||
|
||||
监听客服系统的加载状态:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const handleLoad = (bytedesk) => {
|
||||
console.log('客服系统加载成功', bytedesk);
|
||||
// 可以在这里执行自定义逻辑
|
||||
// 例如: 发送统计事件
|
||||
};
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('客服系统加载失败', error);
|
||||
// 可以在这里显示降级方案
|
||||
// 例如: 显示备用联系方式
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 自定义触发按钮
|
||||
|
||||
隐藏默认图标,使用自定义按钮:
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 隐藏默认图标
|
||||
const bytedeskConfig = {
|
||||
...getBytedeskConfig(),
|
||||
bubbleConfig: {
|
||||
show: false, // 隐藏默认图标
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 自定义按钮 */}
|
||||
<button
|
||||
onClick={() => setShowBytedesk(true)}
|
||||
className="custom-service-btn"
|
||||
>
|
||||
联系客服
|
||||
</button>
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 样式定制
|
||||
|
||||
### 6.1 修改主题色
|
||||
|
||||
在配置中修改主题色:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
theme: {
|
||||
mode: 'light',
|
||||
backgroundColor: '#FF6600', // 您的品牌色
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 修改图标位置
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
placement: 'bottom-left', // 左下角
|
||||
marginBottom: 30, // 距底部30px
|
||||
marginSide: 30, // 距左侧30px
|
||||
```
|
||||
|
||||
### 6.3 使用自定义图标
|
||||
|
||||
使用图片URL替换emoji:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: 'https://yourdomain.com/images/service-icon.png',
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
```
|
||||
|
||||
### 6.4 样式不冲突
|
||||
|
||||
Bytedesk Widget使用Shadow DOM技术,样式完全隔离,不会影响您的全局CSS。
|
||||
|
||||
---
|
||||
|
||||
## 7. 故障排查
|
||||
|
||||
### 7.1 客服图标不显示
|
||||
|
||||
**可能原因**:
|
||||
1. 环境变量未配置
|
||||
2. 配置文件路径错误
|
||||
3. 后端服务未启动
|
||||
4. 脚本加载失败
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 1. 检查.env.local文件是否存在
|
||||
ls -la .env.local
|
||||
|
||||
# 2. 检查环境变量是否加载
|
||||
console.log(process.env.REACT_APP_BYTEDESK_API_URL);
|
||||
|
||||
# 3. 检查后端服务状态
|
||||
curl http://43.143.189.195/api/health
|
||||
|
||||
# 4. 查看浏览器控制台错误
|
||||
# 打开浏览器开发者工具 -> Console标签页
|
||||
```
|
||||
|
||||
### 7.2 连接不上后端
|
||||
|
||||
**检查清单**:
|
||||
```bash
|
||||
# 1. 后端服务是否运行
|
||||
# 联系后端工程师确认docker容器状态
|
||||
|
||||
# 2. 防火墙是否开放
|
||||
# 确认80端口可访问
|
||||
|
||||
# 3. CORS配置
|
||||
# 后端需要在.env.production中添加您的前端地址:
|
||||
# BYTEDESK_CORS_ALLOWED_ORIGINS=http://your-frontend-domain.com
|
||||
```
|
||||
|
||||
### 7.3 ORG或SID错误
|
||||
|
||||
**获取正确配置**:
|
||||
1. 登录管理后台: http://43.143.189.195/admin/
|
||||
2. 导航到"设置" -> "组织信息",复制`组织UID`
|
||||
3. 导航到"客服管理" -> "工作组",复制`工作组ID`
|
||||
4. 更新`.env.local`文件
|
||||
5. 重启开发服务器: `npm start`
|
||||
|
||||
### 7.4 开发环境正常,生产环境异常
|
||||
|
||||
**检查清单**:
|
||||
```bash
|
||||
# 1. 确认生产环境的环境变量
|
||||
# 查看构建时的配置
|
||||
|
||||
# 2. 检查CORS配置
|
||||
# 后端需要添加生产域名到CORS白名单
|
||||
|
||||
# 3. 检查HTTPS/HTTP
|
||||
# 如果前端使用HTTPS,后端也应使用HTTPS
|
||||
|
||||
# 4. 查看生产环境日志
|
||||
npm run build
|
||||
# 检查构建产物中的配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题FAQ
|
||||
|
||||
### Q1: 客服系统会影响页面性能吗?
|
||||
|
||||
**A**: 不会。客服脚本采用异步加载,不会阻塞页面渲染。Widget总大小约50KB(gzip后),首次加载后会被浏览器缓存。
|
||||
|
||||
### Q2: 可以在移动端使用吗?
|
||||
|
||||
**A**: 可以。Bytedesk Widget完全响应式,自动适配移动端和PC端。
|
||||
|
||||
### Q3: 是否支持离线消息?
|
||||
|
||||
**A**: 支持。用户在客服离线时发送的消息会被保存,客服上线后可以查看。
|
||||
|
||||
### Q4: 可以集成到React Native吗?
|
||||
|
||||
**A**: BytedeskWidget是为Web设计的。React Native需要使用Bytedesk的原生SDK(另外提供)。
|
||||
|
||||
### Q5: 如何隐藏特定页面的客服?
|
||||
|
||||
**A**: 使用`shouldShowCustomerService`函数(见3.2节"方式二")。
|
||||
|
||||
### Q6: 可以同时配置多个工作组吗?
|
||||
|
||||
**A**: 可以。参考5.1节"多工作组支持"。
|
||||
|
||||
### Q7: 用户信息是否安全?
|
||||
|
||||
**A**: 是的。所有通信使用WebSocket加密传输,用户信息不会被第三方获取。建议生产环境使用HTTPS。
|
||||
|
||||
### Q8: 是否需要付费?
|
||||
|
||||
**A**: Bytedesk社区版(当前使用)完全免费,License有效期至2040年12月31日。
|
||||
|
||||
---
|
||||
|
||||
## 9. 性能优化
|
||||
|
||||
### 9.1 按需加载
|
||||
|
||||
只在需要时加载客服系统:
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function App() {
|
||||
const [loadBytedesk, setLoadBytedesk] = useState(false);
|
||||
|
||||
// 延迟5秒加载(页面渲染完成后)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoadBytedesk(true);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
{loadBytedesk && (
|
||||
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Lazy Import
|
||||
|
||||
使用React.lazy延迟导入组件:
|
||||
|
||||
```jsx
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const BytedeskWidget = lazy(() => import('./bytedesk-integration/components/BytedeskWidget'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 缓存优化
|
||||
|
||||
客服脚本会自动被浏览器缓存,无需额外配置。
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全注意事项
|
||||
|
||||
### 10.1 环境变量安全
|
||||
|
||||
```bash
|
||||
# ❌ 错误: 不要在代码中硬编码配置
|
||||
const config = {
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
org: 'df_org_uid',
|
||||
};
|
||||
|
||||
# ✅ 正确: 使用环境变量
|
||||
const config = {
|
||||
apiUrl: process.env.REACT_APP_BYTEDESK_API_URL,
|
||||
org: process.env.REACT_APP_BYTEDESK_ORG,
|
||||
};
|
||||
```
|
||||
|
||||
### 10.2 敏感信息保护
|
||||
|
||||
```javascript
|
||||
// ❌ 不要传递敏感信息
|
||||
const user = {
|
||||
id: '12345',
|
||||
password: 'user-password', // 不要传递密码
|
||||
creditCard: '1234-5678', // 不要传递信用卡
|
||||
};
|
||||
|
||||
// ✅ 只传递必要信息
|
||||
const user = {
|
||||
id: '12345',
|
||||
name: '张三',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
```
|
||||
|
||||
### 10.3 HTTPS使用
|
||||
|
||||
生产环境强烈建议使用HTTPS:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 生产环境
|
||||
REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
|
||||
```
|
||||
|
||||
### 10.4 内容安全策略(CSP)
|
||||
|
||||
如果您的项目使用CSP,需要允许以下域名:
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' https://www.weiyuai.cn;
|
||||
connect-src 'self' http://43.143.189.195;
|
||||
img-src 'self' data: http://43.143.189.195;
|
||||
"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 获取帮助
|
||||
|
||||
### 11.1 联系方式
|
||||
|
||||
- **技术支持**: 访问 http://43.143.189.195/chat/ 在线咨询
|
||||
- **管理员**: 联系您的项目管理员获取ORG和SID
|
||||
- **后端工程师**: 联系后端团队确认服务器状态
|
||||
|
||||
### 11.2 日志查看
|
||||
|
||||
```javascript
|
||||
// 在浏览器控制台查看Bytedesk日志
|
||||
// 日志前缀为 [Bytedesk]
|
||||
|
||||
// 示例:
|
||||
[Bytedesk] 开始加载客服Widget...
|
||||
[Bytedesk] Widget脚本加载成功
|
||||
[Bytedesk] 初始化Widget
|
||||
[Bytedesk] Widget初始化成功
|
||||
```
|
||||
|
||||
### 11.3 调试技巧
|
||||
|
||||
```javascript
|
||||
// 1. 检查配置是否正确
|
||||
console.log('Bytedesk配置:', getBytedeskConfig());
|
||||
|
||||
// 2. 检查环境变量
|
||||
console.log('API URL:', process.env.REACT_APP_BYTEDESK_API_URL);
|
||||
console.log('ORG:', process.env.REACT_APP_BYTEDESK_ORG);
|
||||
console.log('SID:', process.env.REACT_APP_BYTEDESK_SID);
|
||||
|
||||
// 3. 检查Widget是否加载
|
||||
console.log('BytedeskWeb对象:', window.BytedeskWeb);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 版本历史
|
||||
|
||||
| 版本 | 日期 | 更新内容 |
|
||||
|------|------|---------|
|
||||
| v1.0 | 2025-01-07 | 初始版本,支持基础集成功能 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 附录
|
||||
|
||||
### 13.1 完整配置示例
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js - 完整配置
|
||||
export const bytedeskConfig = {
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
htmlUrl: 'http://43.143.189.195/chat/',
|
||||
placement: 'bottom-right',
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
autoPopup: false,
|
||||
locale: 'zh-cn',
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: '💬',
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
theme: {
|
||||
mode: 'system',
|
||||
backgroundColor: '#0066FF',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
chatConfig: {
|
||||
org: 'df_org_uid',
|
||||
t: '2',
|
||||
sid: 'df_wg_aftersales',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 13.2 文件清单
|
||||
|
||||
集成所需的所有文件:
|
||||
|
||||
```
|
||||
bytedesk-integration/
|
||||
├── components/
|
||||
│ └── BytedeskWidget.jsx # React组件(必需)
|
||||
├── config/
|
||||
│ └── bytedesk.config.js # 配置文件(必需)
|
||||
├── App.jsx.example # 集成示例(参考)
|
||||
├── .env.bytedesk.example # 环境变量示例(参考)
|
||||
└── 前端工程师集成手册.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,371 +0,0 @@
|
||||
# 消息推送系统整合 - 测试指南
|
||||
|
||||
## 📋 整合完成清单
|
||||
|
||||
✅ **统一事件名称**
|
||||
- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
|
||||
- 移除了 `trade_notification` 事件名
|
||||
|
||||
✅ **数据适配器**
|
||||
- 创建了 `adaptEventToNotification` 函数
|
||||
- 自动识别后端事件格式并转换为前端通知格式
|
||||
- 重要性映射:S → urgent, A → important, B/C → normal
|
||||
|
||||
✅ **NotificationContext 升级**
|
||||
- 监听 `new_event` 事件
|
||||
- 自动使用适配器转换事件数据
|
||||
- 支持 Mock 和 Real 模式无缝切换
|
||||
|
||||
✅ **EventList 实时推送**
|
||||
- 集成 `useEventNotifications` Hook
|
||||
- 实时更新事件列表
|
||||
- Toast 通知提示
|
||||
- WebSocket 连接状态指示器
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试 Mock 模式(开发环境)
|
||||
|
||||
#### 1.1 配置环境变量
|
||||
确保 `.env` 文件包含以下配置:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=true
|
||||
# 或者
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
```
|
||||
|
||||
#### 1.2 启动应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 1.3 验证功能
|
||||
|
||||
**a) 右下角通知卡片**
|
||||
- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
|
||||
- 每隔 60 秒会自动推送 1-2 条模拟消息
|
||||
- 通知类型包括:
|
||||
- 📢 公告通知(蓝色)
|
||||
- 📈 股票动向(红/绿色,根据涨跌)
|
||||
- 📰 事件动向(橙色)
|
||||
- 📊 分析报告(紫色)
|
||||
|
||||
**b) 事件列表页面**
|
||||
- 访问事件列表页面(Community/Events)
|
||||
- 顶部应显示 "🟢 实时推送已开启"
|
||||
- 收到新事件时:
|
||||
- 右上角显示 Toast 通知
|
||||
- 事件自动添加到列表顶部
|
||||
- 无重复添加
|
||||
|
||||
**c) 控制台日志**
|
||||
打开浏览器控制台,应该看到:
|
||||
```
|
||||
[Socket Service] Using MOCK Socket Service
|
||||
NotificationContext: Socket connected
|
||||
EventList: 收到新事件推送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 测试 Real 模式(生产环境)
|
||||
|
||||
#### 2.1 配置环境变量
|
||||
修改 `.env` 文件:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=false
|
||||
# 或删除该配置项
|
||||
```
|
||||
|
||||
#### 2.2 启动后端 Flask 服务
|
||||
```bash
|
||||
python app_2.py
|
||||
```
|
||||
|
||||
确保后端已启动 Socket.IO 服务并监听事件推送。
|
||||
|
||||
#### 2.3 启动前端应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 2.4 创建测试事件(后端)
|
||||
使用后端提供的测试脚本:
|
||||
```bash
|
||||
python test_create_event.py
|
||||
```
|
||||
|
||||
#### 2.5 验证功能
|
||||
|
||||
**a) WebSocket 连接**
|
||||
- 检查控制台:`[Socket Service] Using REAL Socket Service`
|
||||
- 事件列表顶部显示 "🟢 实时推送已开启"
|
||||
|
||||
**b) 事件推送流程**
|
||||
1. 运行 `test_create_event.py` 创建新事件
|
||||
2. 后端轮询检测到新事件(最多等待 30 秒)
|
||||
3. 后端通过 Socket.IO 推送 `new_event`
|
||||
4. 前端接收事件并转换格式
|
||||
5. 同时显示:
|
||||
- 右下角通知卡片
|
||||
- 事件列表 Toast 提示
|
||||
- 事件添加到列表顶部
|
||||
|
||||
**c) 数据格式验证**
|
||||
在控制台查看事件对象,应包含:
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
type: "event_alert", // 适配器转换后
|
||||
priority: "urgent", // importance: S → urgent
|
||||
title: "事件标题",
|
||||
content: "事件描述",
|
||||
clickable: true,
|
||||
link: "/event-detail/123",
|
||||
extra: {
|
||||
eventType: "tech",
|
||||
importance: "S",
|
||||
// ... 更多后端字段
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证清单
|
||||
|
||||
### 功能验证
|
||||
|
||||
- [ ] Mock 模式下收到模拟通知
|
||||
- [ ] Real 模式下收到真实后端推送
|
||||
- [ ] 通知卡片正确显示(类型、颜色、内容)
|
||||
- [ ] 事件列表实时更新
|
||||
- [ ] Toast 通知正常弹出
|
||||
- [ ] 连接状态指示器正确显示
|
||||
- [ ] 点击通知可跳转到详情页
|
||||
- [ ] 无重复事件添加
|
||||
|
||||
### 数据验证
|
||||
|
||||
- [ ] 后端事件格式正确转换
|
||||
- [ ] 重要性映射正确(S/A/B/C → urgent/important/normal)
|
||||
- [ ] 时间戳正确显示
|
||||
- [ ] 链接路径正确生成
|
||||
- [ ] 所有字段完整保留在 extra 中
|
||||
|
||||
### 性能验证
|
||||
|
||||
- [ ] 事件列表最多保留 100 条
|
||||
- [ ] 通知自动关闭(紧急=不关闭,重要=30s,普通=15s)
|
||||
- [ ] WebSocket 自动重连
|
||||
- [ ] 无内存泄漏
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题排查
|
||||
|
||||
### Q1: Mock 模式下没有收到通知?
|
||||
**A:** 检查:
|
||||
1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
|
||||
2. 控制台是否显示 "Using MOCK Socket Service"
|
||||
3. 是否等待了 3 秒(首次通知延迟)
|
||||
|
||||
### Q2: Real 模式下无法连接?
|
||||
**A:** 检查:
|
||||
1. Flask 后端是否启动:`python app_2.py`
|
||||
2. API_BASE_URL 是否正确配置
|
||||
3. CORS 设置是否包含前端域名
|
||||
4. 控制台是否有连接错误
|
||||
|
||||
### Q3: 收到重复通知?
|
||||
**A:** 检查:
|
||||
1. 是否多次渲染了 EventList 组件
|
||||
2. 是否在多个地方调用了 `useEventNotifications`
|
||||
3. 控制台日志中是否有 "事件已存在,跳过添加"
|
||||
|
||||
### Q4: 通知卡片样式异常?
|
||||
**A:** 检查:
|
||||
1. 事件的 `type` 字段是否正确
|
||||
2. 是否缺少必要的字段(title, content)
|
||||
3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
|
||||
|
||||
### Q5: 事件列表不更新?
|
||||
**A:** 检查:
|
||||
1. WebSocket 连接状态(顶部 Badge)
|
||||
2. `onNewEvent` 回调是否触发(控制台日志)
|
||||
3. `setLocalEvents` 是否正确执行
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试数据示例
|
||||
|
||||
### Mock 模拟数据类型
|
||||
|
||||
**公告通知**
|
||||
```javascript
|
||||
{
|
||||
type: "announcement",
|
||||
priority: "urgent",
|
||||
title: "贵州茅台发布2024年度财报公告",
|
||||
content: "2024年度营收同比增长15.2%..."
|
||||
}
|
||||
```
|
||||
|
||||
**股票动向**
|
||||
```javascript
|
||||
{
|
||||
type: "stock_alert",
|
||||
priority: "urgent",
|
||||
title: "您关注的股票触发预警",
|
||||
extra: {
|
||||
stockCode: "300750",
|
||||
priceChange: "+5.2%"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**事件动向**
|
||||
```javascript
|
||||
{
|
||||
type: "event_alert",
|
||||
priority: "important",
|
||||
title: "央行宣布降准0.5个百分点",
|
||||
extra: {
|
||||
eventId: "evt001",
|
||||
sectors: ["银行", "地产", "基建"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析报告**
|
||||
```javascript
|
||||
{
|
||||
type: "analysis_report",
|
||||
priority: "important",
|
||||
title: "医药行业深度报告:创新药迎来政策拐点",
|
||||
author: {
|
||||
name: "李明",
|
||||
organization: "中信证券"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 真实后端事件格式
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
title: "新能源汽车补贴政策延期",
|
||||
description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
|
||||
event_type: "policy",
|
||||
importance: "S",
|
||||
status: "active",
|
||||
created_at: "2025-01-21T14:30:00",
|
||||
hot_score: 95.5,
|
||||
view_count: 1234,
|
||||
related_avg_chg: 5.2,
|
||||
related_max_chg: 15.8,
|
||||
keywords: ["新能源", "补贴", "政策"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步建议
|
||||
|
||||
### 1. 用户设置
|
||||
允许用户控制通知偏好:
|
||||
```jsx
|
||||
<Switch
|
||||
isChecked={enableNotifications}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
启用实时通知
|
||||
</Switch>
|
||||
```
|
||||
|
||||
### 2. 通知过滤
|
||||
按重要性、类型过滤通知:
|
||||
```javascript
|
||||
useEventNotifications({
|
||||
eventType: 'tech', // 只订阅科技类
|
||||
importance: 'S', // 只订阅 S 级
|
||||
enabled: true
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 声音提示
|
||||
添加音效提醒:
|
||||
```javascript
|
||||
onNewEvent: (event) => {
|
||||
if (event.priority === 'urgent') {
|
||||
new Audio('/alert.mp3').play();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 桌面通知
|
||||
利用浏览器通知 API:
|
||||
```javascript
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(event.title, {
|
||||
body: event.content,
|
||||
icon: '/logo.png'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 技术说明
|
||||
|
||||
### 架构优势
|
||||
|
||||
1. **统一接口**:Mock 和 Real 完全相同的 API
|
||||
2. **自动适配**:智能识别数据格式并转换
|
||||
3. **解耦设计**:通知系统和事件列表独立工作
|
||||
4. **向后兼容**:不影响现有功能
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `src/services/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
后端创建事件
|
||||
↓
|
||||
后端轮询检测(30秒)
|
||||
↓
|
||||
Socket.IO 推送 new_event
|
||||
↓
|
||||
前端 socketService 接收
|
||||
↓
|
||||
NotificationContext 监听并适配
|
||||
↓
|
||||
同时触发:
|
||||
├─ NotificationContainer(右下角卡片)
|
||||
└─ EventList onNewEvent(Toast + 列表更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 整合完成
|
||||
|
||||
所有代码和功能已经就绪!你现在可以:
|
||||
|
||||
1. ✅ 在 Mock 模式下测试实时推送
|
||||
2. ✅ 在 Real 模式下连接后端
|
||||
3. ✅ 查看右下角通知卡片
|
||||
4. ✅ 体验事件列表实时更新
|
||||
5. ✅ 随时切换 Mock/Real 模式
|
||||
|
||||
**祝测试顺利!🎉**
|
||||
576
docs/NEW_PAYMENT_SYSTEM_DESIGN.md
Normal file
576
docs/NEW_PAYMENT_SYSTEM_DESIGN.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# 订阅支付系统重新设计方案
|
||||
|
||||
## 📊 问题分析
|
||||
|
||||
### 现有系统的问题
|
||||
|
||||
1. **价格配置混乱**
|
||||
- 季付和月付价格相同(配置错误)
|
||||
- `monthly_price` 和 `yearly_price` 字段命名不清晰
|
||||
- 缺少季付、半年付等周期的价格配置
|
||||
|
||||
2. **升级逻辑复杂且不合理**
|
||||
- 计算剩余价值折算(按天计算 `remaining_value`)
|
||||
- 用户难以理解升级价格
|
||||
- 续费用户和新用户价格不一致
|
||||
- 逻辑复杂,容易出错
|
||||
|
||||
3. **按钮文案不清晰**
|
||||
- 已订阅用户应显示"续费 Pro"/"续费 Max"
|
||||
- 而不是"升级至 Pro"/"切换至 Pro"
|
||||
|
||||
4. **数据库表设计问题**
|
||||
- `SubscriptionUpgrade` 表记录升级,但逻辑过于复杂
|
||||
- `PaymentOrder` 表缺少必要字段
|
||||
- 价格配置分散在多个字段
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新设计方案
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **简化续费逻辑**: **续费用户与新用户价格完全一致**,不做任何折算
|
||||
2. **清晰的价格体系**: 每个套餐每个周期都有明确的价格
|
||||
3. **统一的用户体验**: 无论是新购还是续费,价格透明一致
|
||||
4. **独立的订阅记录**: 每次支付都创建新的订阅记录(历史可追溯)
|
||||
|
||||
---
|
||||
|
||||
## 📐 数据库表设计
|
||||
|
||||
### 1. `subscription_plans` - 订阅套餐表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscription_plans (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
|
||||
plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
|
||||
description TEXT COMMENT '套餐描述',
|
||||
features JSON COMMENT '功能列表',
|
||||
|
||||
-- 价格配置(所有周期价格)
|
||||
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
|
||||
price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
|
||||
price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
|
||||
price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
|
||||
|
||||
-- 状态字段
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
display_order INT DEFAULT 0 COMMENT '展示顺序',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_plan_code (plan_code),
|
||||
INDEX idx_active_order (is_active, display_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
|
||||
```
|
||||
|
||||
**示例数据**:
|
||||
```sql
|
||||
INSERT INTO subscription_plans (plan_code, plan_name, description, price_monthly, price_quarterly, price_semiannual, price_yearly) VALUES
|
||||
('pro', 'Pro 专业版', '为专业投资者打造', 299.00, 799.00, 1499.00, 2699.00),
|
||||
('max', 'Max 旗舰版', '旗舰级体验', 599.00, 1599.00, 2999.00, 5399.00);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `user_subscriptions` - 用户订阅记录表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_subscriptions (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
|
||||
|
||||
-- 订阅基本信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
|
||||
|
||||
-- 订阅时间
|
||||
start_date DATETIME NOT NULL COMMENT '订阅开始时间',
|
||||
end_date DATETIME NOT NULL COMMENT '订阅结束时间',
|
||||
|
||||
-- 订阅状态
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
|
||||
is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
|
||||
|
||||
-- 支付信息
|
||||
payment_order_id INT COMMENT '关联的支付订单ID',
|
||||
paid_amount DECIMAL(10,2) NOT NULL COMMENT '实际支付金额',
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
|
||||
-- 订阅类型
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
|
||||
|
||||
-- 自动续费
|
||||
auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_subscription_id (subscription_id),
|
||||
INDEX idx_user_current (user_id, is_current),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_end_date (end_date),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
|
||||
```
|
||||
|
||||
**设计说明**:
|
||||
- 每次支付都创建新的订阅记录
|
||||
- 通过 `is_current` 标识当前生效的订阅
|
||||
- 支持订阅历史追溯
|
||||
- 续费时记录 `previous_subscription_id` 形成订阅链
|
||||
|
||||
---
|
||||
|
||||
### 3. `payment_orders` - 支付订单表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE payment_orders (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
|
||||
-- 订阅信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
|
||||
-- 价格信息
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
|
||||
|
||||
-- 优惠码
|
||||
promo_code_id INT COMMENT '优惠码ID',
|
||||
promo_code VARCHAR(50) COMMENT '优惠码',
|
||||
|
||||
-- 支付信息
|
||||
payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
|
||||
payment_channel VARCHAR(50) COMMENT '支付渠道详情',
|
||||
transaction_id VARCHAR(64) COMMENT '第三方交易号',
|
||||
qr_code_url TEXT COMMENT '支付二维码URL',
|
||||
|
||||
-- 订单状态
|
||||
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
|
||||
|
||||
-- 时间信息
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
expired_at TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 备注
|
||||
remark TEXT COMMENT '备注信息',
|
||||
|
||||
INDEX idx_order_no (order_no),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `promo_codes` - 优惠码表(保持不变,微调)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promo_codes (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码',
|
||||
description VARCHAR(200) COMMENT '描述',
|
||||
|
||||
-- 折扣类型
|
||||
discount_type VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比), fixed_amount(固定金额)',
|
||||
discount_value DECIMAL(10,2) NOT NULL COMMENT '折扣值',
|
||||
|
||||
-- 适用范围
|
||||
applicable_plans JSON COMMENT '适用套餐: ["pro", "max"] 或 null(全部)',
|
||||
applicable_cycles JSON COMMENT '适用周期: ["monthly", "yearly"] 或 null(全部)',
|
||||
min_amount DECIMAL(10,2) COMMENT '最低消费金额',
|
||||
|
||||
-- 使用限制
|
||||
max_total_uses INT COMMENT '最大使用次数(总)',
|
||||
max_uses_per_user INT DEFAULT 1 COMMENT '每用户最大使用次数',
|
||||
current_uses INT DEFAULT 0 COMMENT '当前使用次数',
|
||||
|
||||
-- 有效期
|
||||
valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
|
||||
valid_until TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 状态
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_code (code),
|
||||
INDEX idx_active (is_active),
|
||||
INDEX idx_valid_period (valid_from, valid_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `promo_code_usage` - 优惠码使用记录表(保持不变)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promo_code_usage (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
promo_code_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
order_id INT NOT NULL,
|
||||
discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
|
||||
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_promo_code (promo_code_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_order_id (order_id),
|
||||
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 删除不必要的表
|
||||
|
||||
**删除 `subscription_upgrades` 表** - 不再需要复杂的升级逻辑
|
||||
|
||||
---
|
||||
|
||||
## 💡 业务逻辑设计
|
||||
|
||||
### 1. 价格计算逻辑(简化版)
|
||||
|
||||
```python
|
||||
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None):
|
||||
"""
|
||||
计算订阅价格(新购和续费价格完全一致)
|
||||
|
||||
Args:
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
promo_code: 优惠码(可选)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'plan_code': 'pro',
|
||||
'billing_cycle': 'yearly',
|
||||
'original_price': 2699.00,
|
||||
'discount_amount': 0,
|
||||
'final_amount': 2699.00,
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
"""
|
||||
# 1. 查询套餐价格
|
||||
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
|
||||
if not plan:
|
||||
return {'error': '套餐不存在'}
|
||||
|
||||
# 2. 获取对应周期的价格
|
||||
price_field = f'price_{billing_cycle}'
|
||||
original_price = getattr(plan, price_field, 0)
|
||||
|
||||
if original_price <= 0:
|
||||
return {'error': '价格配置错误'}
|
||||
|
||||
result = {
|
||||
'plan_code': plan_code,
|
||||
'plan_name': plan.plan_name,
|
||||
'billing_cycle': billing_cycle,
|
||||
'original_price': float(original_price),
|
||||
'discount_amount': 0,
|
||||
'final_amount': float(original_price),
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
|
||||
# 3. 应用优惠码(如果有)
|
||||
if promo_code:
|
||||
promo, error = validate_promo_code(promo_code, plan_code, billing_cycle, original_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, original_price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = float(original_price - discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ 不再计算 `remaining_value`(剩余价值)
|
||||
- ✅ 不再区分新购/续费价格
|
||||
- ✅ 逻辑简单,易于维护
|
||||
- ✅ 用户体验清晰透明
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建订单逻辑
|
||||
|
||||
```python
|
||||
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None):
|
||||
"""
|
||||
创建订阅支付订单
|
||||
"""
|
||||
# 1. 计算价格
|
||||
price_result = calculate_subscription_price(plan_code, billing_cycle, promo_code)
|
||||
if 'error' in price_result:
|
||||
return {'success': False, 'error': price_result['error']}
|
||||
|
||||
# 2. 判断是新购还是续费
|
||||
current_sub = get_current_subscription(user_id)
|
||||
|
||||
subscription_type = 'new'
|
||||
if current_sub and current_sub.plan_code in ['pro', 'max']:
|
||||
subscription_type = 'renew'
|
||||
|
||||
# 3. 创建支付订单
|
||||
order = PaymentOrder(
|
||||
order_no=generate_order_no(user_id),
|
||||
user_id=user_id,
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
subscription_type=subscription_type,
|
||||
original_price=price_result['original_price'],
|
||||
discount_amount=price_result['discount_amount'],
|
||||
final_amount=price_result['final_amount'],
|
||||
promo_code=promo_code,
|
||||
status='pending',
|
||||
expired_at=datetime.now() + timedelta(minutes=30)
|
||||
)
|
||||
|
||||
db.session.add(order)
|
||||
db.session.commit()
|
||||
|
||||
# 4. 生成支付二维码(微信支付)
|
||||
qr_code_url = generate_wechat_qr_code(order)
|
||||
order.qr_code_url = qr_code_url
|
||||
db.session.commit()
|
||||
|
||||
return {'success': True, 'order': order.to_dict()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 支付成功后的订阅激活逻辑
|
||||
|
||||
```python
|
||||
def activate_subscription_after_payment(order_id):
|
||||
"""
|
||||
支付成功后激活订阅
|
||||
"""
|
||||
order = PaymentOrder.query.get(order_id)
|
||||
if not order or order.status != 'paid':
|
||||
return {'success': False, 'error': '订单状态错误'}
|
||||
|
||||
user_id = order.user_id
|
||||
plan_code = order.plan_code
|
||||
billing_cycle = order.billing_cycle
|
||||
|
||||
# 1. 计算订阅周期
|
||||
cycle_days = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
days = cycle_days.get(billing_cycle, 30)
|
||||
|
||||
# 2. 获取当前订阅
|
||||
current_sub = UserSubscription.query.filter_by(
|
||||
user_id=user_id,
|
||||
is_current=True
|
||||
).first()
|
||||
|
||||
# 3. 计算开始和结束时间
|
||||
now = datetime.now()
|
||||
|
||||
if current_sub and current_sub.end_date > now:
|
||||
# 续费:从当前订阅结束时间开始
|
||||
start_date = current_sub.end_date
|
||||
else:
|
||||
# 新购:从当前时间开始
|
||||
start_date = now
|
||||
|
||||
end_date = start_date + timedelta(days=days)
|
||||
|
||||
# 4. 创建新订阅记录
|
||||
new_subscription = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_id=generate_subscription_id(),
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
status='active',
|
||||
is_current=True,
|
||||
payment_order_id=order.id,
|
||||
paid_amount=order.final_amount,
|
||||
original_price=order.original_price,
|
||||
discount_amount=order.discount_amount,
|
||||
subscription_type=order.subscription_type,
|
||||
previous_subscription_id=current_sub.subscription_id if current_sub else None
|
||||
)
|
||||
|
||||
# 5. 将旧订阅标记为非当前
|
||||
if current_sub:
|
||||
current_sub.is_current = False
|
||||
|
||||
db.session.add(new_subscription)
|
||||
db.session.commit()
|
||||
|
||||
return {'success': True, 'subscription': new_subscription.to_dict()}
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
- ✅ 续费时从**当前订阅结束时间**开始,避免浪费
|
||||
- ✅ 每次支付都创建新的订阅记录
|
||||
- ✅ 保留历史订阅记录(通过 `previous_subscription_id` 形成链)
|
||||
- ✅ 逻辑清晰,易于理解
|
||||
|
||||
---
|
||||
|
||||
### 4. 按钮文案逻辑
|
||||
|
||||
```python
|
||||
def get_subscription_button_text(user, plan_code, billing_cycle):
|
||||
"""
|
||||
获取订阅按钮文字
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期
|
||||
|
||||
Returns:
|
||||
str: 按钮文字
|
||||
"""
|
||||
current_sub = get_current_subscription(user.id)
|
||||
|
||||
# 1. 如果没有订阅或订阅已过期
|
||||
if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
|
||||
return f"选择 {get_plan_display_name(plan_code)}"
|
||||
|
||||
# 2. 如果是当前套餐且周期相同
|
||||
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
|
||||
return f"续费 {get_plan_display_name(plan_code)}"
|
||||
|
||||
# 3. 如果是当前套餐但周期不同
|
||||
if current_sub.plan_code == plan_code:
|
||||
return f"切换至{get_cycle_display_name(billing_cycle)}"
|
||||
|
||||
# 4. 如果是不同套餐
|
||||
return f"选择 {get_plan_display_name(plan_code)}"
|
||||
|
||||
def get_plan_display_name(plan_code):
|
||||
names = {'pro': 'Pro 专业版', 'max': 'Max 旗舰版'}
|
||||
return names.get(plan_code, plan_code)
|
||||
|
||||
def get_cycle_display_name(billing_cycle):
|
||||
names = {
|
||||
'monthly': '月付',
|
||||
'quarterly': '季付',
|
||||
'semiannual': '半年付',
|
||||
'yearly': '年付'
|
||||
}
|
||||
return names.get(billing_cycle, billing_cycle)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 免费用户看 Pro 年付: "选择 Pro 专业版"
|
||||
- Pro 月付用户看 Pro 年付: "切换至年付"
|
||||
- Pro 年付用户看 Pro 年付: "续费 Pro 专业版"
|
||||
- Pro 用户看 Max 年付: "选择 Max 旗舰版"
|
||||
|
||||
---
|
||||
|
||||
## 📊 价格配置示例
|
||||
|
||||
### Pro 专业版价格设定
|
||||
|
||||
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|
||||
|---------|------|------|------|---------|
|
||||
| 月付 | ¥299 | - | - | ¥299 |
|
||||
| 季付(3个月) | ¥799 | ¥897 | 11% | ¥266 |
|
||||
| 半年付(6个月) | ¥1499 | ¥1794 | 16% | ¥250 |
|
||||
| 年付(12个月) | ¥2699 | ¥3588 | 25% | ¥225 |
|
||||
|
||||
### Max 旗舰版价格设定
|
||||
|
||||
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|
||||
|---------|------|------|------|---------|
|
||||
| 月付 | ¥599 | - | - | ¥599 |
|
||||
| 季付(3个月) | ¥1599 | ¥1797 | 11% | ¥533 |
|
||||
| 半年付(6个月) | ¥2999 | ¥3594 | 17% | ¥500 |
|
||||
| 年付(12个月) | ¥5399 | ¥7188 | 25% | ¥450 |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 迁移方案
|
||||
|
||||
### 数据迁移 SQL
|
||||
|
||||
参见 `database_migration.sql`
|
||||
|
||||
### 代码迁移步骤
|
||||
|
||||
1. **备份现有数据库**
|
||||
2. **执行数据库迁移 SQL**
|
||||
3. **更新数据库模型** (`models.py`)
|
||||
4. **更新价格计算逻辑** (`calculate_price.py`)
|
||||
5. **更新 API 路由** (`routes.py`)
|
||||
6. **更新前端组件** (`SubscriptionContentNew.tsx`)
|
||||
7. **测试完整流程**
|
||||
8. **灰度发布**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 优势总结
|
||||
|
||||
### 相比旧系统的改进
|
||||
|
||||
1. **价格透明** - 续费用户和新用户价格完全一致
|
||||
2. **逻辑简化** - 不再计算剩余价值,代码减少 50%+
|
||||
3. **易于理解** - 用户体验更清晰
|
||||
4. **灵活扩展** - 轻松添加新的计费周期
|
||||
5. **历史追溯** - 完整的订阅历史记录
|
||||
6. **数据完整** - 每次支付都有完整的记录
|
||||
|
||||
### 用户体验改进
|
||||
|
||||
1. **按钮文案清晰** - "续费 Pro"/"选择 Pro"明确表达意图
|
||||
2. **价格一致性** - 所有用户看到的价格都一样
|
||||
3. **无隐藏费用** - 不会因为"升级折算"产生困惑
|
||||
4. **透明计费** - 支付金额 = 显示价格 - 优惠码折扣
|
||||
|
||||
---
|
||||
|
||||
## 📝 后续优化建议
|
||||
|
||||
1. **自动续费** - 到期前自动扣款续费
|
||||
2. **订阅提醒** - 到期前 7 天、3 天、1 天发送通知
|
||||
3. **订阅暂停** - 允许用户暂停订阅
|
||||
4. **订阅降级** - 从 Max 降级到 Pro(当前周期结束后生效)
|
||||
5. **发票管理** - 支持开具电子发票
|
||||
6. **支付方式扩展** - 支持支付宝、银行卡等
|
||||
|
||||
---
|
||||
|
||||
**设计时间**: 2025-11-19
|
||||
**设计者**: Claude Code
|
||||
**版本**: v2.0.0
|
||||
@@ -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,338 +0,0 @@
|
||||
# 崩溃修复测试指南
|
||||
|
||||
> 测试时间:2025-10-14
|
||||
> 测试范围:SignInIllustration.js + SignUpIllustration.js
|
||||
> 服务器地址:http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证以下修复是否有效:
|
||||
- ✅ 响应对象崩溃(6处)
|
||||
- ✅ 组件卸载后 setState(6处)
|
||||
- ✅ 定时器内存泄漏(2处)
|
||||
|
||||
---
|
||||
|
||||
## 📋 测试清单
|
||||
|
||||
### ✅ 关键测试(必做)
|
||||
|
||||
#### 1. **网络异常测试** - 验证响应对象修复
|
||||
|
||||
**登录页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-in
|
||||
2. 切换到"验证码登录"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 打开浏览器开发者工具 (F12) → Network 标签
|
||||
5. 点击 Offline 模拟断网
|
||||
6. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送验证码失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
|
||||
修复前:
|
||||
❌ 页面白屏崩溃
|
||||
❌ Console 报错:Cannot read property 'json' of null
|
||||
```
|
||||
|
||||
**登录页面 - 微信登录**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面,保持断网状态
|
||||
2. 点击"扫码登录"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"获取微信授权失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
```
|
||||
|
||||
**注册页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-up
|
||||
2. 切换到"验证码注册"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 保持断网状态
|
||||
5. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送失败 - 网络请求失败..."
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. **组件卸载测试** - 验证内存泄漏修复
|
||||
|
||||
**倒计时中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 恢复网络连接
|
||||
2. 在登录页面输入手机号并发送验证码
|
||||
3. 等待倒计时开始(60秒倒计时)
|
||||
4. 立即点击浏览器后退按钮或切换到其他页面
|
||||
5. 打开 Console 查看是否有警告
|
||||
|
||||
预期结果:
|
||||
✅ 无警告:"Can't perform a React state update on an unmounted component"
|
||||
✅ 倒计时定时器正确清理
|
||||
✅ 无内存泄漏
|
||||
|
||||
修复前:
|
||||
❌ Console 警告:Memory leak warning
|
||||
❌ setState 在组件卸载后仍被调用
|
||||
```
|
||||
|
||||
**请求进行中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在注册页面填写完整信息
|
||||
2. 点击"注册"按钮
|
||||
3. 在请求响应前(loading 状态)快速刷新页面或关闭标签页
|
||||
4. 打开新标签页查看 Console
|
||||
|
||||
预期结果:
|
||||
✅ 无崩溃
|
||||
✅ 无警告信息
|
||||
✅ 请求被正确取消或忽略
|
||||
```
|
||||
|
||||
**注册成功跳转前离开**
|
||||
```
|
||||
测试步骤:
|
||||
1. 完成注册提交
|
||||
2. 在显示"注册成功"提示后
|
||||
3. 立即关闭标签页(不等待2秒自动跳转)
|
||||
|
||||
预期结果:
|
||||
✅ 无警告
|
||||
✅ navigate 不会在组件卸载后执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. **边界情况测试** - 验证数据完整性检查
|
||||
|
||||
**后端返回空响应**
|
||||
```
|
||||
测试步骤(需要模拟后端):
|
||||
1. 使用 Chrome DevTools → Network → 右键请求 → Edit and Resend
|
||||
2. 修改响应为空对象 {}
|
||||
3. 观察页面反应
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"服务器响应为空"
|
||||
✅ 不会尝试访问 undefined 属性
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
**后端返回 500 错误**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面点击"扫码登录"
|
||||
2. 如果后端返回 500 错误
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"获取二维码失败:HTTP 500"
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🧪 进阶测试(推荐)
|
||||
|
||||
#### 4. **弱网环境测试**
|
||||
|
||||
**慢速网络模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. Chrome DevTools → Network → Throttling → Slow 3G
|
||||
2. 尝试发送验证码
|
||||
3. 等待 10 秒(超时时间)
|
||||
|
||||
预期结果:
|
||||
✅ 10秒后显示超时错误
|
||||
✅ 不会无限等待
|
||||
✅ 用户可以重试
|
||||
```
|
||||
|
||||
**丢包模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. 使用 Chrome DevTools 模拟丢包
|
||||
2. 连续点击"发送验证码"多次
|
||||
|
||||
预期结果:
|
||||
✅ 每次请求都有适当的错误提示
|
||||
✅ 不会因为并发请求而崩溃
|
||||
✅ 按钮在请求期间正确禁用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. **定时器清理测试**
|
||||
|
||||
**倒计时清理验证**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面发送验证码
|
||||
2. 等待倒计时到 50 秒
|
||||
3. 快速切换到注册页面
|
||||
4. 再切换回登录页面
|
||||
5. 观察倒计时是否重置
|
||||
|
||||
预期结果:
|
||||
✅ 定时器在页面切换时正确清理
|
||||
✅ 返回登录页面时倒计时重新开始(如果再次发送)
|
||||
✅ 没有多个定时器同时运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. **并发请求测试**
|
||||
|
||||
**快速连续点击**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面输入手机号
|
||||
2. 快速连续点击"发送验证码"按钮 5 次
|
||||
|
||||
预期结果:
|
||||
✅ 只发送一次请求(按钮在请求期间禁用)
|
||||
✅ 不会因为并发而崩溃
|
||||
✅ 正确显示 loading 状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 监控指标
|
||||
|
||||
### Console 检查清单
|
||||
|
||||
在测试过程中,打开 Console (F12) 监控以下内容:
|
||||
|
||||
```
|
||||
✅ 无红色错误(Error)
|
||||
✅ 无内存泄漏警告(Memory leak warning)
|
||||
✅ 无 setState 警告(Can't perform a React state update...)
|
||||
✅ 无 undefined 访问错误(Cannot read property of undefined)
|
||||
```
|
||||
|
||||
### Network 检查清单
|
||||
|
||||
打开 Network 标签监控:
|
||||
|
||||
```
|
||||
✅ 请求超时时间:10秒
|
||||
✅ 失败请求有正确的错误处理
|
||||
✅ 没有重复的请求
|
||||
✅ 请求被正确取消(如果页面卸载)
|
||||
```
|
||||
|
||||
### Performance 检查清单
|
||||
|
||||
打开 Performance 标签(可选):
|
||||
|
||||
```
|
||||
✅ 无内存泄漏(Memory 不会持续增长)
|
||||
✅ 定时器正确清理(Timer count 正确)
|
||||
✅ EventListener 正确清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试记录表
|
||||
|
||||
请在测试时填写以下表格:
|
||||
|
||||
| 测试项 | 状态 | 问题描述 | 截图 |
|
||||
|--------|------|---------|------|
|
||||
| 登录页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 登录页 - 断网微信登录 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 倒计时中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 请求进行中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册成功跳转前离开 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 后端返回空响应 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 慢速网络超时 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 定时器清理 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 并发请求 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 如何报告问题
|
||||
|
||||
如果发现问题,请提供:
|
||||
|
||||
1. **测试场景**:具体的测试步骤
|
||||
2. **预期结果**:应该发生什么
|
||||
3. **实际结果**:实际发生了什么
|
||||
4. **Console 错误**:完整的错误信息
|
||||
5. **截图/录屏**:问题的视觉证明
|
||||
6. **环境信息**:
|
||||
- 浏览器版本
|
||||
- 操作系统
|
||||
- 网络状态
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试完成检查
|
||||
|
||||
测试完成后,确认以下内容:
|
||||
|
||||
```
|
||||
□ 所有关键测试通过
|
||||
□ Console 无错误
|
||||
□ Network 请求正常
|
||||
□ 无内存泄漏警告
|
||||
□ 用户体验流畅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速测试命令
|
||||
|
||||
```bash
|
||||
# 1. 确认服务器运行
|
||||
curl http://localhost:3000
|
||||
|
||||
# 2. 打开浏览器测试
|
||||
open http://localhost:3000/auth/sign-in
|
||||
|
||||
# 3. 查看编译日志
|
||||
tail -f /tmp/react-build.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 测试页面链接
|
||||
|
||||
- **登录页面**: http://localhost:3000/auth/sign-in
|
||||
- **注册页面**: http://localhost:3000/auth/sign-up
|
||||
- **首页**: http://localhost:3000/home
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发者工具快捷键
|
||||
|
||||
```
|
||||
F12 - 打开开发者工具
|
||||
Ctrl/Cmd+R - 刷新页面
|
||||
Ctrl/Cmd+Shift+R - 强制刷新(清除缓存)
|
||||
Ctrl/Cmd+Shift+C - 元素选择器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**测试时间**:2025-10-14
|
||||
**预计测试时长**:15-30 分钟
|
||||
**建议测试人员**:开发者 + QA
|
||||
|
||||
祝测试顺利!如发现问题请及时反馈。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,19 +55,40 @@ HTTP_CLIENT = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
# ==================== Agent系统配置 ====================
|
||||
|
||||
# Kimi 配置 - 用于计划制定和深度推理
|
||||
KIMI_CONFIG = {
|
||||
"api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "kimi-k2-thinking", # 思考模型
|
||||
# ==================== 多模型配置 ====================
|
||||
|
||||
# 模型配置字典(支持动态切换)
|
||||
MODEL_CONFIGS = {
|
||||
"kimi-k2": {
|
||||
"api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "moonshot-v1-8k", # 快速模型
|
||||
},
|
||||
"kimi-k2-thinking": {
|
||||
"api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "kimi-k2-thinking", # 深度思考模型
|
||||
},
|
||||
"glm-4.6": {
|
||||
"api_key": "", # 需要配置智谱AI密钥
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"model": "glm-4",
|
||||
},
|
||||
"deepmoney": {
|
||||
"api_key": "", # 空值
|
||||
"base_url": "http://111.62.35.50:8000/v1",
|
||||
"model": "deepmoney",
|
||||
},
|
||||
"gemini-3": {
|
||||
"api_key": "", # 需要配置Google API密钥
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1",
|
||||
"model": "gemini-pro",
|
||||
},
|
||||
}
|
||||
|
||||
# DeepMoney 配置 - 用于新闻总结
|
||||
DEEPMONEY_CONFIG = {
|
||||
"api_key": "", # 空值
|
||||
"base_url": "http://111.62.35.50:8000/v1",
|
||||
"model": "deepmoney",
|
||||
}
|
||||
# 保持向后兼容的配置(默认使用 kimi-k2-thinking)
|
||||
KIMI_CONFIG = MODEL_CONFIGS["kimi-k2-thinking"]
|
||||
DEEPMONEY_CONFIG = MODEL_CONFIGS["deepmoney"]
|
||||
|
||||
# ==================== MCP协议数据模型 ====================
|
||||
|
||||
@@ -143,6 +164,8 @@ class AgentChatRequest(BaseModel):
|
||||
user_avatar: Optional[str] = None # 用户头像URL
|
||||
subscription_type: Optional[str] = None # 用户订阅类型(free/pro/max)
|
||||
session_id: Optional[str] = None # 会话ID(如果为空则创建新会话)
|
||||
model: Optional[str] = "kimi-k2-thinking" # 选择的模型(kimi-k2, kimi-k2-thinking, glm-4.6, deepmoney, gemini-3)
|
||||
tools: Optional[List[str]] = None # 选择的工具列表(工具名称数组,如果为None则使用全部工具)
|
||||
|
||||
# ==================== MCP工具定义 ====================
|
||||
|
||||
@@ -1579,6 +1602,7 @@ class MCPAgentIntegrated:
|
||||
user_nickname: str = None,
|
||||
user_avatar: str = None,
|
||||
cookies: dict = None,
|
||||
model_config: dict = None, # 新增:动态模型配置
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""主流程(流式输出)- 逐步返回执行结果"""
|
||||
logger.info(f"[Agent Stream] 处理查询: {user_query}")
|
||||
@@ -1586,11 +1610,24 @@ class MCPAgentIntegrated:
|
||||
# 将 cookies 存储为实例属性,供工具调用时使用
|
||||
self.cookies = cookies or {}
|
||||
|
||||
# 如果传入了自定义模型配置,使用自定义配置,否则使用默认的 Kimi
|
||||
if model_config:
|
||||
planning_client = OpenAI(
|
||||
api_key=model_config["api_key"],
|
||||
base_url=model_config["base_url"],
|
||||
)
|
||||
planning_model = model_config["model"]
|
||||
logger.info(f"[Agent Stream] 使用自定义模型: {planning_model}")
|
||||
else:
|
||||
planning_client = self.kimi_client
|
||||
planning_model = self.kimi_model
|
||||
logger.info(f"[Agent Stream] 使用默认模型: {planning_model}")
|
||||
|
||||
try:
|
||||
# 发送开始事件
|
||||
yield self._format_sse("status", {"stage": "start", "message": "开始处理查询"})
|
||||
|
||||
# 阶段1: Kimi 制定计划(流式,带 DeepMoney 备选)
|
||||
# 阶段1: 使用选中的模型制定计划(流式,带 DeepMoney 备选)
|
||||
yield self._format_sse("status", {"stage": "planning", "message": "正在制定执行计划..."})
|
||||
|
||||
messages = [
|
||||
@@ -1603,9 +1640,9 @@ class MCPAgentIntegrated:
|
||||
use_fallback = False
|
||||
|
||||
try:
|
||||
# 尝试使用 Kimi 流式 API
|
||||
stream = self.kimi_client.chat.completions.create(
|
||||
model=self.kimi_model,
|
||||
# 尝试使用选中的模型流式 API
|
||||
stream = planning_client.chat.completions.create(
|
||||
model=planning_model,
|
||||
messages=messages,
|
||||
temperature=1.0,
|
||||
max_tokens=16000,
|
||||
@@ -2165,11 +2202,12 @@ async def agent_chat_stream(chat_request: AgentChatRequest, request: Request):
|
||||
except Exception as e:
|
||||
logger.error(f"[ES] 保存用户消息失败: {e}")
|
||||
|
||||
# 获取工具列表
|
||||
tools = [tool.dict() for tool in TOOLS]
|
||||
# ==================== 动态工具过滤 ====================
|
||||
# 获取所有可用工具
|
||||
all_tools = [tool.dict() for tool in TOOLS]
|
||||
|
||||
# 添加特殊工具:summarize_news
|
||||
tools.append({
|
||||
all_tools.append({
|
||||
"name": "summarize_news",
|
||||
"description": "使用 DeepMoney 模型总结新闻数据,提取关键信息",
|
||||
"parameters": {
|
||||
@@ -2188,6 +2226,21 @@ async def agent_chat_stream(chat_request: AgentChatRequest, request: Request):
|
||||
}
|
||||
})
|
||||
|
||||
# 如果用户指定了工具列表,则进行过滤
|
||||
if chat_request.tools is not None and len(chat_request.tools) > 0:
|
||||
selected_tool_names = set(chat_request.tools)
|
||||
tools = [tool for tool in all_tools if tool["name"] in selected_tool_names]
|
||||
logger.info(f"[工具过滤] 用户选择了 {len(tools)}/{len(all_tools)} 个工具: {selected_tool_names}")
|
||||
else:
|
||||
# 默认使用全部工具
|
||||
tools = all_tools
|
||||
logger.info(f"[工具过滤] 使用全部 {len(tools)} 个工具")
|
||||
|
||||
# ==================== 动态模型选择 ====================
|
||||
selected_model = chat_request.model or "kimi-k2-thinking"
|
||||
model_config = MODEL_CONFIGS.get(selected_model, MODEL_CONFIGS["kimi-k2-thinking"])
|
||||
logger.info(f"[模型选择] 使用模型: {selected_model} ({model_config['model']})")
|
||||
|
||||
# 返回流式响应
|
||||
return StreamingResponse(
|
||||
agent.process_query_stream(
|
||||
@@ -2199,6 +2252,7 @@ async def agent_chat_stream(chat_request: AgentChatRequest, request: Request):
|
||||
user_nickname=chat_request.user_nickname,
|
||||
user_avatar=chat_request.user_avatar,
|
||||
cookies=cookies, # 传递 cookies 用于认证 API 调用
|
||||
model_config=model_config, # 传递选中的模型配置
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
|
||||
58
package.json
58
package.json
@@ -5,59 +5,59 @@
|
||||
"homepage": "/",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@asseinfo/react-kanban": "^2.2.0",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/theme-tools": "^1.3.6",
|
||||
"@chakra-ui/icons": "^2.2.6",
|
||||
"@chakra-ui/react": "^2.10.9",
|
||||
"@chakra-ui/theme-tools": "^2.2.6",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@fontsource/open-sans": "^4.5.0",
|
||||
"@fontsource/raleway": "^4.5.0",
|
||||
"@fontsource/roboto": "^4.5.0",
|
||||
"@fullcalendar/daygrid": "^5.9.0",
|
||||
"@fullcalendar/interaction": "^5.9.0",
|
||||
"@fullcalendar/react": "^5.9.0",
|
||||
"@fullcalendar/core": "^6.1.19",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
"@fullcalendar/interaction": "^6.1.19",
|
||||
"@fullcalendar/react": "^6.1.19",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"@visx/scale": "^3.12.0",
|
||||
"@visx/text": "^3.12.0",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"@visx/wordcloud": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
"apexcharts": "^3.27.3",
|
||||
"axios": "^1.10.0",
|
||||
"classnames": "^2.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.23.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"draft-js": "^0.11.7",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"framer-motion": "^12.23.24",
|
||||
"fullcalendar": "^5.9.0",
|
||||
"globalize": "^1.7.0",
|
||||
"history": "^5.3.0",
|
||||
"klinecharts": "^10.0.0-beta1",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"react": "18.3.1",
|
||||
"posthog-js": "^1.295.0",
|
||||
"react": "^19.0.0",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
"react-bootstrap-sweetalert": "5.2.0",
|
||||
"react-circular-slider-svg": "^0.1.5",
|
||||
"react-custom-scrollbars-2": "^4.4.0",
|
||||
"react-datetime": "^3.0.4",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^11.4.2",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-github-btn": "^1.2.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-input-pin-code": "^1.1.5",
|
||||
"react-is": "^19.0.0",
|
||||
"react-just-parallax": "^3.1.16",
|
||||
"react-jvectormap": "0.0.16",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-quill": "^2.0.0-beta.4",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
@@ -65,29 +65,27 @@
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-scroll": "^1.8.4",
|
||||
"react-scroll-into-view": "^2.1.3",
|
||||
"react-swipeable-views": "0.13.9",
|
||||
"react-table": "^7.7.0",
|
||||
"react-tagsinput": "3.19.0",
|
||||
"react-to-print": "^2.13.0",
|
||||
"react-to-print": "^3.0.3",
|
||||
"react-tsparticles": "^2.12.2",
|
||||
"react-wordcloud": "^1.2.7",
|
||||
"recharts": "^3.1.2",
|
||||
"sass": "^1.49.9",
|
||||
"scroll-lock": "^2.1.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"styled-components": "^5.3.11",
|
||||
"stylis": "^4.0.10",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"tsparticles-slim": "^2.12.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-error-overlay": "6.0.9",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0"
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^9.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prestart": "kill-port 3000",
|
||||
@@ -100,7 +98,7 @@
|
||||
"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": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' TSC_COMPILE_ON_ERROR=true DISABLE_ESLINT_PLUGIN=true 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",
|
||||
"test": "craco test --env=jsdom",
|
||||
"eject": "react-scripts eject",
|
||||
@@ -117,12 +115,11 @@
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"ajv": "^8.17.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^8.2.2",
|
||||
"env-cmd": "^11.0.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
@@ -134,7 +131,6 @@
|
||||
"imagemin-pngquant": "^10.0.0",
|
||||
"kill-port": "^2.0.1",
|
||||
"msw": "^2.11.5",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "2.2.1",
|
||||
"react-error-overlay": "6.0.9",
|
||||
"sharp": "^0.34.4",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
}
|
||||
Binary file not shown.
BIN
public/LOGO_badge.png
Normal file
BIN
public/LOGO_badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
736
public/htmls/券商合并预期.html
Normal file
736
public/htmls/券商合并预期.html
Normal file
@@ -0,0 +1,736 @@
|
||||
我将为您创建一个关于券商合并预期概念的现代化HTML页面,融合金融专业性和视觉美感。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>券商合并预期 - 打造金融国家队</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(99, 102, 241, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.stock-row:hover {
|
||||
background: linear-gradient(90deg, rgba(99, 102, 241, 0.1) 0%, rgba(168, 85, 247, 0.1) 100%);
|
||||
transform: translateX(5px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge-glow {
|
||||
box-shadow: 0 0 10px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<div class="navbar glass-effect sticky top-0 z-50 px-4 py-3">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl font-bold gradient-text">券商合并预期</a>
|
||||
</div>
|
||||
<div class="flex-none gap-2">
|
||||
<button class="btn btn-ghost btn-circle">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-circle">
|
||||
<i class="fas fa-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[60vh] glass-effect mx-4 mt-4 rounded-3xl" style="background-image: url('https://picsum.photos/seed/finance-merge/1920/800');">
|
||||
<div class="hero-overlay bg-opacity-60 rounded-3xl"></div>
|
||||
<div class="hero-content text-center text-white">
|
||||
<div class="max-w-4xl">
|
||||
<h1 class="mb-5 text-5xl font-bold animate-fade-in">
|
||||
打造金融国家队
|
||||
</h1>
|
||||
<p class="mb-5 text-xl">
|
||||
券商合并预期 - 从政策推动到价值重塑的三阶段演进
|
||||
</p>
|
||||
<div class="flex justify-center gap-4 mb-8">
|
||||
<div class="stat glass-effect rounded-lg px-6 py-4">
|
||||
<div class="stat-value text-2xl">2-3家</div>
|
||||
<div class="stat-desc">2035年国际投行目标</div>
|
||||
</div>
|
||||
<div class="stat glass-effect rounded-lg px-6 py-4">
|
||||
<div class="stat-value text-2xl">10家</div>
|
||||
<div class="stat-desc">2025年优质机构目标</div>
|
||||
</div>
|
||||
<div class="stat glass-effect rounded-lg px-6 py-4">
|
||||
<div class="stat-value text-2xl">50%↑</div>
|
||||
<div class="stat-desc">CR5资产占比目标</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg" onclick="scrollToSection('timeline')">
|
||||
探索政策时间轴 <i class="fas fa-arrow-down ml-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 政策时间轴 -->
|
||||
<section id="timeline" class="mb-12">
|
||||
<h2 class="text-3xl font-bold mb-8 text-white text-center">政策演进时间轴</h2>
|
||||
<div class="timeline glass-effect rounded-2xl p-8">
|
||||
<div class="relative">
|
||||
<div class="timeline-line absolute left-8 top-0 bottom-0 w-1"></div>
|
||||
|
||||
<div class="mb-8 flex items-center">
|
||||
<div class="timeline-dot w-4 h-4 bg-indigo-600 rounded-full absolute left-6"></div>
|
||||
<div class="ml-16 glass-effect rounded-lg p-4 card-hover">
|
||||
<div class="badge badge-primary badge-glow">2023年10月</div>
|
||||
<h3 class="font-bold text-lg mt-2">中央金融工作会议</h3>
|
||||
<p>首次提出"金融强国"目标,明确支持国有大型金融机构做优做强</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex items-center">
|
||||
<div class="timeline-dot w-4 h-4 bg-purple-600 rounded-full absolute left-6"></div>
|
||||
<div class="ml-16 glass-effect rounded-lg p-4 card-hover">
|
||||
<div class="badge badge-secondary badge-glow">2024年3月</div>
|
||||
<h3 class="font-bold text-lg mt-2">证监会指导意见</h3>
|
||||
<p>明确5年内形成10家优质头部机构,2035年形成2-3家国际投行</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex items-center">
|
||||
<div class="timeline-dot w-4 h-4 bg-pink-600 rounded-full absolute left-6"></div>
|
||||
<div class="ml-16 glass-effect rounded-lg p-4 card-hover">
|
||||
<div class="badge badge-accent badge-glow">2024年4月</div>
|
||||
<h3 class="font-bold text-lg mt-2">新"国九条"</h3>
|
||||
<p>国务院层面首次支持投行通过并购重组提升核心竞争力</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex items-center">
|
||||
<div class="timeline-dot w-4 h-4 bg-green-600 rounded-full absolute left-6"></div>
|
||||
<div class="ml-16 glass-effect rounded-lg p-4 card-hover">
|
||||
<div class="badge badge-success badge-glow">2024年9月</div>
|
||||
<h3 class="font-bold text-lg mt-2">国泰君安+海通证券</h3>
|
||||
<p>新"国九条"后首例头部券商合并,总资产1.62万亿</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 flex items-center">
|
||||
<div class="timeline-dot w-4 h-4 bg-yellow-600 rounded-full absolute left-6"></div>
|
||||
<div class="ml-16 glass-effect rounded-lg p-4 card-hover">
|
||||
<div class="badge badge-warning badge-glow">2025年11月</div>
|
||||
<h3 class="font-bold text-lg mt-2">中金公司"一对二"合并</h3>
|
||||
<p>一次性合并东兴、信达证券,总资产达1万亿,超出市场预期</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心逻辑与市场认知 -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-bold mb-8 text-white text-center">核心逻辑与预期差</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="glass-effect rounded-2xl p-6 card-hover">
|
||||
<div class="text-4xl mb-4">🏛️</div>
|
||||
<h3 class="text-xl font-bold mb-3">政策强制力</h3>
|
||||
<p class="text-gray-700">不同于以往市场化并购,本轮是"只许成功,不许失败"的国家战略,行政推动力度空前</p>
|
||||
<div class="mt-4">
|
||||
<div class="badge badge-info">顶层设计</div>
|
||||
<div class="badge badge-error">硬性约束</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6 card-hover">
|
||||
<div class="text-4xl mb-4">📉</div>
|
||||
<h3 class="text-xl font-bold mb-3">行业生存压力</h3>
|
||||
<p class="text-gray-700">经纪业务佣金率从8‱降至不足1‱,IPO收紧、再融资停滞,中小券商面临生存危机</p>
|
||||
<div class="mt-4">
|
||||
<div class="badge badge-warning">盈亏平衡</div>
|
||||
<div class="badge badge-secondary">供给扭转</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6 card-hover">
|
||||
<div class="text-4xl mb-4">💹</div>
|
||||
<h3 class="text-xl font-bold mb-3">估值修复空间</h3>
|
||||
<p class="text-gray-700">券商板块PB处于历史1.18%分位,国泰君安PB仅0.88倍,较中信存在100%修复空间</p>
|
||||
<div class="mt-4">
|
||||
<div class="badge badge-success">历史底部</div>
|
||||
<div class="badge badge-primary">机构低配</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 关键催化剂 -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-bold mb-8 text-white text-center">关键催化剂</h2>
|
||||
<div class="glass-effect rounded-2xl p-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl p-6 text-white">
|
||||
<i class="fas fa-calendar-alt text-3xl mb-4"></i>
|
||||
<h3 class="text-xl font-bold mb-2">2025年12月-2026年1月</h3>
|
||||
<p>国泰君安/海通合并后首份协同效应数据披露,验证"1+1"增量价值</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl p-6 text-white">
|
||||
<i class="fas fa-chart-pie text-3xl mb-4"></i>
|
||||
<h3 class="text-xl font-bold mb-2">2026年Q1</h3>
|
||||
<p>中金公司吸收东兴、信达证券方案公布,汇金系整合路径明朗</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-green-500 to-teal-500 rounded-xl p-6 text-white">
|
||||
<i class="fas fa-landmark text-3xl mb-4"></i>
|
||||
<h3 class="text-xl font-bold mb-2">2026年3月</h3>
|
||||
<p>证监会《一流投行建设意见》中期评估,出台更具体鼓励政策</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-orange-500 to-red-500 rounded-xl p-6 text-white">
|
||||
<i class="fas fa-city text-3xl mb-4"></i>
|
||||
<h3 class="text-xl font-bold mb-2">2026年Q2</h3>
|
||||
<p>地方两会明确券商整合时间表,浙江系、深圳系后续动作</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 股票数据表格 -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-bold mb-8 text-white text-center">券商股权结构全景图</h2>
|
||||
<div class="glass-effect rounded-2xl p-6 overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr class="bg-gradient-to-r from-indigo-500 to-purple-500 text-white">
|
||||
<th>券商名称</th>
|
||||
<th>分类</th>
|
||||
<th>实际控制人/第一大股东</th>
|
||||
<th>持股比例</th>
|
||||
<th>消息来源</th>
|
||||
<th>合并逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">申万宏源</td>
|
||||
<td><span class="badge badge-primary">汇金系</span></td>
|
||||
<td>中央汇金投资有限责任公司</td>
|
||||
<td>49.70%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人中央汇金投资有限责任公司持股49.70%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">中国银河</td>
|
||||
<td><span class="badge badge-primary">汇金系</span></td>
|
||||
<td>中央汇金投资有限责任公司</td>
|
||||
<td>32.76%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人中央汇金投资有限责任公司持股32.76%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">东兴证券</td>
|
||||
<td><span class="badge badge-secondary">财政系</span></td>
|
||||
<td>国务院国资委</td>
|
||||
<td>32.28%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人国务院国资委持股32.28%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">信达证券</td>
|
||||
<td><span class="badge badge-secondary">财政系</span></td>
|
||||
<td>国务院</td>
|
||||
<td>45.63%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人国务院持股45.63%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">中信证券</td>
|
||||
<td><span class="badge badge-info">中信系</span></td>
|
||||
<td>中国中信金融控股有限公司</td>
|
||||
<td>18.45%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">第一大股东中国中信金融控股有限公司持股18.45%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">中金财富证券</td>
|
||||
<td><span class="badge badge-info">中信系</span></td>
|
||||
<td>北京金融控股集团有限公司</td>
|
||||
<td>35.81%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">第一大股东北京金融控股集团有限公司持股35.81%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">方正证券</td>
|
||||
<td><span class="badge badge-warning">平安系</span></td>
|
||||
<td>新方正控股发展有限责任公司</td>
|
||||
<td>28.71%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">新方正控股发展有限责任公司持股28.71%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">平安证券</td>
|
||||
<td><span class="badge badge-warning">平安系</span></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">未上市</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">华安证券</td>
|
||||
<td><span class="badge badge-success">安徽系</span></td>
|
||||
<td>安徽省国资委</td>
|
||||
<td>32.97%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人安徽省国资委持股32.97%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">国元证券</td>
|
||||
<td><span class="badge badge-success">安徽系</span></td>
|
||||
<td>安徽省国资委</td>
|
||||
<td>28.45%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人安徽省国资委持股28.45%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">浙商证券</td>
|
||||
<td><span class="badge badge-accent">浙江系</span></td>
|
||||
<td>浙江交投投资集团</td>
|
||||
<td>26.38%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人浙江交投投资集团持股26.38%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">财通证券</td>
|
||||
<td><span class="badge badge-accent">浙江系</span></td>
|
||||
<td>浙江省财政厅</td>
|
||||
<td>32.40%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人浙江省财政厅持股32.40%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">华鑫股份</td>
|
||||
<td><span class="badge badge-error">上海系</span></td>
|
||||
<td>上海市国资委</td>
|
||||
<td>55.26%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人上海市国资委持股55.26%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">国泰君安</td>
|
||||
<td><span class="badge badge-error">上海系</span></td>
|
||||
<td>上海国际集团有限公司</td>
|
||||
<td>18.83%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人上海国际集团有限公司持股18.83%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">华泰证券</td>
|
||||
<td><span class="badge badge-info">江苏系</span></td>
|
||||
<td>江苏省国资委</td>
|
||||
<td>28.59%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人江苏省国资委持股28.59%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">东吴证券</td>
|
||||
<td><span class="badge badge-info">江苏系</span></td>
|
||||
<td>苏州市国资委</td>
|
||||
<td>27.80%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人苏州市国资委持股27.80%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">南京证券</td>
|
||||
<td><span class="badge badge-info">江苏系</span></td>
|
||||
<td>南京市国资委</td>
|
||||
<td>28.90%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人南京市国资委持股28.90%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">国联民生</td>
|
||||
<td><span class="badge badge-info">江苏系</span></td>
|
||||
<td>无锡市国资委</td>
|
||||
<td>35.12%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人无锡市国资委持股35.12%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">天风证券</td>
|
||||
<td><span class="badge badge-warning">湖北系</span></td>
|
||||
<td>湖北省财政厅</td>
|
||||
<td>28.14%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人湖北省财政厅持股28.14%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">长江证券</td>
|
||||
<td><span class="badge badge-warning">湖北系</span></td>
|
||||
<td>长江产业投资集团有限公司</td>
|
||||
<td>17.41%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">第一大股东长江产业投资集团有限公司持股17.41%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">西南证券</td>
|
||||
<td><span class="badge badge-secondary">川渝系</span></td>
|
||||
<td>重庆市国资委</td>
|
||||
<td>31.12%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人重庆市国资委持股31.12%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">华西证券</td>
|
||||
<td><span class="badge badge-secondary">川渝系</span></td>
|
||||
<td>泸州市国资委</td>
|
||||
<td>21.07%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人泸州市国资委持股21.07%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">第一创业</td>
|
||||
<td><span class="badge badge-primary">北京系</span></td>
|
||||
<td>北京国有资本运营管理有限公司</td>
|
||||
<td>11.06%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">第一大股东北京国有资本运营管理有限公司持股11.06%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">首创证券</td>
|
||||
<td><span class="badge badge-primary">北京系</span></td>
|
||||
<td>北京市国资委</td>
|
||||
<td>82.39%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人北京市国资委持股82.39%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">兴业证券</td>
|
||||
<td><span class="badge badge-success">福建系</span></td>
|
||||
<td>福建省财政厅</td>
|
||||
<td>20.49%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人福建省财政厅持股20.49%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">华福证券</td>
|
||||
<td><span class="badge badge-success">福建系</span></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">未上市</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">锦龙股份</td>
|
||||
<td><span class="badge badge-accent">东莞系</span></td>
|
||||
<td>杨志茂</td>
|
||||
<td>18.80%</td>
|
||||
<td>年报</td>
|
||||
<td class="text-sm">实际控制人杨志茂持股18.80%</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">东莞证券</td>
|
||||
<td><span class="badge badge-accent">东莞系</span></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>公开资料</td>
|
||||
<td class="text-sm">2024年9月23日锦龙股份董事会通过转让东莞证券20%股份事项</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">中山证券</td>
|
||||
<td><span class="badge badge-accent">东莞系</span></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>公开资料</td>
|
||||
<td class="text-sm">锦龙股份持有中山证券67.78%股权</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">西部证券</td>
|
||||
<td><span class="badge badge-error">最新推进</span></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>公开资料</td>
|
||||
<td class="text-sm">西部证券在2025年9月成功控股国融证券</td>
|
||||
</tr>
|
||||
<tr class="stock-row">
|
||||
<td class="font-bold">国信证券</td>
|
||||
<td><span class="badge badge-error">最新推进</span></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>公开资料</td>
|
||||
<td class="text-sm">2025年8月21日证监会核准国信证券成为万和证券主要股东</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投资建议 -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-bold mb-8 text-white text-center">投资策略与风险提示</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<h3 class="text-2xl font-bold mb-4 gradient-text">最具投资价值方向</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<div>
|
||||
<h4 class="font-bold">头部券商A+H股套利</h4>
|
||||
<p class="text-sm">国泰君安H股、中信证券H股,港股折价50%,存在30-40%套利空间</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<div>
|
||||
<h4 class="font-bold">地方国资"唯一牌照"</h4>
|
||||
<p class="text-sm">首创证券、东吴证券,地方国资做强动力,市值小并购期权未充分定价</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-laptop-code"></i>
|
||||
<div>
|
||||
<h4 class="font-bold">金融IT真受益标的</h4>
|
||||
<p class="text-sm">财富趋势、指南针,C端流量平台,合并后获客成本下降20%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<h3 class="text-2xl font-bold mb-4 gradient-text">核心跟踪指标</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标类别</th>
|
||||
<th>触发条件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>审批速度</td>
|
||||
<td>受理到批复合计<90天</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ROE提升</td>
|
||||
<td>国泰君安+海通ROE>7.5%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>市场情绪</td>
|
||||
<td>成交额占比>5%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>估值修复</td>
|
||||
<td>PB回升至分位30%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>人才稳定</td>
|
||||
<td>核心人员流失率<10%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 风险矩阵 -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-bold mb-8 text-white text-center">风险矩阵</h2>
|
||||
<div class="glass-effect rounded-2xl p-8">
|
||||
<canvas id="riskChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="glass-effect mt-12 py-8">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<p class="text-gray-600">© 2024 券商合并预期分析报告 | 数据来源:公开资料整理</p>
|
||||
<div class="mt-4 flex justify-center gap-4">
|
||||
<a href="#" class="text-gray-400 hover:text-gray-600"><i class="fab fa-github text-2xl"></i></a>
|
||||
<a href="#" class="text-gray-400 hover:text-gray-600"><i class="fab fa-twitter text-2xl"></i></a>
|
||||
<a href="#" class="text-gray-400 hover:text-gray-600"><i class="fab fa-linkedin text-2xl"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// 平滑滚动
|
||||
function scrollToSection(sectionId) {
|
||||
document.getElementById(sectionId).scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// 风险矩阵图表
|
||||
const ctx = document.getElementById('riskChart').getContext('2d');
|
||||
const riskChart = new Chart(ctx, {
|
||||
type: 'bubble',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: '整合风险',
|
||||
data: [{x: 5, y: 5, r: 25}],
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.6)'
|
||||
}, {
|
||||
label: '政策风险',
|
||||
data: [{x: 4, y: 4, r: 20}],
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.6)'
|
||||
}, {
|
||||
label: '市场风险',
|
||||
data: [{x: 3, y: 3, r: 15}],
|
||||
backgroundColor: 'rgba(255, 205, 86, 0.6)'
|
||||
}, {
|
||||
label: '财务风险',
|
||||
data: [{x: 2, y: 2, r: 10}],
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.6)'
|
||||
}, {
|
||||
label: '股东风险',
|
||||
data: [{x: 1, y: 1, r: 8}],
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || '';
|
||||
const impact = '影响程度: ' + context.parsed.y + '★';
|
||||
const probability = '发生概率: ' + context.parsed.x + '★';
|
||||
return [label, impact, probability];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '发生概率'
|
||||
},
|
||||
min: 0,
|
||||
max: 6
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '影响程度'
|
||||
},
|
||||
min: 0,
|
||||
max: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 动画效果
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cards = document.querySelectorAll('.card-hover');
|
||||
cards.forEach((card, index) => {
|
||||
setTimeout(() => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
card.style.transition = 'all 0.5s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
}, index * 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
这个HTML页面完整展现了券商合并预期概念的核心内容,包括:
|
||||
|
||||
1. **视觉设计**:采用渐变色背景、玻璃态效果、卡片悬浮动画等现代设计元素,营造专业金融科技感
|
||||
|
||||
2. **核心内容展示**:
|
||||
- Hero区域突出"打造金融国家队"主题
|
||||
- 政策时间轴清晰展示演进过程
|
||||
- 三层核心逻辑可视化呈现
|
||||
- 关键催化剂时间表
|
||||
- 完整的券商股权结构数据表格(响应式设计,支持横向滚动)
|
||||
|
||||
3. **交互功能**:
|
||||
- 平滑滚动导航
|
||||
- 表格行悬停效果
|
||||
- 风险矩阵气泡图
|
||||
- 卡片动画效果
|
||||
|
||||
4. **数据呈现**:
|
||||
- 股票数据表格完整展示所有券商信息
|
||||
- 使用徽章区分不同派系
|
||||
- 颜色编码增强可读性
|
||||
|
||||
5. **投资价值**:
|
||||
- 明确的投资方向建议
|
||||
- 核心跟踪指标
|
||||
- 风险提示与应对策略
|
||||
|
||||
页面完全响应式设计,适配各种设备屏幕,同时保持了金融专业性和视觉美感的平衡。
|
||||
971
public/htmls/华为AI容器.html
Normal file
971
public/htmls/华为AI容器.html
Normal file
@@ -0,0 +1,971 @@
|
||||
我将为您创建一个详实且炫酷的华为AI容器概念展示页面。这个页面将完整呈现Insight的深度内容,并以现代化的设计风格展示相关股票数据。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>华为AI容器 - 算力效率革命的战略突围</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.stock-table::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.stock-table::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stock-table::-webkit-scrollbar-thumb {
|
||||
background: #667eea;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tag-cloud span {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-cloud span:hover {
|
||||
transform: scale(1.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Hero Section -->
|
||||
<div class="gradient-bg text-white">
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="flex flex-col lg:flex-row items-center justify-between">
|
||||
<div class="lg:w-2/3 mb-8 lg:mb-0">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="bg-white/20 px-3 py-1 rounded-full text-sm">核心概念</span>
|
||||
<span class="bg-red-500 text-white px-3 py-1 rounded-full text-sm flex items-center gap-1">
|
||||
<i data-lucide="flame" class="w-4 h-4"></i>
|
||||
舆情热度 20251120
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-4xl lg:text-6xl font-bold mb-4">华为AI容器</h1>
|
||||
<p class="text-xl lg:text-2xl mb-6 opacity-90">算力效率革命的战略突围</p>
|
||||
<p class="text-lg opacity-80 mb-8">在美国先进制程封锁倒逼下,通过系统级软件创新实现算力效率革命</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="glass-effect px-4 py-2 rounded-lg">
|
||||
<i data-lucide="calendar" class="inline w-5 h-5 mr-2"></i>
|
||||
2025年11月21日发布
|
||||
</div>
|
||||
<div class="glass-effect px-4 py-2 rounded-lg">
|
||||
<i data-lucide="code-2" class="inline w-5 h-5 mr-2"></i>
|
||||
开源Flex:ai技术
|
||||
</div>
|
||||
<div class="glass-effect px-4 py-2 rounded-lg">
|
||||
<i data-lucide="target" class="inline w-5 h-5 mr-2"></i>
|
||||
对标英伟达AI技术
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:w-1/3">
|
||||
<div class="glass-effect rounded-2xl p-6">
|
||||
<h3 class="text-xl font-semibold mb-4">核心催化剂</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0 pulse-dot">
|
||||
<span class="text-sm font-bold">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold">2025年9月</p>
|
||||
<p class="text-sm opacity-80">UCM推理加速器开源</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-sm font-bold">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold">2025年11月21日</p>
|
||||
<p class="text-sm opacity-80">Flex:ai正式发布开源</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-sm font-bold">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold">2027年Q4</p>
|
||||
<p class="text-sm opacity-80">Atlas 960 SuperPoD发布</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="bg-white shadow-sm sticky top-0 z-40">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="tabs tabs-boxed flex flex-nowrap overflow-x-auto py-4" id="mainTabs">
|
||||
<a href="#overview" class="tab tab-active" onclick="showSection('overview')">
|
||||
<i data-lucide="eye" class="w-4 h-4 mr-2"></i>核心洞察
|
||||
</a>
|
||||
<a href="#logic" class="tab" onclick="showSection('logic')">
|
||||
<i data-lucide="brain" class="w-4 h-4 mr-2"></i>核心逻辑
|
||||
</a>
|
||||
<a href="#catalyst" class="tab" onclick="showSection('catalyst')">
|
||||
<i data-lucide="zap" class="w-4 h-4 mr-2"></i>催化剂
|
||||
</a>
|
||||
<a href="#industry" class="tab" onclick="showSection('industry')">
|
||||
<i data-lucide="git-branch" class="w-4 h-4 mr-2"></i>产业链
|
||||
</a>
|
||||
<a href="#stocks" class="tab" onclick="showSection('stocks')">
|
||||
<i data-lucide="line-chart" class="w-4 h-4 mr-2"></i>核心股票
|
||||
</a>
|
||||
<a href="#risk" class="tab" onclick="showSection('risk')">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 mr-2"></i>风险提示
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<section id="overview" class="container mx-auto px-4 py-12">
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- 核心观点 -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8 hover-lift">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="lightbulb" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold">核心观点摘要</h2>
|
||||
</div>
|
||||
<div class="prose prose-lg max-w-none">
|
||||
<p class="text-gray-700 leading-relaxed mb-4">
|
||||
华为AI容器概念正处于<strong class="text-gradient">"技术突破预期发酵期"</strong>,其核心驱动力并非单一产品发布,而是<strong class="text-gradient">在美国先进制程封锁倒逼下,通过系统级软件创新实现算力效率革命的战略突围</strong>。
|
||||
</p>
|
||||
<p class="text-gray-700 leading-relaxed mb-4">
|
||||
当前市场关注度逐步升温,但存在显著<strong class="text-purple-600">预期差</strong>:多数人聚焦昇腾硬件性能,却低估中间层容器技术作为"算力操作系统"的战略价值。
|
||||
</p>
|
||||
<p class="text-gray-700 leading-relaxed">
|
||||
Flex:ai的开源若在性能上真正逼近英伟达生态,将重塑国产AI算力价值链,使华为从"卖铲子"升级为"制定采矿规则",具备<strong class="text-gradient">从主题投资向基本面驱动跃迁</strong>的潜力。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 市场热度评估 -->
|
||||
<div>
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 hover-lift">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center">
|
||||
<i data-lucide="trending-up" class="w-5 h-5 mr-2 text-purple-600"></i>
|
||||
市场热度评估
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">舆情热度</span>
|
||||
<span class="text-sm font-semibold">80%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full" style="width: 80%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">研报覆盖</span>
|
||||
<span class="text-sm font-semibold">30%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full" style="width: 30%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">交易拥挤度</span>
|
||||
<span class="text-sm font-semibold">25%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full" style="width: 25%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 p-4 bg-purple-50 rounded-lg">
|
||||
<p class="text-sm text-purple-700">
|
||||
<i data-lucide="info" class="inline w-4 h-4 mr-1"></i>
|
||||
距离11月发布尚有8个月,属于<strong>预期发酵早期</strong>,尚未达到交易拥挤
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预期差分析 -->
|
||||
<div class="mt-8 bg-white rounded-2xl shadow-lg p-8">
|
||||
<h3 class="text-2xl font-bold mb-6 flex items-center">
|
||||
<i data-lucide="layers" class="w-6 h-6 mr-3 text-purple-600"></i>
|
||||
预期差深度挖掘
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="bg-purple-50 text-purple-700">市场普遍认知</th>
|
||||
<th class="bg-purple-50 text-purple-700">被忽略的关键事实</th>
|
||||
<th class="bg-purple-50 text-purple-700">预期差影响</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-4">Flex:ai只是一个容器工具</td>
|
||||
<td class="py-4">
|
||||
<span class="inline-block px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm mb-1">UCM(9月开源)</span>
|
||||
<span class="inline-block px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">+ Flex:ai(11月发布)</span>
|
||||
<p class="text-sm mt-1">构成"推理+训练"全栈优化</p>
|
||||
</td>
|
||||
<td class="py-4 text-green-600">技术价值被系统性低估</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-4">华为AI容器主要服务昇腾生态</td>
|
||||
<td class="py-4">
|
||||
<span class="inline-block px-2 py-1 bg-green-100 text-green-700 rounded text-sm">兼容K8s/Docker原生接口</span>
|
||||
<p class="text-sm mt-1">可管理第三方云设施</p>
|
||||
</td>
|
||||
<td class="py-4 text-green-600">潜在市场空间从昇腾扩展到全行业</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="py-4">开源=不赚钱</td>
|
||||
<td class="py-4">
|
||||
<span class="inline-block px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-sm">博睿数据</span>
|
||||
<span class="inline-block px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-sm">3000万采购订单</span>
|
||||
<p class="text-sm mt-1">OEM模式已跑通</p>
|
||||
</td>
|
||||
<td class="py-4 text-green-600">商业模式已验证,变现路径清晰</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Logic Section -->
|
||||
<section id="logic" class="container mx-auto px-4 py-12 hidden">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-4">核心驱动力三维解析</h2>
|
||||
<p class="text-gray-600">深入剖析华为AI容器背后的战略逻辑</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- 地缘政治驱动 -->
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden hover-lift">
|
||||
<div class="bg-gradient-to-br from-red-500 to-orange-500 p-6 text-white">
|
||||
<i data-lucide="shield-off" class="w-10 h-10 mb-3"></i>
|
||||
<h3 class="text-xl font-bold">地缘政治驱动</h3>
|
||||
<p class="text-sm opacity-90 mt-2">被迫创新下的突围</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">7nm制程限制</p>
|
||||
<p class="text-sm text-gray-600">无法追赶英伟达最新GPU</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">软件定义硬件</p>
|
||||
<p class="text-sm text-gray-600">系统级创新突破物理限制</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">CloudMatrix架构</p>
|
||||
<p class="text-sm text-gray-600">384颗NPU全对等互联</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI算力需求驱动 -->
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden hover-lift">
|
||||
<div class="bg-gradient-to-br from-blue-500 to-cyan-500 p-6 text-white">
|
||||
<i data-lucide="cpu" class="w-10 h-10 mb-3"></i>
|
||||
<h3 class="text-xl font-bold">AI算力需求驱动</h3>
|
||||
<p class="text-sm opacity-90 mt-2">场景驱动的爆发增长</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="trending-up" class="w-5 h-5 text-blue-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">中国市场增速55%</p>
|
||||
<p class="text-sm text-gray-600">AI服务器市场2023H1达30亿美元</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="trending-up" class="w-5 h-5 text-blue-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">智能算力增速3-3.9倍</p>
|
||||
<p class="text-sm text-gray-600">远超通用算力16.6%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="trending-up" class="w-5 h-5 text-blue-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">成本降低30%+</p>
|
||||
<p class="text-sm text-gray-600">容器技术优化万卡集群效率</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生态构建驱动 -->
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden hover-lift">
|
||||
<div class="bg-gradient-to-br from-purple-500 to-indigo-500 p-6 text-white">
|
||||
<i data-lucide="globe" class="w-10 h-10 mb-3"></i>
|
||||
<h3 class="text-xl font-bold">生态构建驱动</h3>
|
||||
<p class="text-sm opacity-90 mt-2">标准制定的战略布局</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="git-merge" class="w-5 h-5 text-purple-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">开源复制TensorFlow路径</p>
|
||||
<p class="text-sm text-gray-600">建立开发者生态</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="git-merge" class="w-5 h-5 text-purple-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">20+万卡集群客户基础</p>
|
||||
<p class="text-sm text-gray-600">锁定昇腾硬件采购</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="git-merge" class="w-5 h-5 text-purple-500 mt-0.5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">软件-硬件-云服务闭环</p>
|
||||
<p class="text-sm text-gray-600">类比CUDA锁定NV GPU</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Catalyst Section -->
|
||||
<section id="catalyst" class="container mx-auto px-4 py-12 hidden">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-4">关键催化剂与时间轴</h2>
|
||||
<p class="text-gray-600">把握华为AI容器发展的关键节点</p>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="relative">
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-1 h-full timeline-line"></div>
|
||||
|
||||
<!-- 2025年Q3 -->
|
||||
<div class="relative flex items-center mb-8">
|
||||
<div class="w-1/2 pr-8 text-right">
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 hover-lift">
|
||||
<span class="inline-block px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm mb-2">近期催化剂</span>
|
||||
<h3 class="text-xl font-bold mb-2">2025年9月 UCM正式开源</h3>
|
||||
<p class="text-gray-600 text-sm">推理记忆数据管理器开源,作为Flex:ai的前奏</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">金融场景试点</span>
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">GitHub Star数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-8 h-8 bg-blue-500 rounded-full border-4 border-white flex items-center justify-center">
|
||||
<i data-lucide="calendar" class="w-4 h-4 text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 pl-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2025年11月 -->
|
||||
<div class="relative flex items-center mb-8">
|
||||
<div class="w-1/2 pr-8"></div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-8 h-8 bg-purple-500 rounded-full border-4 border-white flex items-center justify-center">
|
||||
<i data-lucide="rocket" class="w-4 h-4 text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 pl-8">
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 hover-lift">
|
||||
<span class="inline-block px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm mb-2">核心事件</span>
|
||||
<h3 class="text-xl font-bold mb-2">2025年11月21日 Flex:ai发布</h3>
|
||||
<p class="text-gray-600 text-sm">上海论坛正式发布并开源,对标英伟达AI技术</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">性能测试数据</span>
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">社区活跃度</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2026-2027 -->
|
||||
<div class="relative flex items-center">
|
||||
<div class="w-1/2 pr-8 text-right">
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 hover-lift">
|
||||
<span class="inline-block px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm mb-2">长期发展</span>
|
||||
<h3 class="text-xl font-bold mb-2">2026-2027 生态爆发期</h3>
|
||||
<p class="text-gray-600 text-sm">Atlas 960 SuperPoD发布,15,488张加速卡</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">标准输出</span>
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">全球引领</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-8 h-8 bg-green-500 rounded-full border-4 border-white flex items-center justify-center">
|
||||
<i data-lucide="target" class="w-4 h-4 text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 pl-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="grid lg:grid-cols-4 gap-4 mt-12">
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 text-center metric-card">
|
||||
<div class="text-3xl font-bold text-gradient mb-2">3000万</div>
|
||||
<p class="text-gray-600 text-sm">博睿数据订单金额</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 text-center metric-card">
|
||||
<div class="text-3xl font-bold text-gradient mb-2">20+</div>
|
||||
<p class="text-gray-600 text-sm">CloudMatrix超级节点</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 text-center metric-card">
|
||||
<div class="text-3xl font-bold text-gradient mb-2">8192</div>
|
||||
<p class="text-gray-600 text-sm">Atlas 950加速卡数</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 text-center metric-card">
|
||||
<div class="text-3xl font-bold text-gradient mb-2">245TB</div>
|
||||
<p class="text-gray-600 text-sm">AI SSD单盘容量</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Industry Chain Section -->
|
||||
<section id="industry" class="container mx-auto px-4 py-12 hidden">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-4">产业链全景图</h2>
|
||||
<p class="text-gray-600">华为AI容器的完整生态体系</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
<!-- 上游:基础设施层 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-orange-500 to-red-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="server" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">上游:基础设施层</h3>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-orange-600">昇腾芯片</h4>
|
||||
<p class="text-sm text-gray-600">昇腾910/950处理器<br>华为海思自主研发</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-orange-600">CloudMatrix</h4>
|
||||
<p class="text-sm text-gray-600">384超级节点架构<br>全对等互联技术</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-orange-600">Atlas集群</h4>
|
||||
<p class="text-sm text-gray-600">950/960 SuperPoD<br>万卡级算力集群</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-orange-600">AI存储</h4>
|
||||
<p class="text-sm text-gray-600">245TB AI SSD<br>"以存代算"技术</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t-2 border-dashed my-8"></div>
|
||||
|
||||
<!-- 中游:AI容器平台层 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="layers" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">中游:AI容器平台层</h3>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-4 gap-4">
|
||||
<div class="bg-blue-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-blue-600">Flex:ai引擎</h4>
|
||||
<p class="text-sm text-gray-600">开源AI容器引擎<br>核心调度算法</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-blue-600">UCS管理平台</h4>
|
||||
<p class="text-sm text-gray-600">华为云统一管理<br>分布式集群管控</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-blue-600">UCM加速套件</h4>
|
||||
<p class="text-sm text-gray-600">KV Cache优化<br>推理性能提升</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-blue-600">中间件适配</h4>
|
||||
<p class="text-sm text-gray-600">东方通/中国软件<br>信创生态整合</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t-2 border-dashed my-8"></div>
|
||||
|
||||
<!-- 下游:行业应用 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-teal-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="globe-2" class="w-6 h-6 text-white"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">下游:行业应用与解决方案</h3>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-4 gap-4">
|
||||
<div class="bg-green-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-green-600">智慧医疗</h4>
|
||||
<p class="text-sm text-gray-600">DCS AI解决方案<br>华大系/塞力医疗</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-green-600">金融科技</h4>
|
||||
<p class="text-sm text-gray-600">银联UCM试点<br>博睿数据运维</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-green-600">政务云</h4>
|
||||
<p class="text-sm text-gray-600">混合云容器服务<br>电科数字/青云科技</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 hover:shadow-md transition">
|
||||
<h4 class="font-semibold mb-2 text-green-600">企业服务</h4>
|
||||
<p class="text-sm text-gray-600">企业级容器云<br>行业定制方案</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stocks Section -->
|
||||
<section id="stocks" class="container mx-auto px-4 py-12 hidden">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-4">核心股票池</h2>
|
||||
<p class="text-gray-600">华为AI容器产业链核心标的</p>
|
||||
</div>
|
||||
|
||||
<!-- Stock Table -->
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<div class="p-4 bg-gradient-to-r from-purple-600 to-indigo-600 text-white">
|
||||
<h3 class="text-xl font-bold">股票数据详情</h3>
|
||||
</div>
|
||||
<div class="stock-table">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="text-left p-4">股票名称</th>
|
||||
<th class="text-left p-4">分类</th>
|
||||
<th class="text-left p-4">相关性描述</th>
|
||||
<th class="text-left p-4">信源</th>
|
||||
<th class="text-center p-4">强度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">博睿数据</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-info">技术服务</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">华为云UCS可观测软件框架供应商,3000万采购订单已落地</td>
|
||||
<td class="p-4 text-sm">互动</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">飞荣达</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-secondary">散热</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">华为服务器液冷散热核心供应商,受益Atlas集群放量</td>
|
||||
<td class="p-4 text-sm">路演</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">青云科技</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-primary">容器云</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">KubeSphere容器平台,华为云原生生态伙伴</td>
|
||||
<td class="p-4 text-sm">官网</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">神州数码</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-primary">容器云</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">推出首个基于鲲鹏环境的开源容器云发行版</td>
|
||||
<td class="p-4 text-sm">公告</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">电科数字</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-primary">容器云</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">华讯容器云平台,华为云高级别合作伙伴</td>
|
||||
<td class="p-4 text-sm">官微/互动</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">蜂助手</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-success">技术服务</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">容器虚拟化核心技术,加强与华为产品级技术合作</td>
|
||||
<td class="p-4 text-sm">互动</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-purple-50 transition">
|
||||
<td class="p-4 font-semibold">东方通</td>
|
||||
<td class="p-4">
|
||||
<span class="badge badge-warning">中间件</span>
|
||||
</td>
|
||||
<td class="p-4 text-sm">云原生中间件平台TongCNMP,已应用于华为云</td>
|
||||
<td class="p-4 text-sm">公告/互动</td>
|
||||
<td class="p-4 text-center">
|
||||
<div class="flex justify-center gap-1">
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-yellow-500 fill-current"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
<i data-lucide="star" class="w-4 h-4 text-gray-300"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Investment Advice -->
|
||||
<div class="mt-8 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl p-8">
|
||||
<h3 class="text-2xl font-bold mb-4">投资建议</h3>
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<div class="bg-white rounded-xl p-6">
|
||||
<h4 class="font-semibold mb-3 text-green-600">首选</h4>
|
||||
<p class="text-sm">博睿数据:唯一明确获得华为云AI容器平台采购订单的公司,OEM模式深度绑定,收入确定性最高</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-6">
|
||||
<h4 class="font-semibold mb-3 text-blue-600">次选</h4>
|
||||
<p class="text-sm">飞荣达:算力效率提升必然带来功耗密度增加,Atlas集群的万卡级部署对液冷需求爆发</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-6">
|
||||
<h4 class="font-semibold mb-3 text-yellow-600">备选</h4>
|
||||
<p class="text-sm">华大系/塞力医疗:华为医疗军团的DCS AI方案已明确集成容器技术,需关注临床落地进展</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Risk Section -->
|
||||
<section id="risk" class="container mx-auto px-4 py-12 hidden">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-4">风险提示</h2>
|
||||
<p class="text-gray-600">投资需关注的关键风险点</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- 技术风险 -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 border-l-4 border-red-500">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i data-lucide="alert-triangle" class="w-6 h-6 text-red-500"></i>
|
||||
<h3 class="text-xl font-bold">技术风险</h3>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-red-500 mt-0.5"></i>
|
||||
<span class="text-sm">Flex:ai性能能否真正对标英伟达存疑</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-red-500 mt-0.5"></i>
|
||||
<span class="text-sm">开源社区活跃度不及预期</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-red-500 mt-0.5"></i>
|
||||
<span class="text-sm">ISV生态构建速度缓慢</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 商业化风险 -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 border-l-4 border-orange-500">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i data-lucide="trending-down" class="w-6 h-6 text-orange-500"></i>
|
||||
<h3 class="text-xl font-bold">商业化风险</h3>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-orange-500 mt-0.5"></i>
|
||||
<span class="text-sm">变现路径不清晰</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-orange-500 mt-0.5"></i>
|
||||
<span class="text-sm">客户迁移成本高</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-orange-500 mt-0.5"></i>
|
||||
<span class="text-sm">华为云容器收入占比低(<5%)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 政策竞争风险 -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 border-l-4 border-yellow-500">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i data-lucide="shield" class="w-6 h-6 text-yellow-500"></i>
|
||||
<h3 class="text-xl font-bold">政策与竞争风险</h3>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-yellow-500 mt-0.5"></i>
|
||||
<span class="text-sm">阿里云、腾讯云快速跟进</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-yellow-500 mt-0.5"></i>
|
||||
<span class="text-sm">国内云CR3市场份额超70%</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-yellow-500 mt-0.5"></i>
|
||||
<span class="text-sm">开源软件出口管制风险</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk Warning Box -->
|
||||
<div class="mt-8 bg-red-50 border-2 border-red-200 rounded-xl p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<i data-lucide="alert-circle" class="w-6 h-6 text-red-500 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="font-bold text-red-700 mb-2">重要提示</h4>
|
||||
<p class="text-sm text-red-600">
|
||||
距离11月Flex:ai发布尚有8个月时间窗口,存在重大不确定性。历史上华为曾延期发布产品,需警惕"预期过度前置"风险。
|
||||
建议在9月UCM开源后根据社区热度动态调整仓位,若技术反馈正面可加仓,若平淡则减仓锁定收益。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 text-white py-12 mt-20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm opacity-75">© 2025 华为AI容器概念分析 | 数据来源:公开信息整理</p>
|
||||
<p class="text-xs opacity-50 mt-2">投资有风险,入市需谨慎</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Initialize Lucide icons
|
||||
lucide.createIcons();
|
||||
|
||||
// Tab navigation
|
||||
function showSection(sectionId) {
|
||||
// Hide all sections
|
||||
const sections = document.querySelectorAll('section');
|
||||
sections.forEach(section => {
|
||||
section.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show selected section
|
||||
const selectedSection = document.getElementById(sectionId);
|
||||
if (selectedSection) {
|
||||
selectedSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update tab active state
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.classList.remove('tab-active');
|
||||
});
|
||||
event.target.classList.add('tab-active');
|
||||
|
||||
// Reinitialize icons for newly shown content
|
||||
setTimeout(() => lucide.createIcons(), 100);
|
||||
}
|
||||
|
||||
// Add smooth scroll
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add animation on scroll
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// Observe all metric cards and hover-lift elements
|
||||
document.querySelectorAll('.metric-card, .hover-lift').forEach(el => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(20px)';
|
||||
el.style.transition = 'all 0.6s ease';
|
||||
observer.observe(el);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
这个HTML页面完整展示了华为AI容器概念的深度分析,主要特点包括:
|
||||
|
||||
## 核心功能:
|
||||
1. **详实的内容保留** - 完整呈现了Insight中的核心观点、逻辑分析、催化剂时间轴等
|
||||
2. **炫酷的视觉设计** - 使用渐变色、玻璃态效果、动画过渡等现代设计元素
|
||||
3. **股票数据表格** - 清晰展示相关股票及其关联度,使用星级评价系统
|
||||
4. **响应式布局** - 完美适配移动端和桌面端
|
||||
5. **交互式导航** - 标签页切换不同内容模块
|
||||
6. **数据可视化** - 进度条、时间轴、卡片等多种数据展示方式
|
||||
|
||||
## 设计亮点:
|
||||
- 渐变色主题贯穿全站
|
||||
- 卡片悬浮效果增强交互感
|
||||
- 时间轴清晰展示发展路径
|
||||
- 星级评价直观展示股票关联度
|
||||
- 风险提示醒目突出
|
||||
- 移动端友好的触控交互
|
||||
|
||||
页面内容深度与视觉效果并重,既保留了专业金融分析的严谨性,又具有现代化的审美体验。
|
||||
615
public/htmls/寒潮.html
Normal file
615
public/htmls/寒潮.html
Normal file
@@ -0,0 +1,615 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>寒潮投资概念深度解析 - 季节性事件驱动的短期机遇</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-morphism {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.hero-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pulse-animation {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stock-table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-icon {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-gradient text-white py-20">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between">
|
||||
<div class="md:w-2/3 mb-10 md:mb-0">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-snowflake text-4xl mr-4 floating-icon"></i>
|
||||
<h1 class="text-5xl font-bold">寒潮投资概念</h1>
|
||||
</div>
|
||||
<p class="text-xl mb-6">季节性事件驱动的短期机遇分析</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white/20 backdrop-blur rounded-lg p-4">
|
||||
<div class="text-3xl font-bold">15-30</div>
|
||||
<div class="text-sm">有效窗口期(天)</div>
|
||||
</div>
|
||||
<div class="bg-white/20 backdrop-blur rounded-lg p-4">
|
||||
<div class="text-3xl font-bold">3次</div>
|
||||
<div class="text-sm">2024-25预警峰值</div>
|
||||
</div>
|
||||
<div class="bg-white/20 backdrop-blur rounded-lg p-4">
|
||||
<div class="text-3xl font-bold">10%</div>
|
||||
<div class="text-sm">波司登单日涨幅</div>
|
||||
</div>
|
||||
<div class="bg-white/20 backdrop-blur rounded-lg p-4">
|
||||
<div class="text-3xl font-bold">60万吨</div>
|
||||
<div class="text-sm">东三省日耗峰值</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/3">
|
||||
<div class="bg-white/10 backdrop-blur rounded-2xl p-6">
|
||||
<h3 class="text-xl font-semibold mb-4">当前阶段判断</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-fire text-yellow-400 mr-3"></i>
|
||||
<span>季节性事件驱动高峰期</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-chart-line text-green-400 mr-3"></i>
|
||||
<span>短期主题投资机会</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-orange-400 mr-3"></i>
|
||||
<span>非长期产业趋势</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mx-auto px-4 py-10">
|
||||
<!-- 时间轴 -->
|
||||
<div class="glass-morphism rounded-2xl p-8 mb-8">
|
||||
<h2 class="text-3xl font-bold gradient-text mb-6">概念事件时间轴</h2>
|
||||
<div class="relative">
|
||||
<div class="timeline-line"></div>
|
||||
<div class="space-y-6">
|
||||
<div class="timeline-item relative pl-8">
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="font-semibold text-blue-600">2024年11月下旬</div>
|
||||
<div>首轮预警启动,寒潮蓝色预警,0℃线南压至苏皖北部</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-item relative pl-8">
|
||||
<div class="bg-orange-50 rounded-lg p-4">
|
||||
<div class="font-semibold text-orange-600">2025年1月23日</div>
|
||||
<div>中国气象局启动寒潮暴雪Ⅲ级应急响应,局地降温超20℃</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-item relative pl-8">
|
||||
<div class="bg-purple-50 rounded-lg p-4">
|
||||
<div class="font-semibold text-purple-600">2025年1-2月</div>
|
||||
<div>欧美同步寒潮,德国天然气消费激增,TTF价格突破50美元</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-item relative pl-8">
|
||||
<div class="bg-green-50 rounded-lg p-4">
|
||||
<div class="font-semibold text-green-600">2025年3月</div>
|
||||
<div>寒潮延续至春季,北京发布蓝色预警,0℃线南压至秦岭</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心逻辑 -->
|
||||
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="glass-morphism rounded-xl p-6 card-hover">
|
||||
<div class="text-blue-500 text-4xl mb-4">
|
||||
<i class="fas fa-cloud-showers-heavy"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">气象灾害冲击</h3>
|
||||
<p class="text-gray-600">全国大部降温6-10℃,局地超20℃,官方定调"极端天气事件"</p>
|
||||
<div class="mt-4 pt-4 border-t">
|
||||
<span class="text-sm text-gray-500">东三省日耗从35万→60万吨/天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-morphism rounded-xl p-6 card-hover">
|
||||
<div class="text-purple-500 text-4xl mb-4">
|
||||
<i class="fas fa-link"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">供应链脆弱性</h3>
|
||||
<p class="text-gray-600">上游原料紧张、中游订单爆单、下游需求即时</p>
|
||||
<div class="mt-4 pt-4 border-t">
|
||||
<span class="text-sm text-gray-500">古麒绒材羽绒收入占比99.27%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-morphism rounded-xl p-6 card-hover">
|
||||
<div class="text-green-500 text-4xl mb-4">
|
||||
<i class="fas fa-bolt"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">能源刚性需求</h3>
|
||||
<p class="text-gray-600">空调制热+煤炭/天然气供暖叠加,需求非线性增长</p>
|
||||
<div class="mt-4 pt-4 border-t">
|
||||
<span class="text-sm text-gray-500">提前10天供暖多耗5000万吨煤</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产业链分析 -->
|
||||
<div class="glass-morphism rounded-2xl p-8 mb-8">
|
||||
<h2 class="text-3xl font-bold gradient-text mb-6">产业链四象限模型</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6">
|
||||
<h3 class="text-lg font-bold text-blue-700 mb-4">能源链(需求强度:★★★★★)</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span>上游资源:</span>
|
||||
<span class="font-semibold">中国神华、陕西煤业</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>中游制造:</span>
|
||||
<span class="font-semibold">古麒绒材</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>下游消费:</span>
|
||||
<span class="font-semibold">波司登</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl p-6">
|
||||
<h3 class="text-lg font-bold text-green-700 mb-4">设备链(需求强度:★★★★)</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span>核心原料:</span>
|
||||
<span class="font-semibold">棉花/鸭绒</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>热泵设备:</span>
|
||||
<span class="font-semibold">日出东方、双良节能</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>终端零售:</span>
|
||||
<span class="font-semibold">彩虹集团</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl p-6">
|
||||
<h3 class="text-lg font-bold text-purple-700 mb-4">服务链(需求强度:★★★)</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span>电力供应:</span>
|
||||
<span class="font-semibold">华能、华电</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>智能温控:</span>
|
||||
<span class="font-semibold">瑞纳智能</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>热力服务:</span>
|
||||
<span class="font-semibold">联美控股</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-orange-50 to-yellow-50 rounded-xl p-6">
|
||||
<h3 class="text-lg font-bold text-orange-700 mb-4">消费品链(需求强度:★★)</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span>品牌服装:</span>
|
||||
<span class="font-semibold">波司登、太平鸟</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>家居保暖:</span>
|
||||
<span class="font-semibold">真爱美家</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>小家电:</span>
|
||||
<span class="font-semibold">彩虹集团</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 股票数据表格 -->
|
||||
<div class="glass-morphism rounded-2xl p-8 mb-8">
|
||||
<h2 class="text-3xl font-bold gradient-text mb-6">相关上市公司一览</h2>
|
||||
|
||||
<!-- 供热/暖板块 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-blue-600">
|
||||
<i class="fas fa-fire mr-2"></i>供热/暖板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="table table-zebra table-compact w-full">
|
||||
<thead>
|
||||
<tr class="bg-blue-100">
|
||||
<th>股票代码</th>
|
||||
<th>公司名称</th>
|
||||
<th>相关性描述</th>
|
||||
<th>核心逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>联美控股</td>
|
||||
<td>2025H1供暖及蒸汽收入14.16亿元,占比74.62%</td>
|
||||
<td>供暖收入占比高,直接受益寒潮</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>惠天热电</td>
|
||||
<td>沈阳市大型专业供热上市公司,2025H1供暖供热气11.66亿元,占比96.35%</td>
|
||||
<td>专业供热且收入占比极高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>京能热力</td>
|
||||
<td>实控人北京市国资委(持股28.46%);2025H1热力服务收入6.55亿元,占比84.67%</td>
|
||||
<td>国资控股保障,区域垄断性强</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>金房能源</td>
|
||||
<td>2025H1供热行业6.55亿元,占比97.59%;北京地区占比62.11%</td>
|
||||
<td>供热收入占比极高,区域集中</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>瑞纳智能</td>
|
||||
<td>2025H1供热节能服务0.74亿元,占比79.59%</td>
|
||||
<td>智慧供暖,技术壁垒高</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热泵板块 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-green-600">
|
||||
<i class="fas fa-temperature-high mr-2"></i>热泵板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="table table-zebra table-compact w-full">
|
||||
<thead>
|
||||
<tr class="bg-green-100">
|
||||
<th>股票代码</th>
|
||||
<th>公司名称</th>
|
||||
<th>相关性描述</th>
|
||||
<th>核心逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>日出东方</td>
|
||||
<td>推进以空气能热泵为龙头业务的战略布局;空气能热泵产能10万套左右</td>
|
||||
<td>热泵为核心业务,西藏项目17.67亿</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>万和电气</td>
|
||||
<td>2024年推出的R290热泵产品成为行业内的佼佼者</td>
|
||||
<td>R290热泵技术领先</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>双良节能</td>
|
||||
<td>中国最大的溴化锂制冷机、吸收式热泵和空冷器生产商和集成商</td>
|
||||
<td>溴化锂热泵领域龙头</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>振邦智能</td>
|
||||
<td>空气能热泵是公司主要产品智能电器控制器的主要应用之一</td>
|
||||
<td>热泵控制器核心供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 御寒产品板块 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-purple-600">
|
||||
<i class="fas fa-tshirt mr-2"></i>御寒产品板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="table table-zebra table-compact w-full">
|
||||
<thead>
|
||||
<tr class="bg-purple-100">
|
||||
<th>股票代码</th>
|
||||
<th>公司名称</th>
|
||||
<th>相关性描述</th>
|
||||
<th>核心逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>古麒绒材</td>
|
||||
<td>主要产品为高规格羽绒材料,2025H1羽绒行业5.33亿元,占比99.27%</td>
|
||||
<td>最纯粹的羽绒原料标的</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>华英农业</td>
|
||||
<td>2025H1羽绒13.55亿元,占比65.43%</td>
|
||||
<td>羽绒收入占比较高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>真爱美家</td>
|
||||
<td>2025H1毛毯3.45亿元,占比88%,保暖性和舒适度高</td>
|
||||
<td>毛毯收入占比极高</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>彩虹集团</td>
|
||||
<td>电热毯行业龙头;2025H1家用柔性取暖系列3.56亿元,占比65.73%</td>
|
||||
<td>电热毯龙头,需求反馈最快</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 能源板块 -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4 text-orange-600">
|
||||
<i class="fas fa-oil-can mr-2"></i>能源板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="table table-zebra table-compact w-full">
|
||||
<thead>
|
||||
<tr class="bg-orange-100">
|
||||
<th>股票代码</th>
|
||||
<th>公司名称</th>
|
||||
<th>相关性描述</th>
|
||||
<th>核心逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>601088</td>
|
||||
<td>中国神华</td>
|
||||
<td>煤炭产业链龙头,股息率4.8%</td>
|
||||
<td>长协煤保供能力强,现金流稳定</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>601225</td>
|
||||
<td>陕西煤业</td>
|
||||
<td>煤炭产业链,股息率4.5%</td>
|
||||
<td>资源禀赋优秀,成本优势明显</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>600188</td>
|
||||
<td>兖矿能源</td>
|
||||
<td>煤炭产业链,澳洲产能占比高</td>
|
||||
<td>受益国际煤价上涨,弹性大</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>601018</td>
|
||||
<td>中煤能源</td>
|
||||
<td>煤炭产业链,央企背景</td>
|
||||
<td>产能规模大,保供主力</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>600997</td>
|
||||
<td>开滦股份</td>
|
||||
<td>焦炭产业链,煤焦一体化</td>
|
||||
<td>焦煤涨价受益,弹性较好</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投资策略 -->
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="glass-morphism rounded-xl p-6">
|
||||
<h3 class="text-xl font-bold mb-4 text-green-600">
|
||||
<i class="fas fa-chess-rook mr-2"></i>操作策略
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 rounded-lg p-4">
|
||||
<div class="font-semibold mb-2">三日法则</div>
|
||||
<p class="text-sm text-gray-600">每3日评估一次,若数据未达预期立即减半</p>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg p-4">
|
||||
<div class="font-semibold mb-2">止损纪律</div>
|
||||
<p class="text-sm text-gray-600">买入3日内若下跌超5%,立即止损</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="font-semibold mb-2">仓位控制</div>
|
||||
<p class="text-sm text-gray-600">主题配置不超过10%,卫星策略定位</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-morphism rounded-xl p-6">
|
||||
<h3 class="text-xl font-bold mb-4 text-red-600">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>关键风险
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-red-50 rounded-lg p-4">
|
||||
<div class="font-semibold mb-2">可持续性风险</div>
|
||||
<p class="text-sm text-gray-600">天气红利难持续,寒潮结束需求断崖</p>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-lg p-4">
|
||||
<div class="font-semibold mb-2">技术风险</div>
|
||||
<p class="text-sm text-gray-600">热泵低温性能衰减,-10℃以下COP降至2.5</p>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4">
|
||||
<div class="font-semibold mb-2">政策风险</div>
|
||||
<p class="text-sm text-gray-600">保供压制煤价,长协上限770元/吨</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关键跟踪指标 -->
|
||||
<div class="glass-morphism rounded-2xl p-8">
|
||||
<h2 class="text-3xl font-bold gradient-text mb-6">关键跟踪指标</h2>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="bg-gradient-to-r from-red-500 to-orange-500 text-white rounded-xl p-6">
|
||||
<div class="text-2xl font-bold mb-2">一级指标(每日)</div>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li>• 中央气象台预警等级</li>
|
||||
<li>• 东三省电厂日耗(>60万吨)</li>
|
||||
<li>• 生猪日屠宰量(>17万头)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-blue-500 to-indigo-500 text-white rounded-xl p-6">
|
||||
<div class="text-2xl font-bold mb-2">二级指标(每周)</div>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li>• 秦皇岛港库存(<500万吨)</li>
|
||||
<li>• 羽绒原料价格指数(周涨>5%)</li>
|
||||
<li>• 小家电换手率(>5%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-green-500 to-teal-500 text-white rounded-xl p-6">
|
||||
<div class="text-2xl font-bold mb-2">三级指标(每月)</div>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li>• 天猫京东羽绒服销量</li>
|
||||
<li>• 企业月度经营数据</li>
|
||||
<li>• 能源库存变动</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="hero-gradient text-white py-8 mt-20">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<p class="mb-2">寒潮投资概念解析 - 数据更新至2025年</p>
|
||||
<p class="text-sm opacity-75">本内容仅供参考,不构成投资建议</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// 动态效果
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 滚动动画
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -100px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.card-hover').forEach(el => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(20px)';
|
||||
el.style.transition = 'all 0.6s ease';
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// 表格行悬停效果
|
||||
document.querySelectorAll('tbody tr').forEach(row => {
|
||||
row.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'scale(1.02)';
|
||||
this.style.transition = 'all 0.2s ease';
|
||||
});
|
||||
row.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'scale(1)';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
746
public/htmls/对日反制.html
Normal file
746
public/htmls/对日反制.html
Normal file
@@ -0,0 +1,746 @@
|
||||
我将为您创建一个专业的金融投资分析页面,展示"对日反制"投资概念的深度分析。这个页面将融合金融严谨性与现代设计美学。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>对日反制投资概念深度分析</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.glass-morphism {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 2px;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.gradient-border-inner {
|
||||
background: #0f172a;
|
||||
border-radius: calc(1rem - 2px);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
background: linear-gradient(180deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
}
|
||||
|
||||
.hover-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: .5; }
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stock-table::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.stock-table::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stock-table::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tag-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.tag-secondary {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
.tag-accent {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
}
|
||||
|
||||
.number-stat {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.risk-high { border-left: 4px solid #ef4444; }
|
||||
.risk-medium { border-left: 4px solid #f59e0b; }
|
||||
.risk-low { border-left: 4px solid #10b981; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-white">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-900/20 via-purple-900/20 to-gray-950"></div>
|
||||
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%239C92AC" fill-opacity="0.05"%3E%3Cpath d="M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')]"></div>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div class="text-center mb-12">
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-blue-500/20 to-purple-500/20 backdrop-blur-sm mb-6">
|
||||
<span class="pulse-dot w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
||||
<span class="text-sm font-medium text-gray-300">2025年下半年地缘政治驱动的投资机会</span>
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
对日反制概念
|
||||
</h1>
|
||||
<p class="text-xl text-gray-400 max-w-3xl mx-auto leading-relaxed">
|
||||
地缘政治博弈下的国产替代加速,从精准制裁到产业链重构的投资逻辑梳理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16">
|
||||
<div class="glass-morphism rounded-2xl p-6 hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-r from-red-500 to-orange-500 flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="ml-4 text-lg font-semibold">风险警示</h3>
|
||||
</div>
|
||||
<p class="text-gray-400">主题炒作早期阶段,警惕情绪退潮风险,政策不确定性高</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 flex items-center justify-center">
|
||||
<i class="fas fa-industry text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="ml-4 text-lg font-semibold">核心机会</h3>
|
||||
</div>
|
||||
<p class="text-gray-400">资源管制 > 材料替代 > 水产出口,关注实质性国产替代标的</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<i class="fas fa-chart-line text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="ml-4 text-lg font-semibold">时间窗口</h3>
|
||||
</div>
|
||||
<p class="text-gray-400">3-6个月关键观察期,等待政策验证,不赌情绪溢价</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Section -->
|
||||
<div class="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-12 text-center">
|
||||
<span class="bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||
关键事件时间轴
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-1 h-full timeline-line rounded-full"></div>
|
||||
|
||||
<div class="space-y-12">
|
||||
<!-- Event 1 -->
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full md:w-5/12 text-right pr-8 hidden md:block">
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<div class="text-sm text-purple-400 mb-2">2025年5月19日</div>
|
||||
<h4 class="text-lg font-semibold mb-2">背景伏笔:资源管制启动</h4>
|
||||
<p class="text-gray-400 text-sm">缅甸成为日本T金属第一大供应国,但货源依赖中国境内非法走私,中方加强全链条管控</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 bg-blue-500 rounded-full border-4 border-gray-950"></div>
|
||||
<div class="w-full md:w-5/12 pl-8 md:hidden">
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<div class="text-sm text-purple-400 mb-2">2025年5月19日</div>
|
||||
<h4 class="text-lg font-semibold mb-2">背景伏笔:资源管制启动</h4>
|
||||
<p class="text-gray-400 text-sm">缅甸成为日本T金属第一大供应国,但货源依赖中国境内非法走私,中方加强全链条管控</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event 2 -->
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full md:w-5/12 text-right pr-8 hidden md:block">
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<div class="text-sm text-orange-400 mb-2">2025年9月8日</div>
|
||||
<h4 class="text-lg font-semibold mb-2">精准制裁落地</h4>
|
||||
<p class="text-gray-400 text-sm">外交部依据《反外国制裁法》对日本参议员石平实施制裁,冻结资产、禁止交易、人员禁入</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 bg-orange-500 rounded-full border-4 border-gray-950 pulse-dot"></div>
|
||||
<div class="w-full md:w-5/12 pl-8 md:hidden">
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<div class="text-sm text-orange-400 mb-2">2025年9月8日</div>
|
||||
<h4 class="text-lg font-semibold mb-2">精准制裁落地</h4>
|
||||
<p class="text-gray-400 text-sm">外交部依据《反外国制裁法》对日本参议员石平实施制裁,冻结资产、禁止交易、人员禁入</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event 3 -->
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full md:w-5/12 text-right pr-8 hidden md:block">
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<div class="text-sm text-red-400 mb-2">2025年11月15日</div>
|
||||
<h4 class="text-lg font-semibold mb-2">事态升级信号</h4>
|
||||
<p class="text-gray-400 text-sm">"玉渊谭天"发布:针对高市早苗挑衅,中方表态从"敦促"升级为"一切后果由日方承担"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 bg-red-500 rounded-full border-4 border-gray-950 pulse-dot"></div>
|
||||
<div class="w-full md:w-5/12 pl-8 md:hidden">
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<div class="text-sm text-red-400 mb-2">2025年11月15日</div>
|
||||
<h4 class="text-lg font-semibold mb-2">事态升级信号</h4>
|
||||
<p class="text-gray-400 text-sm">"玉渊谭天"发布:针对高市早苗挑衅,中方表态从"敦促"升级为"一切后果由日方承担"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Logic Section -->
|
||||
<div class="py-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-b from-gray-950 to-gray-900">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-12 text-center">
|
||||
<span class="bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">
|
||||
核心逻辑三层递进
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/20 to-transparent rounded-2xl blur-xl"></div>
|
||||
<div class="relative glass-morphism rounded-2xl p-8 h-full">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-blue-400">1</span>
|
||||
</div>
|
||||
<h3 class="ml-3 text-xl font-semibold">政治惩戒必要性</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 mb-4">
|
||||
石平案开创对日政治人物精准制裁先例,法律依据明确,手段涵盖资产冻结、人员禁入、供应链隔离,形成强力震慑。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-primary">《反外国制裁法》</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-primary">精准制裁</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-primary">民意基础</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-purple-500/20 to-transparent rounded-2xl blur-xl"></div>
|
||||
<div class="relative glass-morphism rounded-2xl p-8 h-full">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-purple-400">2</span>
|
||||
</div>
|
||||
<h3 class="ml-3 text-xl font-semibold">资源反制可行性</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 mb-4">
|
||||
日本在半导体材料、高性能陶瓷、OLED材料等领域深度依赖中国供应链。中方已在战略矿产实施全链条管控,具备"卡脖子"能力。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-secondary">半导体材料</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-secondary">稀土管制</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-secondary">全链条管控</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-orange-500/20 to-transparent rounded-2xl blur-xl"></div>
|
||||
<div class="relative glass-morphism rounded-2xl p-8 h-full">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-orange-400">3</span>
|
||||
</div>
|
||||
<h3 class="ml-3 text-xl font-semibold">国力博弈战略性</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 mb-4">
|
||||
日本面临"美关税压力+国内加息+经济疲软"三重困境,GDP连续负增长,对华出口依存度超20%,此时反制可精准打击其执政基础。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-accent">两线承压</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-accent">战略筹码</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium tag-accent">以点破面</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock Data Tables -->
|
||||
<div class="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-12 text-center">
|
||||
<span class="bg-gradient-to-r from-green-400 to-emerald-400 bg-clip-text text-transparent">
|
||||
核心标的深度剖析
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Semiconductor Materials -->
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<h3 class="text-2xl font-bold mb-6 flex items-center">
|
||||
<i class="fas fa-microchip mr-3 text-blue-400"></i>
|
||||
半导体材料板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">股票代码</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">细分领域</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">日本产业地位</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">核心竞争力</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">风险等级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-blue-400">彤程新材</td>
|
||||
<td class="py-3 px-4">ArF/KrF光刻胶</td>
|
||||
<td class="py-3 px-4">日系及杜邦主导</td>
|
||||
<td class="py-3 px-4">KrF量产,ArF验证中</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-yellow-500/20 text-yellow-400">中等</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-blue-400">南大光电</td>
|
||||
<td class="py-3 px-4">ArF光刻胶</td>
|
||||
<td class="py-3 px-4">日系及杜邦主导</td>
|
||||
<td class="py-3 px-4">通过02专项验收</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-yellow-500/20 text-yellow-400">中等</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-purple-400">华懋科技</td>
|
||||
<td class="py-3 px-4">PSPI</td>
|
||||
<td class="py-3 px-4">日本东丽、旭化成主导</td>
|
||||
<td class="py-3 px-4">收购徐州博康,PSPI量产</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-red-500/20 text-red-400">高</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-purple-400">鼎龙股份</td>
|
||||
<td class="py-3 px-4">PSPI</td>
|
||||
<td class="py-3 px-4">日本东丽、旭化成主导</td>
|
||||
<td class="py-3 px-4">PSPI产品国内领先</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-red-500/20 text-red-400">高</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-green-400">华海诚科</td>
|
||||
<td class="py-3 px-4">环氧塑封料EMC</td>
|
||||
<td class="py-3 px-4">日本住友电工、力森诺科主导</td>
|
||||
<td class="py-3 px-4">H1营收占比92.8%</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-green-500/20 text-green-400">低</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- High Performance Ceramics -->
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<h3 class="text-2xl font-bold mb-6 flex items-center">
|
||||
<i class="fas fa-cube mr-3 text-purple-400"></i>
|
||||
高性能陶瓷板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">股票代码</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">细分领域</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">日本产业地位</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">技术优势</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">风险等级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-purple-400">国瓷材料</td>
|
||||
<td class="py-3 px-4">MLCC陶瓷粉体</td>
|
||||
<td class="py-3 px-4">日本京瓷、TDK主导</td>
|
||||
<td class="py-3 px-4">全球MLCC粉体龙头</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-green-500/20 text-green-400">低</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-purple-400">三环集团</td>
|
||||
<td class="py-3 px-4">陶瓷电容</td>
|
||||
<td class="py-3 px-4">日本京瓷、TDK主导</td>
|
||||
<td class="py-3 px-4">国内MLCC龙头</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-green-500/20 text-green-400">低</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-purple-400">中瓷电子</td>
|
||||
<td class="py-3 px-4">电子陶瓷外壳</td>
|
||||
<td class="py-3 px-4">日本京瓷、TDK主导</td>
|
||||
<td class="py-3 px-4">军用电子陶瓷领先</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-yellow-500/20 text-yellow-400">中等</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Related Stocks -->
|
||||
<div class="gradient-border">
|
||||
<div class="gradient-border-inner">
|
||||
<h3 class="text-2xl font-bold mb-6 flex items-center">
|
||||
<i class="fas fa-water mr-3 text-cyan-400"></i>
|
||||
其他相关板块
|
||||
</h3>
|
||||
<div class="stock-table">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">股票代码</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">细分领域</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">关联逻辑</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">实际受益度</th>
|
||||
<th class="text-left py-3 px-4 font-semibold text-gray-300">投资建议</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-cyan-400">中水渔业</td>
|
||||
<td class="py-3 px-4">水产养殖</td>
|
||||
<td class="py-3 px-4">对日出口占比10%</td>
|
||||
<td class="py-3 px-4"><span class="text-red-400">受损方</span></td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-red-500/20 text-red-400">规避</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-cyan-400">乐凯胶片</td>
|
||||
<td class="py-3 px-4">偏光片TAC膜</td>
|
||||
<td class="py-3 px-4">唯一国产TFT-TAC膜</td>
|
||||
<td class="py-3 px-4">技术突破但规模小</td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-yellow-500/20 text-yellow-400">观察</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800 hover:bg-gray-800/50 transition-colors">
|
||||
<td class="py-3 px-4 font-medium text-cyan-400">中国稀土</td>
|
||||
<td class="py-3 px-4">稀土资源</td>
|
||||
<td class="py-3 px-4">潜在反制工具</td>
|
||||
<td class="py-3 px-4"><span class="text-green-400">高</span></td>
|
||||
<td class="py-3 px-4"><span class="px-2 py-1 rounded text-xs bg-green-500/20 text-green-400">关注</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Risk Analysis -->
|
||||
<div class="py-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-b from-gray-900 to-gray-950">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-12 text-center">
|
||||
<span class="bg-gradient-to-r from-red-400 to-orange-400 bg-clip-text text-transparent">
|
||||
风险提示与挑战
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="glass-morphism rounded-2xl p-6 risk-high hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
||||
</div>
|
||||
<h3 class="ml-3 text-lg font-semibold">技术风险</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-3">
|
||||
光刻胶需12-18个月验证期,国产良率低、价格高20-30%,若日方断供将导致停产风险
|
||||
</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
关键指标:良率稳定性 < 90%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 risk-high hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-sync-alt text-red-400"></i>
|
||||
</div>
|
||||
<h3 class="ml-3 text-lg font-semibold">反制螺旋风险</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-3">
|
||||
日本可能对半导体设备、精密仪器实施出口管制,影响长江存储、中芯国际扩产计划
|
||||
</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
关键指标:东京电子设备出口许可
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 risk-medium hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-dollar-sign text-yellow-400"></i>
|
||||
</div>
|
||||
<h3 class="ml-3 text-lg font-semibold">商业化风险</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-3">
|
||||
规模不经济导致国产替代成本高,晶圆厂替换动力不足,商业化进程缓慢
|
||||
</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
关键指标:国产替代成本差异 > 15%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 risk-medium hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-users text-yellow-400"></i>
|
||||
</div>
|
||||
<h3 class="ml-3 text-lg font-semibold">客户集中度风险</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-3">
|
||||
华海诚科等公司前五大客户占比超70%,若终端需求下滑将大幅影响业绩
|
||||
</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
关键指标:CR5 > 70%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 risk-low hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt text-green-400"></i>
|
||||
</div>
|
||||
<h3 class="ml-3 text-lg font-semibold">政策持续性</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-3">
|
||||
国产替代政策具有长期性,即使地缘缓和,产业链安全仍是核心诉求
|
||||
</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
关键指标:"十四五"规划延续性
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 risk-low hover-card">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-chart-line text-green-400"></i>
|
||||
</div>
|
||||
<h3 class="ml-3 text-lg font-semibold">市场需求确定</h3>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-3">
|
||||
半导体、新能源等下游需求旺盛,为国产替代提供确定的市场空间
|
||||
</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
关键指标:半导体CAPEX增速 > 10%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Investment Strategy -->
|
||||
<div class="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="gradient-border mb-12">
|
||||
<div class="gradient-border-inner text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
<span class="bg-gradient-to-r from-emerald-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
投资策略与操作建议
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-gray-400 text-lg">
|
||||
当前阶段,观望优于参与。等待政策验证,不赌情绪溢价
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
<div class="glass-morphism rounded-2xl p-6 hover-card">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 mb-4">
|
||||
<i class="fas fa-check-circle text-white text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">重点配置</h3>
|
||||
<p class="text-sm text-gray-400">资源管制 > 材料替代 > 水产出口</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-green-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">中国稀土(资源定价权)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-green-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">华海诚科(EMC龙头)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-green-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">鼎龙股份(PSPI量产)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 hover-card">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 mb-4">
|
||||
<i class="fas fa-eye text-white text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">观察等待</h3>
|
||||
<p class="text-sm text-gray-400">技术突破但规模尚小</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-yellow-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">彤程新材(光刻胶验证)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-yellow-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">乐凯胶片(TAC膜研发)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-yellow-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">南大光电(ArF进展)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 hover-card">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-r from-red-500 to-pink-500 mb-4">
|
||||
<i class="fas fa-times-circle text-white text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">坚决规避</h3>
|
||||
<p class="text-sm text-gray-400">逻辑错误,将直接受损</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-red-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">中水渔业(水产出口)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-red-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">国联水产(对日依赖)</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-red-400 rounded-full mr-3"></span>
|
||||
<span class="text-sm">獐子岛(贸易风险)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="glass-morphism rounded-2xl p-8">
|
||||
<h3 class="text-2xl font-bold mb-6">关键跟踪指标</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold number-stat mb-2">≥2次/月</div>
|
||||
<div class="text-sm text-gray-400">部级官员提及反制</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold number-stat mb-2">≥1项</div>
|
||||
<div class="text-sm text-gray-400">贸易救济立案</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold number-stat mb-2">>30天</div>
|
||||
<div class="text-sm text-gray-400">出口许可周期</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold number-stat mb-2">>15%</div>
|
||||
<div class="text-sm text-gray-400">概念股换手率</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-12 px-4 border-t border-gray-800">
|
||||
<div class="max-w-7xl mx-auto text-center">
|
||||
<p class="text-gray-500 text-sm">
|
||||
* 本分析基于公开信息整理,不构成投资建议。投资有风险,决策需谨慎。
|
||||
</p>
|
||||
<p class="text-gray-600 text-xs mt-4">
|
||||
数据来源:外交部、产业库、公司财报、机构调研 | 更新时间:2025年11月
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Add smooth scroll behavior
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add intersection observer for fade-in animations
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.hover-card').forEach(card => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
card.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||||
observer.observe(card);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
604
public/htmls/忆阻器.html
Normal file
604
public/htmls/忆阻器.html
Normal file
@@ -0,0 +1,604 @@
|
||||
我将为您生成一个专业、详实的忆阻器概念分析页面,融合金融分析深度与现代前端美学。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>忆阻器 - 下一代AI硬件的颠覆性力量</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.tech-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tech-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
background: linear-gradient(180deg, transparent, #667eea, #764ba2, transparent);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.stock-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stock-table th {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
animation: glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from { box-shadow: 0 0 5px #ef4444; }
|
||||
to { box-shadow: 0 0 20px #ef4444, 0 0 30px #ef4444; }
|
||||
}
|
||||
|
||||
.hero-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-gradient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 30s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tech-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stock-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
.tech-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Hero Section -->
|
||||
<header class="hero-gradient text-white relative">
|
||||
<div class="container mx-auto px-4 py-16 relative z-10">
|
||||
<div class="text-center">
|
||||
<div class="inline-block mb-4">
|
||||
<span class="glass-effect px-4 py-2 rounded-full text-sm">
|
||||
<i class="fas fa-microchip mr-2"></i>前沿科技 · 金融洞察
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-6xl font-bold mb-6">
|
||||
忆阻器
|
||||
<span class="block text-3xl md:text-4xl mt-2 opacity-90">
|
||||
Memristor - 破解AI算力能耗之谜
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-xl max-w-3xl mx-auto mb-8 opacity-90">
|
||||
第四代无源电路元件,存算一体架构核心,将AI芯片能耗降低57.2%
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<span class="bg-white/20 px-4 py-2 rounded-lg">
|
||||
<i class="fas fa-chart-line mr-2"></i>Gartner技术成熟度:期望膨胀期
|
||||
</span>
|
||||
<span class="bg-white/20 px-4 py-2 rounded-lg">
|
||||
<i class="fas fa-fire mr-2"></i>市场热度:脉冲式上升
|
||||
</span>
|
||||
<span class="bg-white/20 px-4 py-2 rounded-lg">
|
||||
<i class="fas fa-shield-alt mr-2"></i>投资策略:早布局 · 长周期
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 核心观点摘要 -->
|
||||
<section class="py-16 bg-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="text-4xl font-bold text-center mb-12 gradient-text">
|
||||
核心观点摘要
|
||||
</h2>
|
||||
|
||||
<div class="tech-grid">
|
||||
<div class="tech-card bg-gradient-to-br from-purple-50 to-indigo-50 p-6 rounded-xl border border-purple-200">
|
||||
<div class="text-purple-600 mb-4">
|
||||
<i class="fas fa-bolt text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">技术突破验证期</h3>
|
||||
<p class="text-gray-700">
|
||||
已从理论验证进入工程化突破关键期,AI芯片能耗锐减57.2%,脑机接口实现四自由度操控
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tech-card bg-gradient-to-br from-blue-50 to-cyan-50 p-6 rounded-xl border border-blue-200">
|
||||
<div class="text-blue-600 mb-4">
|
||||
<i class="fas fa-industry text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">0到1的幼稚期</h3>
|
||||
<p class="text-gray-700">
|
||||
产业整体处于"0到1"幼稚期,代工厂缺失构成最大瓶颈,创业公司被迫自建产线
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tech-card bg-gradient-to-br from-green-50 to-emerald-50 p-6 rounded-xl border border-green-200">
|
||||
<div class="text-green-600 mb-4">
|
||||
<i class="fas fa-chart-line text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">研报零覆盖</h3>
|
||||
<p class="text-gray-700">
|
||||
市场关注度极低,6份研报零提及,存在巨大预期差,属于典型的"高壁垒、长周期"主题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tech-card bg-gradient-to-br from-orange-50 to-red-50 p-6 rounded-xl border border-orange-200">
|
||||
<div class="text-orange-600 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-3">核心风险</h3>
|
||||
<p class="text-gray-700">
|
||||
代工厂断链、材料可靠性、技术路线竞争,从实验室到商业化仍需跨越"死亡谷"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 技术突破时间轴 -->
|
||||
<section class="py-16 bg-gray-100">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="text-4xl font-bold text-center mb-12">
|
||||
<span class="gradient-text">技术突破时间轴</span>
|
||||
</h2>
|
||||
|
||||
<div class="relative max-w-4xl mx-auto">
|
||||
<div class="timeline-line absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-1 h-full"></div>
|
||||
|
||||
<!-- 2023年5月 -->
|
||||
<div class="relative flex items-center mb-12">
|
||||
<div class="absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-4 h-4 bg-indigo-600 rounded-full pulse-dot"></div>
|
||||
<div class="ml-16 md:ml-0 md:w-1/2 md:pr-8 md:text-right">
|
||||
<div class="bg-white p-4 rounded-lg shadow-lg">
|
||||
<h3 class="font-semibold text-indigo-600">2023年5月29日</h3>
|
||||
<p class="text-gray-700">中国移动联合清华大学完成业界首次忆阻器存算一体芯片端到端技术验证</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2025年2月 -->
|
||||
<div class="relative flex items-center mb-12 md:flex-row-reverse">
|
||||
<div class="absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-4 h-4 bg-purple-600 rounded-full pulse-dot"></div>
|
||||
<div class="ml-16 md:ml-0 md:w-1/2 md:pl-8">
|
||||
<div class="bg-white p-4 rounded-lg shadow-lg">
|
||||
<h3 class="font-semibold text-purple-600">2025年2月17日</h3>
|
||||
<p class="text-gray-700">清华-天大团队在《自然·电子》发表基于忆阻器的"双环路"脑机接口系统</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2025年7月 -->
|
||||
<div class="relative flex items-center mb-12">
|
||||
<div class="absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-4 h-4 bg-green-600 rounded-full pulse-dot"></div>
|
||||
<div class="ml-16 md:ml-0 md:w-1/2 md:pr-8 md:text-right">
|
||||
<div class="bg-white p-4 rounded-lg shadow-lg">
|
||||
<h3 class="font-semibold text-green-600">2025年7月29日</h3>
|
||||
<p class="text-gray-700">福晶科技披露少量供应镍酸锂晶体,忆阻器关键功能层材料量产突破</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2025年11月 -->
|
||||
<div class="relative flex items-center mb-12">
|
||||
<div class="absolute left-8 md:left-1/2 transform md:-translate-x-1/2 w-4 h-4 bg-red-600 rounded-full pulse-dot"></div>
|
||||
<div class="ml-16 md:ml-0 md:w-1/2 md:pr-8 md:text-right">
|
||||
<div class="bg-white p-4 rounded-lg shadow-lg">
|
||||
<h3 class="font-semibold text-red-600">2025年11月17日</h3>
|
||||
<p class="text-gray-700">港大团队宣布忆阻器AI芯片能耗锐减57.2%,能效比展现颠覆性潜力</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 产业链图谱 -->
|
||||
<section class="py-16 bg-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="text-4xl font-bold text-center mb-12 gradient-text">
|
||||
产业链图谱
|
||||
</h2>
|
||||
|
||||
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl p-8 mb-8">
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="bg-white rounded-lg p-6 shadow-md">
|
||||
<i class="fas fa-layer-group text-4xl text-indigo-600 mb-4"></i>
|
||||
<h3 class="font-semibold text-lg mb-2">上游:材料与设备</h3>
|
||||
<p class="text-sm text-gray-600">功能材料、制造设备、EDA工具</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="text-sm bg-indigo-100 rounded px-2 py-1">天通股份(镍酸锂薄膜)</div>
|
||||
<div class="text-sm bg-indigo-100 rounded px-2 py-1">福晶科技(晶体供应)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="bg-white rounded-lg p-6 shadow-md">
|
||||
<i class="fas fa-microchip text-4xl text-purple-600 mb-4"></i>
|
||||
<h3 class="font-semibold text-lg mb-2">中游:器件与芯片</h3>
|
||||
<p class="text-sm text-gray-600">技术验证、设计制造、代工厂</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="text-sm bg-purple-100 rounded px-2 py-1">亿铸科技(POC芯片)</div>
|
||||
<div class="text-sm bg-red-100 rounded px-2 py-1">代工厂:空缺(核心瓶颈)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="bg-white rounded-lg p-6 shadow-md">
|
||||
<i class="fas fa-robot text-4xl text-green-600 mb-4"></i>
|
||||
<h3 class="font-semibold text-lg mb-2">下游:系统与应用</h3>
|
||||
<p class="text-sm text-gray-600">脑机接口、AI芯片、边缘设备</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="text-sm bg-green-100 rounded px-2 py-1">中国移动(边缘计算)</div>
|
||||
<div class="text-sm bg-green-100 rounded px-2 py-1">清华-天大(脑机接口)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 股票数据表格 -->
|
||||
<section class="py-16 bg-gray-100">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="text-4xl font-bold text-center mb-12 gradient-text">
|
||||
核心相关公司
|
||||
</h2>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="stock-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">股票名称</th>
|
||||
<th class="px-4 py-3 text-left">产业链定位</th>
|
||||
<th class="px-4 py-3 text-left">具体项目</th>
|
||||
<th class="px-4 py-3 text-left">信源</th>
|
||||
<th class="px-4 py-3 text-left">纯度评级</th>
|
||||
<th class="px-4 py-3 text-left">关键逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-semibold text-indigo-600">天通股份</td>
|
||||
<td class="px-4 py-3">镍酸锂晶片薄膜(材料)</td>
|
||||
<td class="px-4 py-3">大尺寸镍酸锂晶片</td>
|
||||
<td class="px-4 py-3">互动/研报</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="bg-yellow-500 text-white px-2 py-1 rounded">★★★★★</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">销量国内第一,受益于忆阻器放量的核心材料商</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-semibold text-indigo-600">盛视科技</td>
|
||||
<td class="px-4 py-3">参股亿铸科技(3.42%)</td>
|
||||
<td class="px-4 py-3">基于忆阻器ReRAM的POC芯片</td>
|
||||
<td class="px-4 py-3">公告</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="bg-blue-500 text-white px-2 py-1 rounded">★★★☆☆</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">财务投资+业务协同,参股国内领先忆阻器芯片公司</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-semibold text-indigo-600">中国移动</td>
|
||||
<td class="px-4 py-3">产品验证方</td>
|
||||
<td class="px-4 py-3">忆阻器存算一体芯片端到端技术验证</td>
|
||||
<td class="px-4 py-3">新闻</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="bg-blue-500 text-white px-2 py-1 rounded">★★★☆☆</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">央企龙头,完成技术验证,具备采购拉动能力</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-semibold text-indigo-600">福晶科技</td>
|
||||
<td class="px-4 py-3">镍酸锂晶体(材料)</td>
|
||||
<td class="px-4 py-3">少量供应镍酸锂晶体</td>
|
||||
<td class="px-4 py-3">互动</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="bg-orange-500 text-white px-2 py-1 rounded">★★☆☆☆</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">少量供应,非核心业务,贡献有限</td>
|
||||
</tr>
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-semibold text-indigo-600">金百泽</td>
|
||||
<td class="px-4 py-3">设计制造服务</td>
|
||||
<td class="px-4 py-3">高校忆阻器研究的电子服务</td>
|
||||
<td class="px-4 py-3">互动</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="bg-orange-500 text-white px-2 py-1 rounded">★★☆☆☆</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">"卖铲人"角色,为学术团队提供原型服务</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-semibold text-indigo-600">风华高科</td>
|
||||
<td class="px-4 py-3">产品</td>
|
||||
<td class="px-4 py-3">忆阻器研究</td>
|
||||
<td class="px-4 py-3">互动</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="bg-gray-500 text-white px-2 py-1 rounded">★☆☆☆☆</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">明确无专项投入,概念纯度最低</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 风险与挑战 -->
|
||||
<section class="py-16 bg-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="text-4xl font-bold text-center mb-12 gradient-text">
|
||||
核心风险与挑战
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div class="bg-red-50 rounded-xl p-6 border-l-4 border-red-500">
|
||||
<h3 class="text-xl font-semibold text-red-700 mb-4">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>技术风险
|
||||
</h3>
|
||||
<ul class="space-y-3 text-gray-700">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-red-500 mt-1 mr-2"></i>
|
||||
<span>代工厂缺失:创业公司被迫自建产线,良率可能<30%</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-red-500 mt-1 mr-2"></i>
|
||||
<span>材料可靠性:循环寿命仅10⁵次,远低于DRAM的10¹⁵次</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-red-500 mt-1 mr-2"></i>
|
||||
<span>集成密度:当前1Kb-1Mb,商业需>1Gb,差3个数量级</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 rounded-xl p-6 border-l-4 border-orange-500">
|
||||
<h3 class="text-xl font-semibold text-orange-700 mb-4">
|
||||
<i class="fas fa-coins mr-2"></i>商业化风险
|
||||
</h3>
|
||||
<ul class="space-y-3 text-gray-700">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-orange-500 mt-1 mr-2"></i>
|
||||
<span>成本倒挂:自建产线成本>$100,边缘AI芯片仅$5-10</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-orange-500 mt-1 mr-2"></i>
|
||||
<span>市场错配:脑机接口市场规模<1亿,无法支撑量产</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-orange-500 mt-1 mr-2"></i>
|
||||
<span>路线竞争:SRAM/MRAM/PCRAM多路线竞争,忆阻器无绝对优势</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 rounded-xl p-6 border-l-4 border-yellow-500">
|
||||
<h3 class="text-xl font-semibold text-yellow-700 mb-4">
|
||||
<i class="fas fa-gavel mr-2"></i>政策与竞争风险
|
||||
</h3>
|
||||
<ul class="space-y-3 text-gray-700">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-yellow-500 mt-1 mr-2"></i>
|
||||
<span>政策优先度:未在"卡脖子"清单明确提及</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-yellow-500 mt-1 mr-2"></i>
|
||||
<span>国际竞争:IBM/惠普专利储备领先,可能封锁代工渠道</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-yellow-500 mt-1 mr-2"></i>
|
||||
<span>知识产权:基础专利到期,但工艺改进专利仍在快速申请</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 rounded-xl p-6 border-l-4 border-purple-500">
|
||||
<h3 class="text-xl font-semibold text-purple-700 mb-4">
|
||||
<i class="fas fa-chart-line mr-2"></i>投资风险
|
||||
</h3>
|
||||
<ul class="space-y-3 text-gray-700">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-purple-500 mt-1 mr-2"></i>
|
||||
<span>预期差:新闻乐观叙事与路演负面现实形成矛盾</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-purple-500 mt-1 mr-2"></i>
|
||||
<span>信息真空:0篇研报覆盖,财务模型无法建立</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-times-circle text-purple-500 mt-1 mr-2"></i>
|
||||
<span>概念混淆:市场存在大量"伪概念股"需仔细甄别</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投资建议 -->
|
||||
<section class="py-16 bg-gradient-to-br from-indigo-600 to-purple-600 text-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="text-4xl font-bold text-center mb-12">
|
||||
投资策略建议
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div class="glass-effect rounded-xl p-6">
|
||||
<h3 class="text-2xl font-bold mb-4">
|
||||
<i class="fas fa-lightbulb mr-2"></i>当前策略
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
轻仓配置天通股份,作为"不对称期权"。仓位控制2-3%,向下有底,向上空间巨大。
|
||||
</p>
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<p class="text-sm">
|
||||
天通股份作为材料龙头,无论技术路线如何,薄膜外延材料都是刚性需求。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-xl p-6">
|
||||
<h3 class="text-2xl font-bold mb-4">
|
||||
<i class="fas fa-rocket mr-2"></i>加仓条件
|
||||
</h3>
|
||||
<ul class="space-y-2 mb-4">
|
||||
<li>✓ 中芯国际宣布忆阻器工艺平台</li>
|
||||
<li>✓ 天通股份单季采购额>1000万</li>
|
||||
<li>✓ 科技部设立忆阻器重大专项</li>
|
||||
</ul>
|
||||
<div class="bg-green-500/20 rounded-lg p-4">
|
||||
<p class="text-sm">
|
||||
满足任一条件,仓位可提升至5-8%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-xl p-6">
|
||||
<h3 class="text-2xl font-bold mb-4">
|
||||
<i class="fas fa-shield-alt mr-2"></i>风控纪律
|
||||
</h3>
|
||||
<p class="mb-4">
|
||||
若2年内未现代工厂合作,或亿铸科技等头部公司倒闭,应清仓离场。
|
||||
</p>
|
||||
<div class="bg-red-500/20 rounded-lg p-4">
|
||||
<p class="text-sm">
|
||||
保持70%观察、20%研究、10%参与的"狙击手"姿态
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<p class="text-xl opacity-90">
|
||||
忆阻器的命运不取决于技术多先进,而取决于产业生态能否在2026年前疏通
|
||||
</p>
|
||||
<div class="mt-4 flex justify-center gap-4">
|
||||
<span class="bg-white/20 px-4 py-2 rounded-lg">
|
||||
<i class="fas fa-clock mr-2"></i>耐心等待基本面质变
|
||||
</span>
|
||||
<span class="bg-white/20 px-4 py-2 rounded-lg">
|
||||
<i class="fas fa-chart-pie mr-2"></i>不宜重仓 · 适合配置
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 text-gray-300 py-8">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
本页面基于公开信息整理,不构成投资建议
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
数据来源:新闻、路演、公司公告 | 更新时间:2025年11月
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// 添加滚动动画
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.tech-card, section').forEach(el => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(20px)';
|
||||
el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// 平滑滚动
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
document.querySelector(this.getAttribute('href')).scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
736
public/htmls/林地资源.html
Normal file
736
public/htmls/林地资源.html
Normal file
@@ -0,0 +1,736 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>林地资源:战略资产的三重价值重构</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-morphism {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(99, 102, 241, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.number-highlight {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.sticky-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 导航栏 -->
|
||||
<div class="sticky-nav shadow-lg">
|
||||
<div class="navbar bg-base-100 px-4 lg:px-8">
|
||||
<div class="flex-1">
|
||||
<a href="#" class="btn btn-ghost text-xl font-bold gradient-text">
|
||||
<i class="fas fa-tree mr-2"></i>林地资源分析
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<button class="btn btn-circle btn-ghost">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</button>
|
||||
<button class="btn btn-circle btn-ghost">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative overflow-hidden">
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<div class="glass-morphism rounded-3xl p-8 lg:p-12">
|
||||
<div class="flex flex-col lg:flex-row items-center gap-8">
|
||||
<div class="flex-1 text-white">
|
||||
<div class="badge badge-warning badge-lg mb-4">战略资产重构</div>
|
||||
<h1 class="text-4xl lg:text-6xl font-bold mb-6">
|
||||
林地资源:从传统资产到<span class="text-yellow-300">三重价值</span>跃迁
|
||||
</h1>
|
||||
<p class="text-lg lg:text-xl mb-8 opacity-90">
|
||||
政策市场化改革 + 产业链上游紧缩 + 碳汇价值商业化,开启林地资源新时代
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="stat bg-white/20 rounded-lg px-4 py-3">
|
||||
<div class="stat-title text-white/80">2024年</div>
|
||||
<div class="stat-value text-2xl text-white">政策破冰期</div>
|
||||
</div>
|
||||
<div class="stat bg-white/20 rounded-lg px-4 py-3">
|
||||
<div class="stat-title text-white/80">2025年</div>
|
||||
<div class="stat-value text-2xl text-white">碳汇元年</div>
|
||||
</div>
|
||||
<div class="stat bg-white/20 rounded-lg px-4 py-3">
|
||||
<div class="stat-title text-white/80">2027年</div>
|
||||
<div class="stat-value text-2xl text-white">价值爆发期</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="bg-white/10 rounded-2xl p-6">
|
||||
<canvas id="trendChart" width="400" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 时间轴 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">概念事件与背景时间轴</h2>
|
||||
<div class="timeline">
|
||||
<div class="relative">
|
||||
<!-- 时间轴线 -->
|
||||
<div class="absolute left-8 top-0 bottom-0 w-0.5 bg-white/30"></div>
|
||||
|
||||
<!-- 时间节点 -->
|
||||
<div class="relative flex items-center mb-8">
|
||||
<div class="timeline-dot absolute left-8 w-4 h-4 bg-yellow-400 rounded-full -translate-x-1/2"></div>
|
||||
<div class="ml-16 glass-morphism rounded-xl p-6 card-hover flex-1">
|
||||
<div class="text-yellow-400 font-bold text-lg mb-2">2024年6月</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">政策破冰期</h3>
|
||||
<p class="text-white/80">国家林草局印发《集体林地经营权流转管理办法》,确立市场化机制</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center mb-8">
|
||||
<div class="timeline-dot absolute left-8 w-4 h-4 bg-blue-400 rounded-full -translate-x-1/2"></div>
|
||||
<div class="ml-16 glass-morphism rounded-xl p-6 card-hover flex-1">
|
||||
<div class="text-blue-400 font-bold text-lg mb-2">2023-2024年</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">产业觉醒期</h3>
|
||||
<p class="text-white/80">全球新增商品浆产能超800万吨,木片资源日趋紧张</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center mb-8">
|
||||
<div class="timeline-dot absolute left-8 w-4 h-4 bg-green-400 rounded-full -translate-x-1/2"></div>
|
||||
<div class="ml-16 glass-morphism rounded-xl p-6 card-hover flex-1">
|
||||
<div class="text-green-400 font-bold text-lg mb-2">2024年9月10日</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">碳汇商业化元年</h3>
|
||||
<p class="text-white/80">CCER交易规则最终确定,岳阳林纸中标云南254.4万亩林业碳汇项目</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center">
|
||||
<div class="timeline-dot absolute left-8 w-4 h-4 bg-red-400 rounded-full -translate-x-1/2"></div>
|
||||
<div class="ml-16 glass-morphism rounded-xl p-6 card-hover flex-1">
|
||||
<div class="text-red-400 font-bold text-lg mb-2">2024年</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">地缘政治映射</h3>
|
||||
<p class="text-white/80">印尼通过林地许可查封部分镍矿,林地成为资源国调控工具</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心观点 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">核心观点摘要</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="glass-morphism rounded-2xl p-6 card-hover">
|
||||
<div class="text-6xl mb-4">📊</div>
|
||||
<h3 class="text-2xl font-bold text-white mb-3">市场认知严重滞后</h3>
|
||||
<p class="text-white/80 mb-4">10篇主流研报中0篇涉及林地资源,关注度处于冰点</p>
|
||||
<div class="flex gap-2">
|
||||
<span class="badge badge-error">预期差极大</span>
|
||||
<span class="badge badge-warning">主题发酵期</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 card-hover">
|
||||
<div class="text-6xl mb-4">💰</div>
|
||||
<h3 class="text-2xl font-bold text-white mb-3">三重价值重构</h3>
|
||||
<p class="text-white/80 mb-4">造纸产业链成本护城河 + 双碳战略绿色金融资产 + 地缘政策缓冲器</p>
|
||||
<div class="flex gap-2">
|
||||
<span class="badge badge-success">成本优势</span>
|
||||
<span class="badge badge-info">碳汇价值</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-8 bg-gradient-to-r from-purple-500/20 to-pink-500/20 rounded-2xl border border-white/20">
|
||||
<p class="text-xl text-white font-medium italic">
|
||||
"林地资源已不再是传统意义上的农林资产,而是造纸产业链的'成本护城河'、双碳战略下的'绿色金融资产'以及资源民族主义的'政策缓冲器'"
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心逻辑 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">三重核心驱动力</h2>
|
||||
<div class="space-y-8">
|
||||
<!-- 政策市场化 -->
|
||||
<div class="glass-morphism rounded-2xl p-8">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-12 h-12 bg-yellow-500 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-gavel text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">政策市场化改革</h3>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-4 mt-6">
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="text-yellow-400 font-semibold mb-2">确权到流转闭环</div>
|
||||
<p class="text-white/80 text-sm">林地可从分散农户集中至企业,实现规模化经营</p>
|
||||
</div>
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="text-yellow-400 font-semibold mb-2">金融属性激活</div>
|
||||
<p class="text-white/80 text-sm">经营权可抵押、可融资,资产估值转向现金流折现</p>
|
||||
</div>
|
||||
<div class="bg-white/10 rounded-lg p-4">
|
||||
<div class="text-yellow-400 font-semibold mb-2">流动性溢价</div>
|
||||
<p class="text-white/80 text-sm">拥有流转渠道的企业将获得流动性溢价</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产业链紧缩 -->
|
||||
<div class="glass-morphism rounded-2xl p-8">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-industry text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">产业链上游资源紧缩</h3>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-blue-400 mb-2">2027年前无新增项目</div>
|
||||
<p class="text-white/80">全球新增商品浆产能超800万吨,下一个大型项目需等到2027年</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-blue-400 mb-2">成本差100美元+</div>
|
||||
<p class="text-white/80">太阳纸业自制木片成本50-100美元/吨,外购价150-200美元/吨</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<div class="flex justify-between text-white/80 mb-2">太阳纸业老挝木片自供率</div>
|
||||
<div class="w-full bg-white/20 rounded-full h-6 progress-bar">
|
||||
<div class="bg-gradient-to-r from-blue-400 to-blue-600 h-6 rounded-full flex items-center justify-center text-white font-semibold" style="width: 60%">
|
||||
60% (2024年)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 碳汇价值 -->
|
||||
<div class="glass-morphism rounded-2xl p-8">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-leaf text-white text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white">碳汇价值商业化</h3>
|
||||
</div>
|
||||
<div class="stats shadow w-full">
|
||||
<div class="stat place-items-center bg-white/10 rounded-lg">
|
||||
<div class="stat-title">碳汇毛利率</div>
|
||||
<div class="stat-value text-green-400">70-80%</div>
|
||||
</div>
|
||||
<div class="stat place-items-center bg-white/10 rounded-lg">
|
||||
<div class="stat-title">岳阳林纸储备</div>
|
||||
<div class="stat-value text-green-400">8000万亩+</div>
|
||||
</div>
|
||||
<div class="stat place-items-center bg-white/10 rounded-lg">
|
||||
<div class="stat-title">CCER价格预期</div>
|
||||
<div class="stat-value text-green-400">年增30-40%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心公司对比 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">核心玩家深度对比</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full glass-morphism rounded-2xl overflow-hidden">
|
||||
<thead class="bg-white/20">
|
||||
<tr>
|
||||
<th class="text-white">公司</th>
|
||||
<th class="text-white">核心逻辑</th>
|
||||
<th class="text-white">资源规模</th>
|
||||
<th class="text-white">商业模式</th>
|
||||
<th class="text-white">竞争优势</th>
|
||||
<th class="text-white">投资价值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold text-yellow-400">太阳纸业</td>
|
||||
<td>林浆纸一体化</td>
|
||||
<td>老挝6万公顷</td>
|
||||
<td>木片自给降本</td>
|
||||
<td>16年深耕,成本优势</td>
|
||||
<td><span class="badge badge-success badge-lg">★★★★★</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold text-green-400">岳阳林纸</td>
|
||||
<td>林业碳汇第一股</td>
|
||||
<td>8000万亩储备</td>
|
||||
<td>碳汇高毛利</td>
|
||||
<td>央企背景,开发能力</td>
|
||||
<td><span class="badge badge-success badge-lg">★★★★☆</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold text-blue-400">仙鹤股份</td>
|
||||
<td>特种纸原料锁定</td>
|
||||
<td>100万亩桉树</td>
|
||||
<td>政府排他支持</td>
|
||||
<td>运输成本最低</td>
|
||||
<td><span class="badge badge-warning badge-lg">★★★★☆</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">永安林业</td>
|
||||
<td>纯资源持有</td>
|
||||
<td>114.1万亩</td>
|
||||
<td>传统采伐</td>
|
||||
<td>区域垄断</td>
|
||||
<td><span class="badge badge-warning badge-lg">★★☆☆☆</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">东珠生态</td>
|
||||
<td>碳汇开发服务</td>
|
||||
<td>421.5万亩</td>
|
||||
<td>轻资产模式</td>
|
||||
<td>快速扩张</td>
|
||||
<td><span class="badge badge-error badge-lg">★★☆☆☆</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 股票数据表格 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">林地资源相关股票数据</h2>
|
||||
<div class="glass-morphism rounded-2xl p-6 overflow-x-auto">
|
||||
<table class="table table-zebra w-full text-white">
|
||||
<thead class="bg-white/20">
|
||||
<tr>
|
||||
<th class="text-white">股票代码</th>
|
||||
<th class="text-white">股票名称</th>
|
||||
<th class="text-white">分类</th>
|
||||
<th class="text-white">项目详情</th>
|
||||
<th class="text-white">地理位置</th>
|
||||
<th class="text-white">股东背景</th>
|
||||
<th class="text-white">持股比例</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">岳阳林纸</td>
|
||||
<td>岳阳林纸</td>
|
||||
<td><span class="badge badge-info">自有林地</span></td>
|
||||
<td>公司拥有林业面积180万亩</td>
|
||||
<td>-</td>
|
||||
<td>国务院国资委</td>
|
||||
<td>30.9%</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">岳阳林纸</td>
|
||||
<td>岳阳林纸</td>
|
||||
<td><span class="badge badge-success">林业碳汇</span></td>
|
||||
<td>累计完成项目储备8246.79万亩</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">康欣新材</td>
|
||||
<td>康欣新材</td>
|
||||
<td><span class="badge badge-info">自有林地</span></td>
|
||||
<td>速生杨林地等138万余亩</td>
|
||||
<td>-</td>
|
||||
<td>无锡国资委</td>
|
||||
<td>19.86%</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">永安林业</td>
|
||||
<td>永安林业</td>
|
||||
<td><span class="badge badge-info">自有林地</span></td>
|
||||
<td>森林资源总面积114.1万亩</td>
|
||||
<td>福建永安市</td>
|
||||
<td>国务院国资委</td>
|
||||
<td>5.31%</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">福建金森</td>
|
||||
<td>福建金森</td>
|
||||
<td><span class="badge badge-info">自有林地</span></td>
|
||||
<td>森林资源面积近80万亩</td>
|
||||
<td>福建省将乐县</td>
|
||||
<td>将乐县财政局</td>
|
||||
<td>63.83%</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">平潭发展</td>
|
||||
<td>平潭发展</td>
|
||||
<td><span class="badge badge-info">自有林地</span></td>
|
||||
<td>自有林地约60万亩、托管约30万亩</td>
|
||||
<td>福建省平潭综合实验区</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">丰林集团</td>
|
||||
<td>丰林集团</td>
|
||||
<td><span class="badge badge-info">自有林地</span></td>
|
||||
<td>拥有近20万亩产权林地</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">东珠生态</td>
|
||||
<td>东珠生态</td>
|
||||
<td><span class="badge badge-success">林业碳汇</span></td>
|
||||
<td>开发面积林地421.5万亩</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">海南橡胶</td>
|
||||
<td>海南橡胶</td>
|
||||
<td><span class="badge badge-warning">其他</span></td>
|
||||
<td>公司胶园土地390万亩</td>
|
||||
<td>-</td>
|
||||
<td>海南国资委</td>
|
||||
<td>57.92%</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/10">
|
||||
<td class="font-bold">泉阳泉</td>
|
||||
<td>泉阳泉</td>
|
||||
<td><span class="badge badge-warning">其他</span></td>
|
||||
<td>控股股东拥有林地128.96万公顷</td>
|
||||
<td>-</td>
|
||||
<td>吉林国资委</td>
|
||||
<td>15.99%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 关键催化剂 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">关键催化剂</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="bg-gradient-to-br from-yellow-500/20 to-orange-500/20 glass-morphism rounded-2xl p-6 border border-yellow-500/30">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i class="fas fa-clock text-yellow-400 text-2xl"></i>
|
||||
<h3 class="text-xl font-bold text-white">近期催化剂(3-6个月)</h3>
|
||||
</div>
|
||||
<ul class="space-y-3 text-white/80">
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-circle text-xs mt-2 text-yellow-400"></i>
|
||||
<span>岳阳林纸2024年报:碳汇利润达5000万+</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-circle text-xs mt-2 text-yellow-400"></i>
|
||||
<span>CCER价格落地:80-100元/吨</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-circle text-xs mt-2 text-yellow-400"></i>
|
||||
<span>太阳纸业中报:木片自供率提升</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-circle text-xs mt-2 text-yellow-400"></i>
|
||||
<span>仙鹤股份:广西林地协议签署</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-br from-blue-500/20 to-purple-500/20 glass-morphism rounded-2xl p-6 border border-blue-500/30">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i class="fas fa-rocket text-blue-400 text-2xl"></i>
|
||||
<h3 class="text-xl font-bold text-white">长期发展路径(2025-2030)</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">1</div>
|
||||
<div>
|
||||
<div class="text-white font-semibold">2025-2026:商业模式兑现期</div>
|
||||
<div class="text-white/60 text-sm">木片自给率80%+,碳汇收入占比15-20%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center text-white font-bold">2</div>
|
||||
<div>
|
||||
<div class="text-white font-semibold">2027-2028:证券化与金融化</div>
|
||||
<div class="text-white/60 text-sm">林地经营权ABS、碳汇期货</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-pink-500 rounded-full flex items-center justify-center text-white font-bold">3</div>
|
||||
<div>
|
||||
<div class="text-white font-semibold">2029-2030:产业整合</div>
|
||||
<div class="text-white/60 text-sm">国内并购潮,海外基地复制</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 风险提示 -->
|
||||
<section class="container mx-auto px-4 py-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">潜在风险与挑战</h2>
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="glass-morphism rounded-2xl p-6 border-red-500/30 border">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-400 text-xl"></i>
|
||||
<h3 class="text-xl font-bold text-white">政策风险</h3>
|
||||
</div>
|
||||
<ul class="text-white/80 space-y-2 text-sm">
|
||||
<li>• CCER政策变动</li>
|
||||
<li>• 海外林地租约不确定性</li>
|
||||
<li>• 碳汇价格波动</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 border-orange-500/30 border">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i class="fas fa-tools text-orange-400 text-xl"></i>
|
||||
<h3 class="text-xl font-bold text-white">执行风险</h3>
|
||||
</div>
|
||||
<ul class="text-white/80 space-y-2 text-sm">
|
||||
<li>• 砍伐面积爬坡不及预期</li>
|
||||
<li>• 碳汇项目签发周期长</li>
|
||||
<li>• 本土化运营挑战</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass-morphism rounded-2xl p-6 border-yellow-500/30 border">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i class="fas fa-globe text-yellow-400 text-xl"></i>
|
||||
<h3 class="text-xl font-bold text-white">地缘风险</h3>
|
||||
</div>
|
||||
<ul class="text-white/80 space-y-2 text-sm">
|
||||
<li>• 资源民族主义蔓延</li>
|
||||
<li>• 政权更迭影响</li>
|
||||
<li>• 社区关系维护</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投资建议 -->
|
||||
<section class="container mx-auto px-4 py-12 mb-12">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">投资启示</h2>
|
||||
<div class="glass-morphism rounded-2xl p-8 bg-gradient-to-br from-green-500/20 to-blue-500/20 border-green-400/30 border">
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">阶段判断</h3>
|
||||
<p class="text-white/80 mb-4">
|
||||
当前处于"主题发酵期向基本面兑现期过渡"的关键节点。与纯主题概念不同,林地资源具备明确的成本节约和利润贡献。
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<span class="badge badge-success">PB普遍1-2倍</span>
|
||||
<span class="badge badge-info">修复空间>50%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">核心配置</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between bg-white/10 rounded-lg p-3">
|
||||
<span class="text-white font-semibold">第一顺位</span>
|
||||
<span class="text-yellow-400">太阳纸业</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/10 rounded-lg p-3">
|
||||
<span class="text-white font-semibold">第二顺位</span>
|
||||
<span class="text-green-400">岳阳林纸</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/10 rounded-lg p-3">
|
||||
<span class="text-white font-semibold">第三顺位</span>
|
||||
<span class="text-blue-400">仙鹤股份</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-6 bg-white/10 rounded-xl">
|
||||
<p class="text-xl text-white font-medium text-center">
|
||||
"2025年是林地资源基本面兑现元年,静待估值修复与戴维斯双击"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="glass-morphism py-8">
|
||||
<div class="container mx-auto px-4 text-center text-white/80">
|
||||
<p>© 2024 林地资源概念分析 | 数据来源:公开信息整理</p>
|
||||
<p class="mt-2 text-sm">投资有风险,入市需谨慎</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// 趋势图表
|
||||
const ctx = document.getElementById('trendChart').getContext('2d');
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)');
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0.1)');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['2023', '2024', '2025', '2026', '2027', '2028'],
|
||||
datasets: [{
|
||||
label: '林地资源价值指数',
|
||||
data: [100, 135, 180, 250, 350, 480],
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
backgroundColor: gradient,
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.8)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.8)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 平滑滚动
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
document.querySelector(this.getAttribute('href')).scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 滚动动画
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.card-hover').forEach(card => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
card.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||||
observer.observe(card);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
675
public/htmls/牛肉.html
Normal file
675
public/htmls/牛肉.html
Normal file
@@ -0,0 +1,675 @@
|
||||
我将为您创建一个专业且炫酷的牛肉概念投资分析页面。这个页面将整合所有深度insight、新闻数据和股票信息,采用现代化的设计风格。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>牛肉产业投资分析 - 周期反转与全球共振</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.dark-gradient {
|
||||
background: linear-gradient(180deg, #1a1c20 0%, #2d3436 100%);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
width: 2px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.scroll-smooth {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.neon-border {
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.5),
|
||||
inset 0 0 20px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.data-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-line {
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white scroll-smooth">
|
||||
<!-- Hero Section -->
|
||||
<section class="gradient-bg py-20 px-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center">
|
||||
<h1 class="text-5xl md:text-6xl font-bold mb-6 text-white">
|
||||
<i class="fas fa-chart-line mr-4"></i>牛肉产业投资分析
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-purple-100 mb-8">周期反转与全球共振的黄金投资机遇</p>
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-8">
|
||||
<span class="bg-white/20 px-6 py-3 rounded-full text-lg">
|
||||
<i class="fas fa-arrow-trend-up mr-2"></i>价格弹性: +70-80%
|
||||
</span>
|
||||
<span class="bg-white/20 px-6 py-3 rounded-full text-lg">
|
||||
<i class="fas fa-calendar mr-2"></i>周期长度: 2-3年
|
||||
</span>
|
||||
<span class="bg-white/20 px-6 py-3 rounded-full text-lg">
|
||||
<i class="fas fa-globe mr-2"></i>全球共振
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心观点摘要 -->
|
||||
<section class="py-16 px-6 dark-gradient">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-8 text-gradient">核心观点</h2>
|
||||
<div class="data-card rounded-2xl p-8 neon-border">
|
||||
<p class="text-xl leading-relaxed">
|
||||
当前牛肉概念处于<span class="text-purple-400 font-bold">周期反转初期</span>,驱动逻辑从政策主题转向基本面改善。
|
||||
<span class="text-purple-400 font-bold">未来2-3年景气度将持续上行</span>,价格弹性或超预期。
|
||||
</p>
|
||||
<div class="grid md:grid-cols-3 gap-6 mt-8">
|
||||
<div class="bg-gradient-to-r from-purple-600/20 to-pink-600/20 p-6 rounded-xl">
|
||||
<i class="fas fa-industry text-3xl text-purple-400 mb-3"></i>
|
||||
<h3 class="text-xl font-semibold mb-2">深度去化</h3>
|
||||
<p class="text-gray-300">母牛存栏降3% · 犊牛降8% · 产业根基受损</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-blue-600/20 to-cyan-600/20 p-6 rounded-xl">
|
||||
<i class="fas fa-shield-halved text-3xl text-blue-400 mb-3"></i>
|
||||
<h3 class="text-xl font-semibold mb-2">政策约束</h3>
|
||||
<p class="text-gray-300">商务部保障措施 · 关税加征 · 进口收缩</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-green-600/20 to-emerald-600/20 p-6 rounded-xl">
|
||||
<i class="fas fa-earth-americas text-3xl text-green-400 mb-3"></i>
|
||||
<h3 class="text-xl font-semibold mb-2">全球共振</h3>
|
||||
<p class="text-gray-300">国际价格涨40% · 美国供给缺口 · 巴西减产</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 时间轴 -->
|
||||
<section class="py-16 px-6 bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">核心事件时间轴</h2>
|
||||
<div class="relative">
|
||||
<div class="timeline-line"></div>
|
||||
|
||||
<!-- 2020-2022 -->
|
||||
<div class="flex items-center mb-12">
|
||||
<div class="w-1/2 md:pr-8 text-right">
|
||||
<div class="data-card p-6 rounded-xl card-hover">
|
||||
<h3 class="text-2xl font-bold mb-2 text-purple-400">2020-2022年</h3>
|
||||
<p class="text-lg">景气高点与产能扩张</p>
|
||||
<ul class="mt-3 text-gray-300 text-sm">
|
||||
<li>• 牛肉价格达历史峰值 86.21元/公斤</li>
|
||||
<li>• 2020-2024年产量CAGR 3.5%</li>
|
||||
<li>• 地方政府鼓励大规模补栏</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-600 rounded-full flex items-center justify-center pulse">
|
||||
<i class="fas fa-arrow-up text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 md:pl-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2023年 -->
|
||||
<div class="flex items-center mb-12">
|
||||
<div class="w-1/2 md:pr-8"></div>
|
||||
<div class="w-12 h-12 bg-red-600 rounded-full flex items-center justify-center pulse">
|
||||
<i class="fas fa-arrow-down text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 md:pl-8">
|
||||
<div class="data-card p-6 rounded-xl card-hover">
|
||||
<h3 class="text-2xl font-bold mb-2 text-red-400">2023年</h3>
|
||||
<p class="text-lg">供需反转,价格崩塌</p>
|
||||
<ul class="mt-3 text-gray-300 text-sm">
|
||||
<li>• 3月开启持续下跌,跌幅超13%</li>
|
||||
<li>• 能繁母牛存栏下降1.9%</li>
|
||||
<li>• 产能去化信号初现</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2024年 -->
|
||||
<div class="flex items-center mb-12">
|
||||
<div class="w-1/2 md:pr-8 text-right">
|
||||
<div class="data-card p-6 rounded-xl card-hover">
|
||||
<h3 class="text-2xl font-bold mb-2 text-orange-400">2024年</h3>
|
||||
<p class="text-lg">深度亏损,产业动摇</p>
|
||||
<ul class="mt-3 text-gray-300 text-sm">
|
||||
<li>• 连续8个月亏损超1000元/头</li>
|
||||
<li>• 65%以上养殖场户亏损</li>
|
||||
<li>• 12月商务部启动保障措施调查</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-600 rounded-full flex items-center justify-center pulse">
|
||||
<i class="fas fa-exclamation-triangle text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 md:pl-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- 2025年 -->
|
||||
<div class="flex items-center">
|
||||
<div class="w-1/2 md:pr-8"></div>
|
||||
<div class="w-12 h-12 bg-green-600 rounded-full flex items-center justify-center pulse">
|
||||
<i class="fas fa-chart-line text-white"></i>
|
||||
</div>
|
||||
<div class="w-1/2 md:pl-8">
|
||||
<div class="data-card p-6 rounded-xl card-hover">
|
||||
<h3 class="text-2xl font-bold mb-2 text-green-400">2025年</h3>
|
||||
<p class="text-lg">周期拐点显现</p>
|
||||
<ul class="mt-3 text-gray-300 text-sm">
|
||||
<li>• 1-2月进口量同比下降17.4%</li>
|
||||
<li>• 3月起价格淡季不淡,上涨8.67%</li>
|
||||
<li>• 11月预期政策落地,核心催化剂</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 供需与价格分析 -->
|
||||
<section class="py-16 px-6 dark-gradient">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-12">供需格局深度分析</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8 mb-12">
|
||||
<!-- 供给端 -->
|
||||
<div class="data-card p-8 rounded-2xl">
|
||||
<h3 class="text-2xl font-bold mb-6 text-purple-400">
|
||||
<i class="fas fa-boxes-stacked mr-3"></i>供给端变化
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span>能繁母牛存栏</span>
|
||||
<span class="text-red-400">↓ 3%</span>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded-full h-3">
|
||||
<div class="bg-gradient-to-r from-red-500 to-red-600 h-3 rounded-full" style="width: 3%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span>新生犊牛</span>
|
||||
<span class="text-red-400">↓ 8%</span>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded-full h-3">
|
||||
<div class="bg-gradient-to-r from-red-500 to-red-600 h-3 rounded-full" style="width: 8%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span>全国牛存栏</span>
|
||||
<span class="text-red-400">↓ 4.4%</span>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded-full h-3">
|
||||
<div class="bg-gradient-to-r from-red-500 to-red-600 h-3 rounded-full" style="width: 4.4%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span>进口量</span>
|
||||
<span class="text-red-400">↓ 17.4%</span>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded-full h-3">
|
||||
<div class="bg-gradient-to-r from-red-500 to-red-600 h-3 rounded-full" style="width: 17.4%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格预测 -->
|
||||
<div class="data-card p-8 rounded-2xl">
|
||||
<h3 class="text-2xl font-bold mb-6 text-green-400">
|
||||
<i class="fas fa-chart-column mr-3"></i>价格走势预测
|
||||
</h3>
|
||||
<canvas id="priceChart" width="400" height="200"></canvas>
|
||||
<div class="mt-6 space-y-2">
|
||||
<p class="text-sm"><span class="text-green-400">2025年:</span> 26-28元/公斤 (↑10-15%)</p>
|
||||
<p class="text-sm"><span class="text-yellow-400">2026年:</span> 32-35元/公斤 (↑30-40%)</p>
|
||||
<p class="text-sm"><span class="text-red-400">2027年:</span> 38-42元/公斤 (↑70-80%)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心催化剂 -->
|
||||
<section class="py-16 px-6 bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">关键催化剂</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="data-card p-6 rounded-xl text-center card-hover">
|
||||
<div class="text-5xl mb-4 text-purple-400">
|
||||
<i class="fas fa-gavel"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">政策落地</h3>
|
||||
<p class="text-gray-300">2025年11月商务部保障措施终裁</p>
|
||||
<div class="mt-4 text-2xl font-bold text-purple-400">核心催化</div>
|
||||
</div>
|
||||
|
||||
<div class="data-card p-6 rounded-xl text-center card-hover">
|
||||
<div class="text-5xl mb-4 text-blue-400">
|
||||
<i class="fas fa-snowflake"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">消费旺季</h3>
|
||||
<p class="text-gray-300">Q4传统旺季,需求环比提升30%</p>
|
||||
<div class="mt-4 text-2xl font-bold text-blue-400">季节催化</div>
|
||||
</div>
|
||||
|
||||
<div class="data-card p-6 rounded-xl text-center card-hover">
|
||||
<div class="text-5xl mb-4 text-green-400">
|
||||
<i class="fas fa-globe"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">海外涨价</h3>
|
||||
<p class="text-gray-300">国际价格已涨40%,传导效应显现</p>
|
||||
<div class="mt-4 text-2xl font-bold text-green-400">外部催化</div>
|
||||
</div>
|
||||
|
||||
<div class="data-card p-6 rounded-xl text-center card-hover">
|
||||
<div class="text-5xl mb-4 text-orange-400">
|
||||
<i class="fas fa-file-invoice-dollar"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">业绩预增</h3>
|
||||
<p class="text-gray-300">龙头Q3利润或增200%+</p>
|
||||
<div class="mt-4 text-2xl font-bold text-orange-400">业绩催化</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 产业链分析 -->
|
||||
<section class="py-16 px-6 dark-gradient">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-12">产业链价值分布</h2>
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<!-- 上游 -->
|
||||
<div class="data-card p-8 rounded-2xl text-center">
|
||||
<h3 class="text-2xl font-bold mb-4 text-purple-400">上游</h3>
|
||||
<p class="text-lg mb-4">种源与饲料</p>
|
||||
<div class="text-3xl font-bold mb-4">15-25% 毛利率</div>
|
||||
<div class="space-y-2 text-left">
|
||||
<p>• 天山生物(育种)</p>
|
||||
<p>• 禾丰股份、海大集团(饲料)</p>
|
||||
<p>• 福成股份、西部牧业(养殖)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中游 -->
|
||||
<div class="data-card p-8 rounded-2xl text-center">
|
||||
<h3 class="text-2xl font-bold mb-4 text-blue-400">中游</h3>
|
||||
<p class="text-lg mb-4">屠宰加工</p>
|
||||
<div class="text-3xl font-bold mb-4">6-13% 毛利率</div>
|
||||
<div class="space-y-2 text-left">
|
||||
<p>• 光明肉业(银蕨农场)</p>
|
||||
<p>• 得利斯(产能利用率关键)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下游 -->
|
||||
<div class="data-card p-8 rounded-2xl text-center">
|
||||
<h3 class="text-2xl font-bold mb-4 text-green-400">下游</h3>
|
||||
<p class="text-lg mb-4">品牌与渠道</p>
|
||||
<div class="text-3xl font-bold mb-4">25-35% 毛利率</div>
|
||||
<div class="space-y-2 text-left">
|
||||
<p>• 福成股份(肥牛品牌)</p>
|
||||
<p>• 光明肉业(银蕨品牌)</p>
|
||||
<p>• 味知香(预制菜)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心公司股票数据 -->
|
||||
<section class="py-16 px-6 bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">核心上市公司分析</h2>
|
||||
<div class="table-container">
|
||||
<table class="w-full data-card rounded-lg overflow-hidden">
|
||||
<thead class="sticky-header bg-gradient-to-r from-purple-600 to-pink-600">
|
||||
<tr>
|
||||
<th class="p-4 text-left">股票代码</th>
|
||||
<th class="p-4 text-left">股票名称</th>
|
||||
<th class="p-4 text-left">业务分类</th>
|
||||
<th class="p-4 text-left">核心业务</th>
|
||||
<th class="p-4 text-left">投资逻辑</th>
|
||||
<th class="p-4 text-center">推荐评级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">600965</td>
|
||||
<td class="p-4 font-bold text-green-400">福成股份</td>
|
||||
<td class="p-4"><span class="bg-purple-600/30 px-2 py-1 rounded">养殖</span></td>
|
||||
<td class="p-4 text-sm">三个养牛场,活牛存栏容纳能力达到四万头,2025年上半年活牛存栏3.44万头</td>
|
||||
<td class="p-4 text-sm">全产业链区域龙头,华北渠道强,"福成肥牛"品牌知名度高</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐⭐⭐⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">300313</td>
|
||||
<td class="p-4 font-bold text-blue-400">天山生物</td>
|
||||
<td class="p-4"><span class="bg-orange-600/30 px-2 py-1 rounded">育种</span></td>
|
||||
<td class="p-4 text-sm">我国唯一专业从事牛品种改良的上市公司,将打造自有鲜牛肉品牌</td>
|
||||
<td class="p-4 text-sm">稀缺育种标的,掌握核心种源,长期成长确定</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐⭐⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">600073</td>
|
||||
<td class="p-4 font-bold text-purple-400">光明肉业</td>
|
||||
<td class="p-4"><span class="bg-green-600/30 px-2 py-1 rounded">加工</span></td>
|
||||
<td class="p-4 text-sm">控股新西兰银蕨农场(市占率30%),2025Q1净利润同比+312%</td>
|
||||
<td class="p-4 text-sm">海外资源龙头,直接受益全球涨价,业绩弹性最大</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐⭐⭐⭐⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">002330</td>
|
||||
<td class="p-4 font-bold text-yellow-400">得利斯</td>
|
||||
<td class="p-4"><span class="bg-blue-600/30 px-2 py-1 rounded">加工</span></td>
|
||||
<td class="p-4 text-sm">主要业务之一为牛肉精深加工行业</td>
|
||||
<td class="p-4 text-sm">纯屠宰企业,当前仍亏损,产能利用率是关键</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">605089</td>
|
||||
<td class="p-4 font-bold text-cyan-400">味知香</td>
|
||||
<td class="p-4"><span class="bg-green-600/30 px-2 py-1 rounded">预制菜</span></td>
|
||||
<td class="p-4 text-sm">牛肉类产品占比较大(预制菜)</td>
|
||||
<td class="p-4 text-sm">受益消费升级,牛肉预制菜需求增长</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐⭐⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">300106</td>
|
||||
<td class="p-4 font-bold text-green-400">西部牧业</td>
|
||||
<td class="p-4"><span class="bg-purple-600/30 px-2 py-1 rounded">养殖</span></td>
|
||||
<td class="p-4 text-sm">建设肉牛专业育肥基地,与肉牛养殖专业合作社建立合作关系</td>
|
||||
<td class="p-4 text-sm">乳肉兼营,受益原奶+牛肉双周期</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐⭐⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700/50 transition">
|
||||
<td class="p-4">000061</td>
|
||||
<td class="p-4 font-bold text-red-400">农产品</td>
|
||||
<td class="p-4"><span class="bg-red-600/30 px-2 py-1 rounded">贸易</span></td>
|
||||
<td class="p-4 text-sm">引进的南美牛肉等核心品类的规模稳健增长</td>
|
||||
<td class="p-4 text-sm">进口贸易商,政策收紧直接受损,建议规避</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="text-2xl">⭐</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投资策略 -->
|
||||
<section class="py-16 px-6 dark-gradient">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">投资策略建议</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8 mb-12">
|
||||
<!-- 第一梯队 -->
|
||||
<div class="data-card p-8 rounded-2xl neon-border">
|
||||
<div class="text-3xl font-bold mb-4 text-green-400">第一梯队</div>
|
||||
<div class="text-xl mb-4">海外资源型(首选)</div>
|
||||
<div class="bg-green-600/20 p-4 rounded-lg mb-4">
|
||||
<p class="font-bold text-green-400 mb-2">核心标的:光明肉业</p>
|
||||
<p class="text-sm">逻辑最顺 · 弹性最大</p>
|
||||
<p class="text-sm mt-2">2027年利润或达5亿元,对应PE仅5-6倍</p>
|
||||
</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<p>• 仓位建议:60%</p>
|
||||
<p>• 持有周期:至2027年景气高点</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二梯队 -->
|
||||
<div class="data-card p-8 rounded-2xl neon-border">
|
||||
<div class="text-3xl font-bold mb-4 text-blue-400">第二梯队</div>
|
||||
<div class="text-xl mb-4">全产业链型(次优)</div>
|
||||
<div class="bg-blue-600/20 p-4 rounded-lg mb-4">
|
||||
<p class="font-bold text-blue-400 mb-2">核心标的:福成股份</p>
|
||||
<p class="text-sm">双轮驱动 · 品牌护城河</p>
|
||||
<p class="text-sm mt-2">存栏3.44万头具备扩张潜力</p>
|
||||
</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<p>• 仓位建议:30%</p>
|
||||
<p>• 持有周期:中期配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三梯队 -->
|
||||
<div class="data-card p-8 rounded-2xl neon-border">
|
||||
<div class="text-3xl font-bold mb-4 text-purple-400">第三梯队</div>
|
||||
<div class="text-xl mb-4">育种核心(长期)</div>
|
||||
<div class="bg-purple-600/20 p-4 rounded-lg mb-4">
|
||||
<p class="font-bold text-purple-400 mb-2">核心标的:天山生物</p>
|
||||
<p class="text-sm">产业升级 · 种源卡脖子</p>
|
||||
<p class="text-sm mt-2">技术壁垒高,业绩释放慢</p>
|
||||
</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<p>• 仓位建议:10%</p>
|
||||
<p>• 持有周期:卫星仓位</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 风险提示 -->
|
||||
<div class="bg-red-900/30 border border-red-600 p-6 rounded-xl">
|
||||
<h3 class="text-2xl font-bold mb-4 text-red-400">
|
||||
<i class="fas fa-triangle-exclamation mr-2"></i>风险提示
|
||||
</h3>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-bold mb-2 text-red-300">需求风险</h4>
|
||||
<ul class="text-sm text-gray-300 space-y-1">
|
||||
<li>• 消费降级导致高价蛋白需求萎缩</li>
|
||||
<li>• 餐饮端恢复不及预期</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold mb-2 text-red-300">政策风险</h4>
|
||||
<ul class="text-sm text-gray-300 space-y-1">
|
||||
<li>• 保障措施力度不及预期</li>
|
||||
<li>• 贸易反制措施</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 border-t border-gray-800 py-8 px-6">
|
||||
<div class="max-w-7xl mx-auto text-center">
|
||||
<p class="text-gray-400">© 2025 牛肉产业投资分析报告 | 数据来源:公开信息整理</p>
|
||||
<p class="text-gray-500 text-sm mt-2">投资有风险,入市需谨慎</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// 价格走势图表
|
||||
const ctx = document.getElementById('priceChart').getContext('2d');
|
||||
const priceChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['2024', '2025', '2026', '2027', '2028'],
|
||||
datasets: [{
|
||||
label: '牛肉价格预测(元/公斤)',
|
||||
data: [24, 27, 34, 40, 38],
|
||||
borderColor: 'rgb(168, 85, 247)',
|
||||
backgroundColor: 'rgba(168, 85, 247, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 滚动动画
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// 为所有卡片添加滚动动画
|
||||
document.querySelectorAll('.card-hover').forEach(el => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(20px)';
|
||||
el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// 平滑滚动
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
document.querySelector(this.getAttribute('href')).scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
这个HTML页面提供了一个专业、炫酷且功能丰富的牛肉产业投资分析界面。主要特点包括:
|
||||
|
||||
## 🎯 核心功能
|
||||
1. **完整的内容展示** - 整合了所有insight分析、新闻数据和股票信息
|
||||
2. **交互式时间轴** - 展示核心事件演进过程
|
||||
3. **数据可视化** - 包含价格走势图表和供需进度条
|
||||
4. **详细股票表格** - 清晰展示相关上市公司信息,支持水平滚动
|
||||
5. **投资策略建议** - 分梯队展示投资标的和建议
|
||||
|
||||
## 💫 设计亮点
|
||||
- **渐变色主题** - 紫色到粉色的渐变,营造专业金融感
|
||||
- **玻璃态效果** - 半透明卡片配合模糊背景
|
||||
- **霓虹光效** - 重要元素添加发光边框
|
||||
- **响应式布局** - 完美适配桌面和移动设备
|
||||
- **平滑动画** - 滚动触发的渐入效果
|
||||
- **微交互** - 悬停效果和脉冲动画
|
||||
|
||||
## 📊 数据展示
|
||||
- 三重共振驱动逻辑的可视化
|
||||
- 供需端变化进度条
|
||||
- 价格预测折线图
|
||||
- 产业链价值分布
|
||||
- 核心公司评级表格
|
||||
|
||||
页面保留了insight的绝大部分关键内容,包括周期分析、产能去化、政策影响、全球格局等核心观点,并通过现代化的设计语言让信息更易理解和吸收。
|
||||
624
public/htmls/羽绒.html
Normal file
624
public/htmls/羽绒.html
Normal file
@@ -0,0 +1,624 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>羽绒行业深度分析报告</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.24/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(147, 51, 234, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(147, 51, 234, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(147, 51, 234, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-text {
|
||||
font-size: 2rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.price-up {
|
||||
animation: priceUp 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes priceUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white">
|
||||
<!-- Hero Section -->
|
||||
<div class="gradient-bg min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-black opacity-50"></div>
|
||||
<div class="container mx-auto px-6 relative z-10">
|
||||
<div class="text-center">
|
||||
<h1 class="hero-text text-6xl md:text-8xl font-bold mb-6 text-white animate-pulse">
|
||||
羽绒行业
|
||||
</h1>
|
||||
<p class="text-2xl md:text-3xl mb-8 text-gray-200">供需失衡下的史诗级行情</p>
|
||||
<div class="flex justify-center space-x-4 mb-12">
|
||||
<div class="glass-effect rounded-lg px-6 py-3">
|
||||
<i class="fas fa-chart-line text-yellow-400 mr-2"></i>
|
||||
<span class="text-xl font-semibold">价格涨幅 <span class="text-3xl">241%</span></span>
|
||||
</div>
|
||||
<div class="glass-effect rounded-lg px-6 py-3">
|
||||
<i class="fas fa-snowflake text-blue-400 mr-2"></i>
|
||||
<span class="text-xl font-semibold">冷冬催化</span>
|
||||
</div>
|
||||
<div class="glass-effect rounded-lg px-6 py-3">
|
||||
<i class="fas fa-industry text-green-400 mr-2"></i>
|
||||
<span class="text-xl font-semibold">产业升级</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="scrollToSection('timeline')" class="bg-purple-600 hover:bg-purple-700 px-8 py-4 rounded-full text-xl font-semibold transition-all hover-scale">
|
||||
深度解析 <i class="fas fa-arrow-down ml-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320">
|
||||
<path fill="#111827" fill-opacity="1" d="M0,96L48,112C96,128,192,160,288,160C384,160,480,128,576,117.3C672,107,768,117,864,138.7C960,160,1056,192,1152,192C1248,192,1344,160,1392,144L1440,128L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Section -->
|
||||
<section id="timeline" class="py-20 bg-gray-900">
|
||||
<div class="container mx-auto px-6">
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-center mb-16 gradient-text">概念事件时间轴</h2>
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-purple-600 rounded-full flex items-center justify-center timeline-dot">
|
||||
<span class="text-sm font-bold">2023</span>
|
||||
</div>
|
||||
<div class="flex-grow glass-effect rounded-lg p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2">价格基础奠定期</h3>
|
||||
<p class="text-gray-300">白鸭绒价格从3,500元/吨涨至5,380元/吨,涨幅超53%。供给端产能去化,需求端冰雪经济推动。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-red-600 rounded-full flex items-center justify-center timeline-dot">
|
||||
<span class="text-sm font-bold">2024.11</span>
|
||||
</div>
|
||||
<div class="flex-grow glass-effect rounded-lg p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2">央视曝光造假事件</h3>
|
||||
<p class="text-gray-300">央视曝光羽绒造假黑幕,以"飞丝"冒充羽绒,成本差距50%+。行业监管趋严,劣币出清加速。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-green-600 rounded-full flex items-center justify-center timeline-dot">
|
||||
<span class="text-sm font-bold">2025.05</span>
|
||||
</div>
|
||||
<div class="flex-grow glass-effect rounded-lg p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2">古麒绒材上市</h3>
|
||||
<p class="text-gray-300">羽绒材料龙头古麒绒材登陆A股,上市首日大涨222.85%触发临停,概念情绪达到高点。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-yellow-600 rounded-full flex items-center justify-center timeline-dot">
|
||||
<span class="text-sm font-bold">2025.10-11</span>
|
||||
</div>
|
||||
<div class="flex-grow glass-effect rounded-lg p-6 card-hover price-up">
|
||||
<h3 class="text-xl font-semibold mb-2">价格暴涨期</h3>
|
||||
<p class="text-gray-300">寒潮提前来临,90%白鸭绒价格从17万元/吨飙升至58万元/吨,鹅绒达98万元/吨。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center timeline-dot">
|
||||
<span class="text-sm font-bold">2025.11.20</span>
|
||||
</div>
|
||||
<div class="flex-grow glass-effect rounded-lg p-6 card-hover">
|
||||
<h3 class="text-xl font-semibold mb-2">机构密集推荐</h3>
|
||||
<p class="text-gray-300">多家机构同步推荐波司登,关注古麒绒材,明确"冷冬逐步确认"交易逻辑。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Core Logic Section -->
|
||||
<section class="py-20 bg-gray-800">
|
||||
<div class="container mx-auto px-6">
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-center mb-16 gradient-text">核心逻辑三维解析</h2>
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div class="glass-effect rounded-xl p-8 card-hover">
|
||||
<div class="text-4xl mb-4">📉</div>
|
||||
<h3 class="text-2xl font-bold mb-4 text-purple-400">供给侧刚性收缩</h3>
|
||||
<ul class="space-y-2 text-gray-300">
|
||||
<li>• 养殖产能历史低位(40-42亿羽)</li>
|
||||
<li>• 较高峰期减少16.7%</li>
|
||||
<li>• 环保政策挤出中小屠宰场</li>
|
||||
<li>• 羽毛分流效应(羽毛球原料)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-xl p-8 card-hover">
|
||||
<div class="text-4xl mb-4">📈</div>
|
||||
<h3 class="text-2xl font-bold mb-4 text-green-400">需求侧结构性升级</h3>
|
||||
<ul class="space-y-2 text-gray-300">
|
||||
<li>• 冰雪经济1.5万亿市场空间</li>
|
||||
<li>• 新国标实际成本增加15-20%</li>
|
||||
<li>• 高充绒量(300克/件)成标配</li>
|
||||
<li>• 渗透率仅9%(vs 欧美35%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-xl p-8 card-hover">
|
||||
<div class="text-4xl mb-4">⚖️</div>
|
||||
<h3 class="text-2xl font-bold mb-4 text-blue-400">监管侧劣币出清</h3>
|
||||
<ul class="space-y-2 text-gray-300">
|
||||
<li>• 央视曝光行业造假乱象</li>
|
||||
<li>• 成本铁底420-550元/千克</li>
|
||||
<li>• 从"价格战"转向"价值战"</li>
|
||||
<li>• 头部企业市占率加速提升</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Industry Chain Section -->
|
||||
<section class="py-20 bg-gray-900">
|
||||
<div class="container mx-auto px-6">
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-center mb-16 gradient-text">产业链图谱</h2>
|
||||
<div class="overflow-x-auto custom-scrollbar">
|
||||
<div class="flex space-x-6 pb-4" style="min-width: max-content;">
|
||||
<!-- 上游 -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 min-w-[280px] card-hover">
|
||||
<h3 class="text-xl font-bold mb-4 text-purple-400">上游:水禽养殖与屠宰</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">益客食品</div>
|
||||
<div class="text-sm text-gray-400">全球第二大肉鸭供应商</div>
|
||||
<div class="text-sm text-gray-400">年屠宰3.5亿只</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">立华股份</div>
|
||||
<div class="text-sm text-gray-400">商品鹅年销210万只</div>
|
||||
<div class="text-sm text-gray-400">鹅绒高端原料</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">华英农业</div>
|
||||
<div class="text-sm text-gray-400">自产约8%原毛</div>
|
||||
<div class="text-sm text-gray-400">其余90%外采</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中游 -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 min-w-[280px] card-hover">
|
||||
<h3 class="text-xl font-bold mb-4 text-green-400">中游:羽绒加工与水洗</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">古麒绒材</div>
|
||||
<div class="text-sm text-gray-400">A股次新龙头</div>
|
||||
<div class="text-sm text-gray-400">产能2288吨</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">华英农业</div>
|
||||
<div class="text-sm text-gray-400">市占率国内15%</div>
|
||||
<div class="text-sm text-gray-400">全球接近10%</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">柳桥集团</div>
|
||||
<div class="text-sm text-gray-400">非上市龙头</div>
|
||||
<div class="text-sm text-gray-400">出口占比高</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下游 -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 min-w-[320px] card-hover">
|
||||
<h3 class="text-xl font-bold mb-4 text-blue-400">下游:品牌服饰与家纺</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">波司登</div>
|
||||
<div class="text-sm text-gray-400">全球羽绒服专家</div>
|
||||
<div class="text-sm text-gray-400">品牌定价权最强</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">富安娜/罗莱生活</div>
|
||||
<div class="text-sm text-gray-400">家纺龙头</div>
|
||||
<div class="text-sm text-gray-400">羽绒被渗透率提升</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded p-3">
|
||||
<div class="font-semibold">安踏/李宁</div>
|
||||
<div class="text-sm text-gray-400">运动品牌</div>
|
||||
<div class="text-sm text-gray-400">冬季羽绒品类增量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Core Companies Comparison -->
|
||||
<section class="py-20 bg-gray-800">
|
||||
<div class="container mx-auto px-6">
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-center mb-16 gradient-text">核心玩家四维对比</h2>
|
||||
<div class="overflow-x-auto custom-scrollbar">
|
||||
<table class="w-full bg-gray-900 rounded-lg overflow-hidden">
|
||||
<thead>
|
||||
<tr class="bg-purple-600">
|
||||
<th class="px-6 py-4 text-left">公司</th>
|
||||
<th class="px-6 py-4 text-center">业务纯度</th>
|
||||
<th class="px-6 py-4 text-center">定价权</th>
|
||||
<th class="px-6 py-4 text-center">资金实力</th>
|
||||
<th class="px-6 py-4 text-center">风险敞口</th>
|
||||
<th class="px-6 py-4 text-center">核心逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-800">
|
||||
<td class="px-6 py-4 font-semibold">古麒绒材</td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐⭐⭐<br><span class="text-sm text-gray-400">99%羽绒业务</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐<br><span class="text-sm text-gray-400">技术溢价但账期被动</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐<br><span class="text-sm text-gray-400">IPO募资5亿</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐⭐⭐<br><span class="text-sm text-gray-400">原材料占比96%+</span></td>
|
||||
<td class="px-6 py-4 text-sm">技术龙头+产能扩张<br><span class="text-red-400">需警惕现金流恶化</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-800">
|
||||
<td class="px-6 py-4 font-semibold">华英农业</td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐⭐<br><span class="text-sm text-gray-400">70%羽绒+30%食品</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐<br><span class="text-sm text-gray-400">加工费模式,毛利率7-9%</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐<br><span class="text-sm text-gray-400">依赖融资</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐⭐<br><span class="text-sm text-gray-400">订单锁定+库存跌价</span></td>
|
||||
<td class="px-6 py-4 text-sm">规模优势+出清受益<br><span class="text-yellow-400">但盈利能力脆弱</span></td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-800">
|
||||
<td class="px-6 py-4 font-semibold">波司登</td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐<br><span class="text-sm text-gray-400">主品牌+多元化</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐⭐⭐<br><span class="text-sm text-gray-400">品牌溢价,可提价传导</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐⭐<br><span class="text-sm text-gray-400">港股龙头,现金充裕</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐<br><span class="text-sm text-gray-400">成本占比<45%</span></td>
|
||||
<td class="px-6 py-4 text-sm">品牌护城河最深<br><span class="text-green-400">估值已反映预期</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-gray-800">
|
||||
<td class="px-6 py-4 font-semibold">益客食品</td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐<br><span class="text-sm text-gray-400">羽绒为辅,食品为主</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐<br><span class="text-sm text-gray-400">副产品,无单独定价</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐⭐⭐<br><span class="text-sm text-gray-400">肉鸭龙头</span></td>
|
||||
<td class="px-6 py-4 text-center">⭐<br><span class="text-sm text-gray-400">成本内部化</span></td>
|
||||
<td class="px-6 py-4 text-sm">上游资源隐形标的<br><span class="text-blue-400">业务纯度不足</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stock Data Section -->
|
||||
<section class="py-20 bg-gray-900">
|
||||
<div class="container mx-auto px-6">
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-center mb-16 gradient-text">相关股票数据</h2>
|
||||
<div class="overflow-x-auto custom-scrollbar">
|
||||
<table class="w-full bg-gray-800 rounded-lg overflow-hidden text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gradient-to-r from-purple-600 to-purple-700">
|
||||
<th class="px-4 py-3 text-left">股票名称</th>
|
||||
<th class="px-4 py-3 text-left">分类</th>
|
||||
<th class="px-4 py-3 text-left">行业</th>
|
||||
<th class="px-4 py-3 text-left">项目</th>
|
||||
<th class="px-4 py-3 text-left">产业链/地位</th>
|
||||
<th class="px-4 py-3 text-left">关键数据</th>
|
||||
<th class="px-4 py-3 text-left">投资逻辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-purple-400">古麒绒材</td>
|
||||
<td class="px-4 py-3">相关业务</td>
|
||||
<td class="px-4 py-3">羽绒产品</td>
|
||||
<td class="px-4 py-3">高规格羽绒产品研产销</td>
|
||||
<td class="px-4 py-3">原材料-研发-生产-销售</td>
|
||||
<td class="px-4 py-3">营收占比99.27%,2024年营收9.59亿</td>
|
||||
<td class="px-4 py-3">A股唯一纯羽绒材料标的,技术龙头</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-purple-400">华英农业</td>
|
||||
<td class="px-4 py-3">相关业务</td>
|
||||
<td class="px-4 py-3">羽绒产品</td>
|
||||
<td class="px-4 py-3">羽绒加工</td>
|
||||
<td class="px-4 py-3">中国羽绒行业出口十强</td>
|
||||
<td class="px-4 py-3">营收占比67.22%,2024年营收31.8亿</td>
|
||||
<td class="px-4 py-3">规模优势,市占率国内15%,军工订单</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-purple-400">益客食品</td>
|
||||
<td class="px-4 py-3">相关业务</td>
|
||||
<td class="px-4 py-3">羽绒产品</td>
|
||||
<td class="px-4 py-3">原料绒生产</td>
|
||||
<td class="px-4 py-3">原料绒-毛梗/毛片副产品</td>
|
||||
<td class="px-4 py-3">2024年羽绒营收9.19亿元</td>
|
||||
<td class="px-4 py-3">上游资源隐形标的,年屠宰3.5亿只</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-purple-400">立华股份</td>
|
||||
<td class="px-4 py-3">相关业务</td>
|
||||
<td class="px-4 py-3">养鹅业务</td>
|
||||
<td class="px-4 py-3">种鹅繁育/商品鹅养殖</td>
|
||||
<td class="px-4 py-3">育种-种鹅繁育-商品鹅养殖及销售</td>
|
||||
<td class="px-4 py-3">2024年销售商品鹅209.63万只,同比+21.73%</td>
|
||||
<td class="px-4 py-3">鹅绒高端原料,羽毛制作羽毛球</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-purple-400">煌上煌</td>
|
||||
<td class="px-4 py-3">相关业务</td>
|
||||
<td class="px-4 py-3">羽绒加工</td>
|
||||
<td class="px-4 py-3">羽绒加工</td>
|
||||
<td class="px-4 py-3">子公司加工</td>
|
||||
<td class="px-4 py-3">持股51%子公司丰城煌鹏羽绒</td>
|
||||
<td class="px-4 py-3">业务占比较小,弹性有限</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-blue-400">森马服饰</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">品牌运营</td>
|
||||
<td class="px-4 py-3">品牌设计-生产-销售</td>
|
||||
<td class="px-4 py-3">古麒绒材大客户(占比约20%)</td>
|
||||
<td class="px-4 py-3">下游品牌商,拥有定价权</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-blue-400">海澜之家</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">品牌运营</td>
|
||||
<td class="px-4 py-3">品牌设计-生产-销售</td>
|
||||
<td class="px-4 py-3">古麒绒材大客户(占比约20%)</td>
|
||||
<td class="px-4 py-3">下游品牌商,拥有定价权</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-blue-400">探路者</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">户外服装</td>
|
||||
<td class="px-4 py-3">户外品牌-羽绒服产品</td>
|
||||
<td class="px-4 py-3">专业户外羽绒服市场</td>
|
||||
<td class="px-4 py-3">户外领域羽绒服,细分市场龙头</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700 hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3 font-semibold text-blue-400">嘉曼服饰</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">羽绒服</td>
|
||||
<td class="px-4 py-3">品牌运营</td>
|
||||
<td class="px-4 py-3">品牌设计-生产-销售</td>
|
||||
<td class="px-4 py-3">童装羽绒服市场</td>
|
||||
<td class="px-4 py-3">儿童羽绒服细分市场</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Investment Advice Section -->
|
||||
<section class="py-20 bg-gray-800">
|
||||
<div class="container mx-auto px-6">
|
||||
<h2 class="text-4xl md:text-5xl font-bold text-center mb-16 gradient-text">投资建议与风险提示</h2>
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div class="glass-effect rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold mb-6 text-green-400">投资优先级</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-3xl">🥇</span>
|
||||
<div>
|
||||
<div class="font-semibold">超配:波司登、益客食品</div>
|
||||
<div class="text-sm text-gray-400">品牌护城河+上游资源</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-3xl">🥈</span>
|
||||
<div>
|
||||
<div class="font-semibold">标配:华英农业</div>
|
||||
<div class="text-sm text-gray-400">规模优势+军工订单</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-3xl">🥉</span>
|
||||
<div>
|
||||
<div class="font-semibold">低配:古麒绒材、富安娜</div>
|
||||
<div class="text-sm text-gray-400">情绪溢价高、逻辑不纯</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold mb-6 text-red-400">关键风险</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start space-x-3">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 mt-1"></i>
|
||||
<div>
|
||||
<div class="font-semibold">价格数据混乱</div>
|
||||
<div class="text-sm text-gray-400">新闻涨幅241% vs 实际涨幅53%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 mt-1"></i>
|
||||
<div>
|
||||
<div class="font-semibold">中游毛利率压缩</div>
|
||||
<div class="text-sm text-gray-400">订单锁定导致毛利率仅7-9%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 mt-1"></i>
|
||||
<div>
|
||||
<div class="font-semibold">库存周期错配</div>
|
||||
<div class="text-sm text-gray-400">囤货压力与资金占用</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 mt-1"></i>
|
||||
<div>
|
||||
<div class="font-semibold">暖冬风险</div>
|
||||
<div class="text-sm text-gray-400">2025年Q1业绩证伪概率高</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-12 bg-gray-900 border-t border-gray-800">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<p class="text-gray-400 mb-4">本报告仅供参考,不构成投资建议</p>
|
||||
<p class="text-sm text-gray-500">数据来源:公开新闻、路演记录、研报 | 更新时间:2025年11月</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Smooth scroll function
|
||||
function scrollToSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
section.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Add animation on scroll
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -100px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('price-up');
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
document.querySelectorAll('.card-hover').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// Dynamic date update
|
||||
const updateDate = () => {
|
||||
const now = new Date();
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
document.getElementById('currentDate').textContent = now.toLocaleDateString('zh-CN', options);
|
||||
};
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateDate();
|
||||
|
||||
// Add hover effect to table rows
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
tableRows.forEach(row => {
|
||||
row.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'scale(1.02)';
|
||||
this.style.transition = 'transform 0.2s ease';
|
||||
});
|
||||
row.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'scale(1)';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Parallax effect for hero section
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrolled = window.pageYOffset;
|
||||
const hero = document.querySelector('.gradient-bg');
|
||||
if (hero) {
|
||||
hero.style.transform = `translateY(${scrolled * 0.5}px)`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
576
public/htmls/谷歌.html
Normal file
576
public/htmls/谷歌.html
Normal file
@@ -0,0 +1,576 @@
|
||||
我将为您创建一个专业的金融科技风格HTML页面,展示谷歌AI转型的完整投资分析。这个页面将融合数据可视化、动态效果和响应式设计。
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>谷歌AI产业链投资分析 - 战略蜕变与价值重估</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.number-animate {
|
||||
animation: countUp 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.timeline-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 20px;
|
||||
width: 2px;
|
||||
height: calc(100% + 10px);
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.glow-text {
|
||||
text-shadow: 0 0 20px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.scroll-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.badge-glow {
|
||||
animation: badgeGlow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes badgeGlow {
|
||||
from {
|
||||
box-shadow: 0 0 5px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<!-- Hero Section -->
|
||||
<section class="gradient-bg text-white py-20 px-4">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center mb-10">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-white/20 rounded-full mb-6 badge-glow">
|
||||
<i class="fab fa-google text-3xl"></i>
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-6xl font-bold mb-6 glow-text">
|
||||
谷歌AI产业链投资分析
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-blue-100 max-w-3xl mx-auto">
|
||||
从"搜索广告公司"向"AI基础设施+应用生态平台"的战略蜕变
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-4 gap-6 mt-16">
|
||||
<div class="glass-effect rounded-xl p-6 card-hover">
|
||||
<div class="text-3xl font-bold number-animate">450<span class="text-xl">万</span></div>
|
||||
<div class="text-blue-200 mt-2">等效H100算力</div>
|
||||
<div class="text-sm text-blue-300 mt-1">2025年预期</div>
|
||||
</div>
|
||||
<div class="glass-effect rounded-xl p-6 card-hover">
|
||||
<div class="text-3xl font-bold number-animate">1300<span class="text-xl">万亿</span></div>
|
||||
<div class="text-blue-200 mt-2">月均Token处理量</div>
|
||||
<div class="text-sm text-blue-300 mt-1">环比增长30%</div>
|
||||
</div>
|
||||
<div class="glass-effect rounded-xl p-6 card-hover">
|
||||
<div class="text-3xl font-bold number-animate">23.7<span class="text-xl">%</span></div>
|
||||
<div class="text-blue-200 mt-2">云业务利润率</div>
|
||||
<div class="text-sm text-blue-300 mt-1">Q3 2025</div>
|
||||
</div>
|
||||
<div class="glass-effect rounded-xl p-6 card-hover">
|
||||
<div class="text-3xl font-bold number-animate">1550<span class="text-xl">亿美元</span></div>
|
||||
<div class="text-blue-200 mt-2">云积压订单</div>
|
||||
<div class="text-sm text-blue-300 mt-1">环比增长46.2%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Key Timeline -->
|
||||
<section class="py-16 px-4 bg-white">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-8 text-center">
|
||||
<i class="fas fa-clock text-purple-600 mr-3"></i>关键时间轴
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div class="timeline-item">
|
||||
<div class="text-sm text-purple-600 font-semibold mb-2">2024年2-3月</div>
|
||||
<div class="text-lg font-semibold text-gray-800 mb-2">AI Overview大规模推广</div>
|
||||
<div class="text-gray-600 text-sm">Token消耗量达480万亿/月</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="text-sm text-purple-600 font-semibold mb-2">2024年5月</div>
|
||||
<div class="text-lg font-semibold text-gray-800 mb-2">Gemini 2.5 Pro发布</div>
|
||||
<div class="text-gray-600 text-sm">AI Mode上线,月活破1亿</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="text-sm text-purple-600 font-semibold mb-2">2024年8月</div>
|
||||
<div class="text-lg font-semibold text-gray-800 mb-2">Pixel 9搭载Gemini Nano</div>
|
||||
<div class="text-gray-600 text-sm">Gemini Live对标Apple Intelligence</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="text-sm text-purple-600 font-semibold mb-2">2024年9月</div>
|
||||
<div class="text-lg font-semibold text-gray-800 mb-2">反垄断判决超预期</div>
|
||||
<div class="text-gray-600 text-sm">保留Chrome,消除不确定性</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="text-sm text-purple-600 font-semibold mb-2">2024年10月</div>
|
||||
<div class="text-lg font-semibold text-gray-800 mb-2">Q3财报验证AI正循环</div>
|
||||
<div class="text-gray-600 text-sm">搜索收入增速创2-3年新高</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="text-sm text-purple-600 font-semibold mb-2">2024年11月</div>
|
||||
<div class="text-lg font-semibold text-gray-800 mb-2">TPU外供重大突破</div>
|
||||
<div class="text-gray-600 text-sm">OpenAI/META成为客户</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Core Logic Section -->
|
||||
<section class="py-16 px-4 bg-gray-100">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-12 text-center">
|
||||
<i class="fas fa-brain text-purple-600 mr-3"></i>核心逻辑分析
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8 mb-12">
|
||||
<div class="bg-white rounded-2xl p-8 card-hover shadow-lg">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mb-6">
|
||||
<i class="fas fa-microchip text-white text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">算力自主化</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
自研TPU芯片性能对标英伟达B200,凭借功耗和成本优势,云业务利润率从11%提升至23.7%,单季度利润率提升超12个百分点
|
||||
</p>
|
||||
<div class="mt-6 flex items-center text-purple-600 font-semibold">
|
||||
<span>450万等效H100</span>
|
||||
<i class="fas fa-arrow-trend-up ml-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl p-8 card-hover shadow-lg">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-green-500 to-teal-600 rounded-full flex items-center justify-center mb-6">
|
||||
<i class="fas fa-robot text-white text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">模型代际领先</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
Gemini 2.5 Pro在LMArena测评中ELO分数达1448分,Veo 3、Imagen 4、Genie模型在多模态领域实现突破性进展
|
||||
</p>
|
||||
<div class="mt-6 flex items-center text-green-600 font-semibold">
|
||||
<span>多模态原生能力</span>
|
||||
<i class="fas fa-trophy ml-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl p-8 card-hover shadow-lg">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-orange-500 to-red-600 rounded-full flex items-center justify-center mb-6">
|
||||
<i class="fas fa-chart-line text-white text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">流量护城河转化</h3>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
AI Overview覆盖15-20亿月活,AI Mode Token消耗是传统模式的2-3倍,重构搜索商业模式,提升广告ROI
|
||||
</p>
|
||||
<div class="mt-6 flex items-center text-orange-600 font-semibold">
|
||||
<span>20亿月活用户</span>
|
||||
<i class="fas fa-users ml-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Sentiment -->
|
||||
<div class="bg-gradient-to-r from-purple-50 to-blue-50 rounded-2xl p-8">
|
||||
<h3 class="text-2xl font-bold text-gray-800 mb-6">
|
||||
<i class="fas fa-chart-pie text-purple-600 mr-3"></i>市场预期差
|
||||
</h3>
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-700 mb-4">当前市场认知</h4>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-500 mr-2"><i class="fas fa-times-circle"></i></span>
|
||||
<span class="text-gray-600">估值便宜但增长乏力(PE 18倍)</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-500 mr-2"><i class="fas fa-times-circle"></i></span>
|
||||
<span class="text-gray-600">担忧AI搜索冲击广告收入</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-500 mr-2"><i class="fas fa-times-circle"></i></span>
|
||||
<span class="text-gray-600">机构低配,情绪偏谨慎</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-700 mb-4">产业实际进展</h4>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2"><i class="fas fa-check-circle"></i></span>
|
||||
<span class="text-gray-600">产业链订单上修30-50%</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2"><i class="fas fa-check-circle"></i></span>
|
||||
<span class="text-gray-600">TPU外供打开第二增长曲线</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-green-500 mr-2"><i class="fas fa-check-circle"></i></span>
|
||||
<span class="text-gray-600">进入"业绩饥渴"状态</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stock Chain Table -->
|
||||
<section class="py-16 px-4 bg-white">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-8 text-center">
|
||||
<i class="fas fa-network-wired text-purple-600 mr-3"></i>谷歌产业链核心标的
|
||||
</h2>
|
||||
|
||||
<div class="mb-8 flex flex-wrap gap-4 justify-center">
|
||||
<button onclick="filterCategory('all')" class="px-6 py-2 bg-purple-600 text-white rounded-full hover:bg-purple-700 transition">
|
||||
全部
|
||||
</button>
|
||||
<button onclick="filterCategory('光模块')" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 transition">
|
||||
光模块
|
||||
</button>
|
||||
<button onclick="filterCategory('PCB')" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 transition">
|
||||
PCB
|
||||
</button>
|
||||
<button onclick="filterCategory('OCS')" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 transition">
|
||||
OCS
|
||||
</button>
|
||||
<button onclick="filterCategory('液冷散热')" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 transition">
|
||||
液冷散热
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse" id="stockTable">
|
||||
<thead>
|
||||
<tr class="bg-gradient-to-r from-purple-600 to-blue-600 text-white">
|
||||
<th class="p-4 text-left font-semibold">分类</th>
|
||||
<th class="p-4 text-left font-semibold">个股</th>
|
||||
<th class="p-4 text-left font-semibold">相关性描述</th>
|
||||
<th class="p-4 text-left font-semibold">消息来源</th>
|
||||
<th class="p-4 text-center font-semibold">投资评级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stockTableBody">
|
||||
<!-- Table content will be dynamically generated -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Risk Analysis -->
|
||||
<section class="py-16 px-4 bg-gradient-to-br from-red-50 to-orange-50">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-800 mb-12 text-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 mr-3"></i>风险提示
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div class="bg-white rounded-xl p-6 border-l-4 border-red-500">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-microchip text-red-500 mr-2"></i>技术风险
|
||||
</h3>
|
||||
<ul class="space-y-2 text-gray-600">
|
||||
<li>• AI Mode幻觉率达15-20%</li>
|
||||
<li>• Agent识别第三方应用仅30%</li>
|
||||
<li>• Agent to Agent协议推进缓慢</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 border-l-4 border-orange-500">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-dollar-sign text-orange-500 mr-2"></i>商业化风险
|
||||
</h3>
|
||||
<ul class="space-y-2 text-gray-600">
|
||||
<li>• AI Ultra月费245美元过高</li>
|
||||
<li>• 广告展示次数或降30-40%</li>
|
||||
<li>• Gemini Advanced仅3000万用户</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 border-l-4 border-yellow-500">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-gavel text-yellow-600 mr-2"></i>政策风险
|
||||
</h3>
|
||||
<ul class="space-y-2 text-gray-600">
|
||||
<li>• 美国司法部终极裁决未定</li>
|
||||
<li>• 欧盟数字税反制风险</li>
|
||||
<li>• 广告业务拆分风险犹存</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Investment Advice -->
|
||||
<section class="py-16 px-4 gradient-bg text-white">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h2 class="text-3xl font-bold mb-12 text-center glow-text">
|
||||
<i class="fas fa-lightbulb mr-3"></i>投资建议
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div class="glass-effect rounded-2xl p-8">
|
||||
<h3 class="text-2xl font-bold mb-6">谷歌本体 (GOOGL)</h3>
|
||||
<div class="mb-6">
|
||||
<div class="text-4xl font-bold mb-2">增持评级</div>
|
||||
<div class="text-blue-200">目标价:$233-250</div>
|
||||
</div>
|
||||
<ul class="space-y-3 text-blue-100">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle mr-3 mt-1"></i>
|
||||
<span>当前18倍PE未反映TPU外供价值</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle mr-3 mt-1"></i>
|
||||
<span>监管出清后估值向25倍PE修复</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle mr-3 mt-1"></i>
|
||||
<span>AI订阅带来第二增长曲线</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass-effect rounded-2xl p-8">
|
||||
<h3 class="text-2xl font-bold mb-6">A股产业链配置</h3>
|
||||
<div class="mb-6">
|
||||
<div class="text-4xl font-bold mb-2">重点配置</div>
|
||||
<div class="text-blue-200">硬件链业绩确定性最强</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between bg-white/10 rounded-lg p-3">
|
||||
<span>光库科技</span>
|
||||
<span class="text-green-300 font-semibold">第一优先级</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/10 rounded-lg p-3">
|
||||
<span>中际旭创</span>
|
||||
<span class="text-green-300 font-semibold">第二优先级</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-white/10 rounded-lg p-3">
|
||||
<span>沪电股份</span>
|
||||
<span class="text-green-300 font-semibold">第三优先级</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<div class="inline-flex items-center bg-white/20 rounded-full px-8 py-4">
|
||||
<i class="fas fa-chart-line text-3xl mr-4 pulse-dot"></i>
|
||||
<div class="text-left">
|
||||
<div class="text-sm text-blue-200">关键跟踪指标</div>
|
||||
<div class="text-xl font-bold">Q4云业务利润率 & Token环比增速</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 text-white py-8 px-4">
|
||||
<div class="max-w-7xl mx-auto text-center">
|
||||
<p class="text-gray-400">© 2024 谷歌AI产业链投资分析 | 数据来源:公开研报、路演纪要、产业链调研</p>
|
||||
<p class="text-gray-500 text-sm mt-2">风险提示:投资有风险,入市需谨慎</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Stock data processing
|
||||
const stockData = [
|
||||
{category: '光模块', name: '中际旭创', desc: '供货谷歌800G光模块份额A股第一,2025年谷歌光模块采购量约350万只,中际旭创占其采购份额的70%', source: '网传纪要', rating: '强烈推荐'},
|
||||
{category: '光模块', name: '新易盛', desc: '供货谷歌800G光模块份额A股第二,2025年首次切入谷歌800G供应链', source: '网传纪要', rating: '推荐'},
|
||||
{category: '光模块', name: '长芯盛创', desc: '800G硅光模块已通过谷歌多轮验证,计划2025年第三季度量产,预计年出货量超50万只', source: '网传纪要', rating: '推荐'},
|
||||
{category: 'PCB', name: '沪电股份', desc: '供货谷歌PCB份额A股第一,谷歌TPU PCB的核心供应商,在谷歌TPU供应商中占比约30%', source: '网传纪要', rating: '强烈推荐'},
|
||||
{category: 'PCB', name: '胜宏科技', desc: '谷歌V7大份额一供', source: '机构研报', rating: '推荐'},
|
||||
{category: 'OCS', name: '腾景科技', desc: '谷歌OCS交换机的核心光学器件供应商,业务收入占比已达28%', source: '公开资料', rating: '推荐'},
|
||||
{category: 'OCS', name: '光库科技', desc: '2025年6月份并购的武汉捷普工厂,是过去谷歌OCS交换机方案的独家代工厂商', source: '网传纪要', rating: '强烈推荐'},
|
||||
{category: '液冷散热', name: '英维克', desc: '公司推出基于谷歌Descartes 5 CDU规格的CDU产品,同时展示26MW全链路液冷案例', source: '公开资料', rating: '推荐'}
|
||||
];
|
||||
|
||||
// Populate table
|
||||
function populateTable(data) {
|
||||
const tbody = document.getElementById('stockTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.forEach((stock, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b hover:bg-gray-50 transition-colors';
|
||||
row.innerHTML = `
|
||||
<td class="p-4">
|
||||
<span class="inline-block px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm font-medium">
|
||||
${stock.category}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-4 font-semibold text-gray-800">${stock.name}</td>
|
||||
<td class="p-4 text-gray-600 max-w-md">${stock.desc}</td>
|
||||
<td class="p-4 text-gray-500 text-sm">${stock.source}</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="inline-block px-3 py-1 ${
|
||||
stock.rating === '强烈推荐' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'
|
||||
} rounded-full text-sm font-medium">
|
||||
${stock.rating}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter category
|
||||
function filterCategory(category) {
|
||||
const buttons = document.querySelectorAll('button');
|
||||
buttons.forEach(btn => {
|
||||
if (btn.textContent === '全部' && category === 'all' ||
|
||||
btn.textContent === category) {
|
||||
btn.className = 'px-6 py-2 bg-purple-600 text-white rounded-full hover:bg-purple-700 transition';
|
||||
} else if (btn.textContent !== '全部') {
|
||||
btn.className = 'px-6 py-2 bg-gray-200 text-gray-700 rounded-full hover:bg-gray-300 transition';
|
||||
}
|
||||
});
|
||||
|
||||
const filtered = category === 'all' ? stockData : stockData.filter(s => s.category === category);
|
||||
populateTable(filtered);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
populateTable(stockData);
|
||||
|
||||
// Add smooth scroll
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
document.querySelector(this.getAttribute('href')).scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Animate numbers on scroll
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('number-animate');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.number-animate').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
这个HTML页面完整展示了谷歌AI转型的投资分析,包含以下核心特性:
|
||||
|
||||
## 🎯 核心亮点
|
||||
|
||||
1. **动态数据可视化**:Hero区域展示关键数据指标,带动画效果
|
||||
2. **时间轴展示**:清晰呈现谷歌AI发展的关键节点
|
||||
3. **产业链分析**:交互式表格展示核心标的,支持分类筛选
|
||||
4. **风险提示矩阵**:三维度风险分析(技术、商业化、政策)
|
||||
5. **投资建议卡片**:玻璃拟态设计展示具体配置建议
|
||||
|
||||
## 💫 设计特色
|
||||
|
||||
- **渐变色彩系统**:紫色到蓝色的科技感渐变
|
||||
- **卡片悬浮效果**:鼠标悬停时的3D浮起动画
|
||||
- **脉冲动画**:重要指标的呼吸灯效果
|
||||
- **响应式布局**:完美适配移动端和桌面端
|
||||
- **微交互设计**:按钮点击、表格筛选等细节动画
|
||||
|
||||
## 📊 数据展示
|
||||
|
||||
- 完整保留了insight中的核心数据和逻辑
|
||||
- 股票产业链表格支持动态筛选
|
||||
- 关键数据突出显示(450万算力、1300万亿Token等)
|
||||
- 风险和投资建议分层展示
|
||||
|
||||
这个页面不仅是一个信息展示工具,更是一个专业的投资决策辅助界面,通过视觉化设计帮助投资者快速把握谷歌AI产业链的投资机会。
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" layout="admin">
|
||||
<html lang="zh-CN" dir="ltr" layout="admin">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
@@ -7,6 +7,177 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!-- 基本 SEO -->
|
||||
<title>价值前沿 - 金融AI舆情分析系统 | LLM赋能的智能分析平台</title>
|
||||
<meta name="description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析,助力投资决策" />
|
||||
<meta name="keywords" content="金融AI,舆情分析,股市行情,LLM,价值前沿,a股,美股,投资分析" />
|
||||
<link rel="canonical" href="https://valuefrontier.cn/" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://valuefrontier.cn/" />
|
||||
<meta property="og:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta property="og:description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析" />
|
||||
<meta property="og:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
<meta property="og:site_name" content="价值前沿" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://valuefrontier.cn/" />
|
||||
<meta name="twitter:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta name="twitter:description" content="基于金融大语言模型,实时监控股市行情、a股、美股" />
|
||||
<meta name="twitter:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
|
||||
<!-- SEO 增强 -->
|
||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
||||
<meta name="author" content="价值前沿团队" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
|
||||
<!-- 性能优化: DNS 预连接 -->
|
||||
<link rel="preconnect" href="https://valuefrontier.cn" />
|
||||
<link rel="dns-prefetch" href="https://valuefrontier.cn" />
|
||||
|
||||
<!-- JSON-LD 结构化数据: 组织信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"logo": "https://valuefrontier.cn/logo.png",
|
||||
"description": "基于金融大语言模型的智能舆情分析平台",
|
||||
"foundingDate": "2023",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "Customer Service",
|
||||
"availableLanguage": ["zh-CN"]
|
||||
},
|
||||
"sameAs": [
|
||||
"https://valuefrontier.cn"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 网站信息 + 搜索功能 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "金融AI舆情分析系统,实时监控股市行情",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://valuefrontier.cn/search?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 软件应用产品信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "价值前沿",
|
||||
"applicationCategory": "FinanceApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "基于金融大语言模型,实时监控股市行情、a股、美股,提供企业舆情分析",
|
||||
"offers": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "专业版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "198",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "旗舰版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "998",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.8",
|
||||
"ratingCount": "1250",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"featureList": [
|
||||
"实时舆情监控",
|
||||
"智能事件分析",
|
||||
"多维度数据可视化",
|
||||
"AI驱动的投资建议",
|
||||
"行业板块分析"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 面包屑导航 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "首页",
|
||||
"item": "https://valuefrontier.cn/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "事件中心",
|
||||
"item": "https://valuefrontier.cn/community"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "概念分析",
|
||||
"item": "https://valuefrontier.cn/concepts"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 4,
|
||||
"name": "个股分析",
|
||||
"item": "https://valuefrontier.cn/stocks"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link
|
||||
@@ -15,10 +186,19 @@
|
||||
href="%PUBLIC_URL%/apple-icon.png"
|
||||
/>
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
|
||||
<title>价值前沿——LLM赋能的分析平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<noscript>
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 20px;">
|
||||
<div>
|
||||
<h1 style="font-size: 2em; margin-bottom: 20px;">⚠️ 需要启用 JavaScript</h1>
|
||||
<p style="font-size: 1.2em; line-height: 1.6; max-width: 600px; margin: 0 auto;">
|
||||
价值前沿是一个现代化的 Web 应用,需要 JavaScript 才能正常运行。<br><br>
|
||||
请在浏览器设置中启用 JavaScript,然后刷新页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.0'
|
||||
const PACKAGE_VERSION = '2.12.2'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
BIN
public/og-image.jpg
Normal file
BIN
public/og-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -1,3 +0,0 @@
|
||||
INFO Accepting connections at http://localhost:58321
|
||||
|
||||
INFO Gracefully shutting down. Please wait...
|
||||
78
src/App.js
78
src/App.js
@@ -9,8 +9,9 @@
|
||||
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, { useEffect, useRef } from "react";
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Routes
|
||||
import AppRoutes from './routes';
|
||||
@@ -20,6 +21,7 @@ import AppProviders from './providers/AppProviders';
|
||||
|
||||
// Components
|
||||
import GlobalComponents from './components/GlobalComponents';
|
||||
import { PerformancePanel } from './components/PerformancePanel';
|
||||
|
||||
// Hooks
|
||||
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
|
||||
@@ -30,12 +32,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
|
||||
// Utils
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
// PostHog 追踪
|
||||
import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
|
||||
// Contexts
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* AppContent - 应用核心内容
|
||||
* 负责 PostHog 初始化和渲染路由
|
||||
*/
|
||||
function AppContent() {
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
|
||||
const pageEnterTimeRef = useRef(Date.now());
|
||||
const currentPathRef = useRef(location.pathname);
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
@@ -43,6 +57,67 @@ function AppContent() {
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪
|
||||
useEffect(() => {
|
||||
const hasVisited = localStorage.getItem('has_visited');
|
||||
|
||||
if (!hasVisited) {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
// ⚡ 使用异步追踪,不阻塞页面渲染
|
||||
trackEventAsync('first_visit', {
|
||||
referrer: document.referrer || 'direct',
|
||||
utm_source: urlParams.get('utm_source'),
|
||||
utm_medium: urlParams.get('utm_medium'),
|
||||
utm_campaign: urlParams.get('utm_campaign'),
|
||||
landing_page: location.pathname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
localStorage.setItem('has_visited', 'true');
|
||||
}
|
||||
}, [location.search, location.pathname]);
|
||||
|
||||
// ✅ 页面浏览时长追踪
|
||||
useEffect(() => {
|
||||
// 计算上一个页面的停留时长
|
||||
const calculateAndTrackDuration = () => {
|
||||
const exitTime = Date.now();
|
||||
const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒
|
||||
|
||||
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
|
||||
if (duration > 1) {
|
||||
// ⚡ 使用异步追踪,不阻塞页面切换
|
||||
trackEventAsync('page_view_duration', {
|
||||
path: currentPathRef.current,
|
||||
duration_seconds: duration,
|
||||
is_authenticated: isAuthenticated,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 路由切换时追踪上一个页面的时长
|
||||
if (currentPathRef.current !== location.pathname) {
|
||||
calculateAndTrackDuration();
|
||||
|
||||
// 更新为新页面
|
||||
currentPathRef.current = location.pathname;
|
||||
pageEnterTimeRef.current = Date.now();
|
||||
}
|
||||
|
||||
// 页面关闭/刷新时追踪时长
|
||||
const handleBeforeUnload = () => {
|
||||
calculateAndTrackDuration();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [location.pathname, isAuthenticated]);
|
||||
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
@@ -58,6 +133,7 @@ export default function App() {
|
||||
<AppProviders>
|
||||
<AppContent />
|
||||
<GlobalComponents />
|
||||
<PerformancePanel />
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* vf_react App.jsx集成示例
|
||||
*
|
||||
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
|
||||
*
|
||||
* 集成步骤:
|
||||
* 1. 将bytedesk-integration文件夹复制到src/目录
|
||||
* 2. 在App.jsx中导入BytedeskWidget和配置
|
||||
* 3. 添加BytedeskWidget组件(代码如下)
|
||||
* 4. 配置.env文件(参考.env.bytedesk.example)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom'; // 如果使用react-router
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
// ============================================================================
|
||||
// 方案一: 全局集成(推荐)
|
||||
// 适用场景: 客服系统需要在所有页面显示
|
||||
// ============================================================================
|
||||
|
||||
function App() {
|
||||
// ========== vf_react原有代码保持不变 ==========
|
||||
// 这里是您原有的App.jsx代码
|
||||
// 例如: const [user, setUser] = useState(null);
|
||||
// 例如: const [theme, setTheme] = useState('light');
|
||||
// ... 保持原有逻辑不变 ...
|
||||
|
||||
// ========== Bytedesk集成代码开始 ==========
|
||||
|
||||
const location = useLocation(); // 获取当前路径
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 根据页面路径决定是否显示客服
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 获取Bytedesk配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 客服加载成功回调
|
||||
const handleBytedeskLoad = (bytedesk) => {
|
||||
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
|
||||
};
|
||||
|
||||
// 客服加载失败回调
|
||||
const handleBytedeskError = (error) => {
|
||||
console.error('[App] Bytedesk客服系统加载失败', error);
|
||||
};
|
||||
|
||||
// ========== Bytedesk集成代码结束 ==========
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* ========== vf_react原有内容保持不变 ========== */}
|
||||
{/* 这里是您原有的App.jsx JSX代码 */}
|
||||
{/* 例如: <Header /> */}
|
||||
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
|
||||
{/* ... 保持原有结构不变 ... */}
|
||||
|
||||
{/* ========== Bytedesk客服Widget ========== */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleBytedeskLoad}
|
||||
onError={handleBytedeskError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案二: 带用户信息集成
|
||||
// 适用场景: 需要将登录用户信息传递给客服端
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
|
||||
|
||||
function App() {
|
||||
// 获取登录用户信息
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const location = useLocation();
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 根据用户信息生成配置
|
||||
const bytedeskConfig = user
|
||||
? getBytedeskConfigWithUser(user)
|
||||
: getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案三: 条件性加载
|
||||
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在用户登录且为普通用户时显示客服
|
||||
if (user && user.role === 'customer') {
|
||||
setShowBytedesk(true);
|
||||
} else {
|
||||
setShowBytedesk(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案四: 动态控制显示/隐藏
|
||||
// 适用场景: 需要通过按钮或其他交互控制客服显示
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const toggleBytedesk = () => {
|
||||
setShowBytedesk(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{/* 自定义客服按钮 *\/}
|
||||
<button onClick={toggleBytedesk} className="custom-service-button">
|
||||
{showBytedesk ? '关闭客服' : '联系客服'}
|
||||
</button>
|
||||
|
||||
{/* 客服Widget *\/}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 重要提示
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 1. CSS样式兼容性
|
||||
* - Bytedesk Widget使用Shadow DOM,不会影响您的全局样式
|
||||
* - Widget的样式可通过config中的theme配置调整
|
||||
*
|
||||
* 2. 性能优化
|
||||
* - Widget脚本采用异步加载,不会阻塞页面渲染
|
||||
* - 建议在非关键页面(如登录、支付页)隐藏客服
|
||||
*
|
||||
* 3. 错误处理
|
||||
* - 如果客服脚本加载失败,不会影响主应用
|
||||
* - 建议添加onError回调进行错误监控
|
||||
*
|
||||
* 4. 调试模式
|
||||
* - 查看浏览器控制台的[Bytedesk]前缀日志
|
||||
* - 检查Network面板确认脚本加载成功
|
||||
*
|
||||
* 5. 生产部署
|
||||
* - 确保.env文件配置正确(特别是REACT_APP_BYTEDESK_API_URL)
|
||||
* - 确保CORS已在后端配置(允许您的前端域名)
|
||||
* - 在管理后台配置正确的工作组ID(sid)
|
||||
*/
|
||||
@@ -53,6 +53,21 @@ const BytedeskWidget = ({
|
||||
widgetRef.current = bytedesk;
|
||||
console.log('[Bytedesk] Widget初始化成功');
|
||||
|
||||
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
|
||||
// Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能
|
||||
// SDK 会自动降级使用 HTTP 轮询
|
||||
const originalConsoleError = console.error;
|
||||
console.error = function(...args) {
|
||||
const errorMsg = args.join(' ');
|
||||
// 忽略 /stomp 和 STOMP 相关错误
|
||||
if (errorMsg.includes('/stomp') ||
|
||||
errorMsg.includes('stomp onWebSocketError') ||
|
||||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
|
||||
return; // 不输出日志
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(bytedesk);
|
||||
}
|
||||
@@ -78,26 +93,43 @@ const BytedeskWidget = ({
|
||||
document.body.appendChild(script);
|
||||
scriptRef.current = script;
|
||||
|
||||
// 清理函数
|
||||
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错
|
||||
return () => {
|
||||
console.log('[Bytedesk] 清理Widget');
|
||||
|
||||
// 移除脚本
|
||||
if (scriptRef.current && document.body.contains(scriptRef.current)) {
|
||||
document.body.removeChild(scriptRef.current);
|
||||
try {
|
||||
if (scriptRef.current && scriptRef.current.parentNode) {
|
||||
scriptRef.current.parentNode.removeChild(scriptRef.current);
|
||||
}
|
||||
scriptRef.current = null;
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message);
|
||||
}
|
||||
|
||||
// 移除Widget DOM元素
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
if (el && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
try {
|
||||
if (el && el.parentNode && el.parentNode.contains(el)) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略单个元素移除失败(可能已被移除)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理Widget DOM元素失败:', error.message);
|
||||
}
|
||||
|
||||
// 清理全局对象
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
|
||||
}
|
||||
};
|
||||
}, [config, autoLoad, onLoad, onError]);
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
/**
|
||||
* Bytedesk客服配置文件
|
||||
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
|
||||
*
|
||||
* 环境变量配置(.env文件):
|
||||
* REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
* REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
*
|
||||
* 架构说明:
|
||||
* - iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
* - 本地:CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
|
||||
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
* - baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*
|
||||
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
|
||||
*/
|
||||
|
||||
// 从环境变量读取配置
|
||||
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
|
||||
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
|
||||
|
||||
/**
|
||||
* Bytedesk客服基础配置
|
||||
- iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
- 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
- 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
- baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*/
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址(如果 SDK 需要调用 API)
|
||||
@@ -61,9 +44,9 @@ export const bytedeskConfig = {
|
||||
|
||||
// 聊天配置(必需)
|
||||
chatConfig: {
|
||||
org: BYTEDESK_ORG, // 组织ID
|
||||
org: 'df_org_uid', // 组织ID
|
||||
t: '1', // 类型: 1=人工客服, 2=机器人
|
||||
sid: BYTEDESK_SID, // 工作组ID
|
||||
sid: 'df_wg_uid', // 工作组ID
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,45 +94,8 @@ export const getBytedeskConfigWithUser = (user) => {
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据页面路径判断是否显示客服
|
||||
*
|
||||
* @param {string} pathname - 当前页面路径
|
||||
* @returns {boolean} 是否显示客服
|
||||
*/
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面隐藏客服(黑名单)
|
||||
const blockedPages = [
|
||||
// '/home', // 登录页
|
||||
];
|
||||
|
||||
// 检查是否在黑名单
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 默认所有页面都显示客服
|
||||
return true;
|
||||
|
||||
/* ============================================
|
||||
白名单模式(备用,需要时取消注释)
|
||||
============================================
|
||||
const allowedPages = [
|
||||
'/', // 首页
|
||||
'/home', // 主页
|
||||
'/products', // 产品页
|
||||
'/pricing', // 价格页
|
||||
'/contact', // 联系我们
|
||||
];
|
||||
|
||||
// 只在白名单页面显示客服
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
============================================ */
|
||||
};
|
||||
|
||||
export default {
|
||||
bytedeskConfig,
|
||||
getBytedeskConfig,
|
||||
getBytedeskConfigWithUser,
|
||||
shouldShowCustomerService,
|
||||
};
|
||||
|
||||
@@ -85,15 +85,15 @@ export default function AuthFormContent() {
|
||||
const [showNicknamePrompt, setShowNicknamePrompt] = useState(false);
|
||||
const [currentPhone, setCurrentPhone] = useState("");
|
||||
|
||||
// 响应式布局配置
|
||||
// 响应式断点
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'AuthFormContent',
|
||||
isMobile: isMobile
|
||||
isMobile,
|
||||
});
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
// 表单数据
|
||||
@@ -186,8 +186,6 @@ export default function AuthFormContent() {
|
||||
purpose: config.api.purpose
|
||||
};
|
||||
|
||||
logger.api.request('POST', '/api/auth/send-verification-code', requestData);
|
||||
|
||||
const response = await fetch('/api/auth/send-verification-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -203,8 +201,6 @@ export default function AuthFormContent() {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.api.response('POST', '/api/auth/send-verification-code', response.status, data);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (!data) {
|
||||
@@ -309,12 +305,6 @@ export default function AuthFormContent() {
|
||||
login_type: 'phone',
|
||||
};
|
||||
|
||||
logger.api.request('POST', '/api/auth/login-with-code', {
|
||||
credential: cleanedPhone.substring(0, 3) + '****' + cleanedPhone.substring(7),
|
||||
verification_code: verificationCode.substring(0, 2) + '****',
|
||||
login_type: 'phone'
|
||||
});
|
||||
|
||||
// 调用API(根据模式选择不同的endpoint
|
||||
const response = await fetch('/api/auth/login-with-code', {
|
||||
method: 'POST',
|
||||
@@ -331,11 +321,6 @@ export default function AuthFormContent() {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.api.response('POST', '/api/auth/login-with-code', response.status, {
|
||||
...data,
|
||||
user: data.user ? { id: data.user.id, phone: data.user.phone } : null
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (!data) {
|
||||
@@ -356,24 +341,22 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// ✅ 兼容后端两种命名格式:camelCase (isNewUser) 和 snake_case (is_new_user)
|
||||
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
title: isNewUser ? '注册成功' : '登录成功',
|
||||
description: config.successDescription,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
logger.info('AuthFormContent', '登录成功', {
|
||||
isNewUser: data.isNewUser,
|
||||
userId: data.user?.id
|
||||
});
|
||||
|
||||
// 检查是否为新注册用户
|
||||
if (data.isNewUser) {
|
||||
if (isNewUser) {
|
||||
// 新注册用户,延迟后显示昵称设置引导
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useBreakpointValue
|
||||
} from '@chakra-ui/react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { useBreakpointValue } from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
import { ACTIVATION_EVENTS } from '@lib/constants';
|
||||
|
||||
/**
|
||||
* 全局认证弹窗管理器
|
||||
@@ -21,85 +17,64 @@ export default function AuthModalManager() {
|
||||
closeModal
|
||||
} = useAuthModal();
|
||||
|
||||
// 响应式尺寸配置
|
||||
const modalSize = useBreakpointValue({
|
||||
base: "md", // 移动端:md(不占满全屏)
|
||||
sm: "md", // 小屏:md
|
||||
md: "lg", // 中屏:lg
|
||||
lg: "xl" // 大屏:xl(更紧凑)
|
||||
});
|
||||
// ✅ 追踪弹窗打开次数(用于漏斗分析)
|
||||
const hasTrackedOpen = useRef(false);
|
||||
|
||||
// 响应式宽度配置
|
||||
const modalMaxW = useBreakpointValue({
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
});
|
||||
useEffect(() => {
|
||||
if (isAuthModalOpen && !hasTrackedOpen.current) {
|
||||
// ✅ 使用异步追踪,不阻塞渲染
|
||||
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
modal_type: 'auth_modal',
|
||||
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
|
||||
});
|
||||
|
||||
// 响应式水平边距
|
||||
const modalMx = useBreakpointValue({
|
||||
base: 4, // 移动端:左右各16px边距
|
||||
md: "auto" // 桌面端:自动居中
|
||||
});
|
||||
hasTrackedOpen.current = true;
|
||||
}
|
||||
|
||||
// 响应式垂直边距
|
||||
const modalMy = useBreakpointValue({
|
||||
base: 8, // 移动端:上下各32px边距
|
||||
md: 8 // 桌面端:上下各32px边距
|
||||
});
|
||||
// ✅ 弹窗关闭时重置标记(允许再次追踪)
|
||||
if (!isAuthModalOpen) {
|
||||
hasTrackedOpen.current = false;
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 条件渲染:只在打开时才渲染 Modal,避免创建不必要的 Portal
|
||||
if (!isAuthModalOpen) {
|
||||
return null;
|
||||
}
|
||||
// 响应式宽度配置(Ant Design Modal 使用数字或字符串)
|
||||
const modalMaxW = useBreakpointValue(
|
||||
{
|
||||
base: "90%", // 移动端:屏幕宽度的90%
|
||||
sm: "90%", // 小屏:90%
|
||||
md: "700px", // 中屏:固定700px
|
||||
lg: "700px" // 大屏:固定700px
|
||||
},
|
||||
{ fallback: "700px", ssr: false }
|
||||
);
|
||||
|
||||
// ✅ 使用 Ant Design Modal,完全避开 Chakra UI Portal 的 AnimatePresence 问题
|
||||
// Ant Design Modal 不使用 Framer Motion,不会有 React 18 并发渲染的 insertBefore 错误
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isAuthModalOpen}
|
||||
onClose={closeModal}
|
||||
size={modalSize}
|
||||
isCentered
|
||||
closeOnOverlayClick={false} // 防止误点击背景关闭
|
||||
closeOnEsc={true} // 允许ESC键关闭
|
||||
scrollBehavior="inside" // 内容滚动
|
||||
zIndex={999} // 低于导航栏(1000),不覆盖导航
|
||||
open={isAuthModalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
width={modalMaxW}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
zIndex={999}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
maxHeight: 'calc(90vh - 120px)',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
mask: {
|
||||
backdropFilter: 'blur(10px)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 半透明背景 + 模糊效果 */}
|
||||
<ModalOverlay
|
||||
bg="blackAlpha.700"
|
||||
backdropFilter="blur(10px)"
|
||||
/>
|
||||
|
||||
{/* 弹窗内容容器 */}
|
||||
<ModalContent
|
||||
bg="white"
|
||||
boxShadow="2xl"
|
||||
borderRadius="2xl"
|
||||
maxW={modalMaxW}
|
||||
mx={modalMx}
|
||||
my={modalMy}
|
||||
position="relative"
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<ModalCloseButton
|
||||
position="absolute"
|
||||
right={4}
|
||||
top={4}
|
||||
zIndex={9999}
|
||||
color="gray.500"
|
||||
bg="transparent"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
borderRadius="full"
|
||||
size="lg"
|
||||
onClick={closeModal}
|
||||
/>
|
||||
|
||||
{/* 弹窗主体内容 */}
|
||||
<ModalBody p={6}>
|
||||
<AuthFormContent />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
<AuthFormContent />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
53
src/components/Button2/index.tsx
Normal file
53
src/components/Button2/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
|
||||
type CommonProps = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
isPrimary?: boolean;
|
||||
isSecondary?: boolean;
|
||||
};
|
||||
|
||||
type ButtonAsButton = {
|
||||
as?: "button";
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
type ButtonAsAnchor = {
|
||||
as: "a";
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
type ButtonAsLink = {
|
||||
as: "link";
|
||||
} & LinkProps;
|
||||
|
||||
type ButtonProps = CommonProps &
|
||||
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
children,
|
||||
isPrimary,
|
||||
isSecondary,
|
||||
as = "button",
|
||||
...props
|
||||
}) => {
|
||||
const isLink = as === "link";
|
||||
const Component: React.ElementType = isLink ? Link : as;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
|
||||
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
|
||||
} ${
|
||||
isSecondary
|
||||
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
|
||||
: ""
|
||||
} ${className || ""}`}
|
||||
{...(isLink ? (props as LinkProps) : props)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -23,8 +23,8 @@ import { FiTarget, FiCheckCircle, FiXCircle, FiClock, FiTool } from 'react-icons
|
||||
* 执行计划卡片组件
|
||||
*/
|
||||
export const PlanCard = ({ plan, stepResults }) => {
|
||||
const cardBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const borderColor = useColorModeValue('blue.200', 'blue.700');
|
||||
const cardBg = useColorModeValue('blue.50', 'rgba(40, 45, 50, 0.8)');
|
||||
const borderColor = useColorModeValue('blue.200', 'rgba(255, 215, 0, 0.3)');
|
||||
const successColor = useColorModeValue('green.500', 'green.300');
|
||||
const errorColor = useColorModeValue('red.500', 'red.300');
|
||||
const pendingColor = useColorModeValue('gray.400', 'gray.500');
|
||||
@@ -73,7 +73,7 @@ export const PlanCard = ({ plan, stepResults }) => {
|
||||
<Icon as={FiTarget} color="blue.500" boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">执行目标</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600" pl={7}>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', '#9BA1A6')} pl={7}>
|
||||
{plan.goal}
|
||||
</Text>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const PlanCard = ({ plan, stepResults }) => {
|
||||
{plan.reasoning && (
|
||||
<>
|
||||
<Text fontSize="sm" fontWeight="bold">规划思路:</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', '#9BA1A6')}>
|
||||
{plan.reasoning}
|
||||
</Text>
|
||||
<Divider />
|
||||
@@ -106,7 +106,7 @@ export const PlanCard = ({ plan, stepResults }) => {
|
||||
<HStack
|
||||
key={index}
|
||||
p={2}
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
bg={useColorModeValue('white', 'rgba(50, 55, 60, 0.6)')}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={stepColor}
|
||||
@@ -129,7 +129,7 @@ export const PlanCard = ({ plan, stepResults }) => {
|
||||
status === 'failed' ? '✗ 失败' : '⏳ 等待'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.600', '#9BA1A6')}>
|
||||
{step.reason}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -23,8 +23,8 @@ import { FiChevronDown, FiChevronUp, FiCheckCircle, FiXCircle, FiClock, FiDataba
|
||||
export const StepResultCard = ({ stepResult }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const cardBg = useColorModeValue('white', 'rgba(40, 45, 50, 0.8)');
|
||||
const borderColor = useColorModeValue('gray.200', 'rgba(255, 215, 0, 0.2)');
|
||||
const successColor = useColorModeValue('green.500', 'green.300');
|
||||
const errorColor = useColorModeValue('red.500', 'red.300');
|
||||
|
||||
@@ -80,7 +80,7 @@ export const StepResultCard = ({ stepResult }) => {
|
||||
justify="space-between"
|
||||
cursor="pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'rgba(50, 55, 60, 0.7)') }}
|
||||
>
|
||||
<HStack flex={1}>
|
||||
<Icon as={StatusIcon} color={`${statusColorScheme}.500`} boxSize={5} />
|
||||
@@ -94,7 +94,7 @@ export const StepResultCard = ({ stepResult }) => {
|
||||
stepResult.status === 'failed' ? '失败' : '执行中'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.500', '#9BA1A6')}>
|
||||
耗时: {stepResult.execution_time?.toFixed(2)}s
|
||||
</Text>
|
||||
</VStack>
|
||||
@@ -140,7 +140,7 @@ export const StepResultCard = ({ stepResult }) => {
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
p={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.800')}
|
||||
bg={useColorModeValue('gray.50', 'rgba(25, 28, 32, 0.6)')}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
@@ -155,7 +155,7 @@ export const StepResultCard = ({ stepResult }) => {
|
||||
</Code>
|
||||
))}
|
||||
{stepResult.result.length > 3 && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.500', '#9BA1A6')}>
|
||||
...还有 {stepResult.result.length - 3} 条记录
|
||||
</Text>
|
||||
)}
|
||||
@@ -172,7 +172,7 @@ export const StepResultCard = ({ stepResult }) => {
|
||||
{stepResult.status === 'failed' && stepResult.error && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="xs" fontWeight="bold" color="red.500">错误信息:</Text>
|
||||
<Text fontSize="xs" color="red.600" p={2} bg="red.50" borderRadius="md">
|
||||
<Text fontSize="xs" color={useColorModeValue('red.600', 'red.300')} p={2} bg={useColorModeValue('red.50', 'rgba(220, 38, 38, 0.15)')} borderRadius="md">
|
||||
{stepResult.error}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const now = dayjs();
|
||||
const time = dayjs(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/GlobalComponents.js
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -14,7 +14,7 @@ import ScrollToTop from './ScrollToTop';
|
||||
|
||||
// Bytedesk客服组件
|
||||
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
|
||||
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
@@ -74,7 +74,9 @@ function ConnectionStatusBarWrapper() {
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
|
||||
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,13 +92,11 @@ export function GlobalComponents() {
|
||||
{/* 通知容器 */}
|
||||
<NotificationContainer />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfigMemo}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
309
src/components/ImageLightbox/index.js
Normal file
309
src/components/ImageLightbox/index.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 图片灯箱组件
|
||||
* 点击图片放大查看
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Image,
|
||||
Box,
|
||||
IconButton,
|
||||
HStack,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 单图片灯箱
|
||||
*/
|
||||
export const ImageLightbox = ({ src, alt, ...props }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 缩略图 */}
|
||||
<Box
|
||||
position="relative"
|
||||
cursor="pointer"
|
||||
onClick={onOpen}
|
||||
_hover={{
|
||||
'& .zoom-icon': {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
w="100%"
|
||||
h="100%"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
filter: 'brightness(0.8)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 放大图标 */}
|
||||
<Box
|
||||
className="zoom-icon"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
opacity={0}
|
||||
transition="opacity 0.3s"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Box
|
||||
bg="blackAlpha.700"
|
||||
borderRadius="full"
|
||||
p="3"
|
||||
>
|
||||
<ZoomIn size={32} color="white" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 灯箱模态框 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="full" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.900" backdropFilter="blur(10px)" />
|
||||
<ModalContent bg="transparent" boxShadow="none">
|
||||
<ModalCloseButton
|
||||
position="fixed"
|
||||
top="4"
|
||||
right="4"
|
||||
size="lg"
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
borderRadius="full"
|
||||
zIndex={2}
|
||||
/>
|
||||
<ModalBody display="flex" alignItems="center" justifyContent="center" p="0">
|
||||
<MotionBox
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
maxW="90vw"
|
||||
maxH="90vh"
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
maxW="100%"
|
||||
maxH="90vh"
|
||||
objectFit="contain"
|
||||
borderRadius="lg"
|
||||
/>
|
||||
</MotionBox>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 多图片轮播灯箱
|
||||
*/
|
||||
export const ImageGalleryLightbox = ({ images, initialIndex = 0, ...props }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
|
||||
const handleOpen = (index) => {
|
||||
setCurrentIndex(index);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowLeft') handlePrev();
|
||||
if (e.key === 'ArrowRight') handleNext();
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 缩略图网格 */}
|
||||
<HStack spacing="2" flexWrap="wrap" {...props}>
|
||||
{images.map((image, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
position="relative"
|
||||
cursor="pointer"
|
||||
onClick={() => handleOpen(index)}
|
||||
_hover={{
|
||||
'& .zoom-icon': {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={image.src || image}
|
||||
alt={image.alt || `图片 ${index + 1}`}
|
||||
w="150px"
|
||||
h="150px"
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
filter: 'brightness(0.8)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 放大图标 */}
|
||||
<Box
|
||||
className="zoom-icon"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
opacity={0}
|
||||
transition="opacity 0.3s"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Box bg="blackAlpha.700" borderRadius="full" p="2">
|
||||
<ZoomIn size={24} color="white" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
{/* 灯箱模态框(带轮播) */}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
isCentered
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.900" backdropFilter="blur(10px)" />
|
||||
<ModalContent bg="transparent" boxShadow="none">
|
||||
{/* 关闭按钮 */}
|
||||
<IconButton
|
||||
icon={<X />}
|
||||
position="fixed"
|
||||
top="4"
|
||||
right="4"
|
||||
size="lg"
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
borderRadius="full"
|
||||
zIndex={2}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<ModalBody
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p="0"
|
||||
position="relative"
|
||||
>
|
||||
{/* 左箭头 */}
|
||||
{images.length > 1 && (
|
||||
<IconButton
|
||||
icon={<ChevronLeft />}
|
||||
position="absolute"
|
||||
left="4"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
size="lg"
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
borderRadius="full"
|
||||
zIndex={2}
|
||||
onClick={handlePrev}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<MotionBox
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
maxW="90vw"
|
||||
maxH="90vh"
|
||||
>
|
||||
<Image
|
||||
src={images[currentIndex].src || images[currentIndex]}
|
||||
alt={images[currentIndex].alt || `图片 ${currentIndex + 1}`}
|
||||
maxW="100%"
|
||||
maxH="90vh"
|
||||
objectFit="contain"
|
||||
borderRadius="lg"
|
||||
/>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 右箭头 */}
|
||||
{images.length > 1 && (
|
||||
<IconButton
|
||||
icon={<ChevronRight />}
|
||||
position="absolute"
|
||||
right="4"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
size="lg"
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
borderRadius="full"
|
||||
zIndex={2}
|
||||
onClick={handleNext}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片计数 */}
|
||||
{images.length > 1 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="4"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
bg="blackAlpha.700"
|
||||
color="white"
|
||||
px="4"
|
||||
py="2"
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="600"
|
||||
>
|
||||
{currentIndex + 1} / {images.length}
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageLightbox;
|
||||
270
src/components/ImagePreviewModal/index.js
Normal file
270
src/components/ImagePreviewModal/index.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 图片预览弹窗组件
|
||||
* 支持多张图片左右切换、缩放、下载
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Image,
|
||||
IconButton,
|
||||
HStack,
|
||||
Text,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
const ImagePreviewModal = ({ isOpen, onClose, images = [], initialIndex = 0 }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
// 切换到上一张
|
||||
const handlePrevious = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
|
||||
setScale(1); // 重置缩放
|
||||
};
|
||||
|
||||
// 切换到下一张
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
setScale(1); // 重置缩放
|
||||
};
|
||||
|
||||
// 放大
|
||||
const handleZoomIn = () => {
|
||||
setScale((prev) => Math.min(prev + 0.25, 3));
|
||||
};
|
||||
|
||||
// 缩小
|
||||
const handleZoomOut = () => {
|
||||
setScale((prev) => Math.max(prev - 0.25, 0.5));
|
||||
};
|
||||
|
||||
// 下载图片
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = images[currentIndex];
|
||||
link.download = `image-${currentIndex + 1}.jpg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// 键盘快捷键
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
handlePrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
handleNext();
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
handleZoomIn();
|
||||
break;
|
||||
case '-':
|
||||
handleZoomOut();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, currentIndex]);
|
||||
|
||||
// 关闭时重置状态
|
||||
const handleClose = () => {
|
||||
setScale(1);
|
||||
setCurrentIndex(initialIndex);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="full" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.900" backdropFilter="blur(10px)" />
|
||||
<ModalContent bg="transparent" boxShadow="none" m="0">
|
||||
<ModalCloseButton
|
||||
size="lg"
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
zIndex="2"
|
||||
top="20px"
|
||||
right="20px"
|
||||
/>
|
||||
|
||||
<ModalBody
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
p="0"
|
||||
>
|
||||
{/* 图片显示区域 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<MotionBox
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
maxH="90vh"
|
||||
maxW="90vw"
|
||||
>
|
||||
<Image
|
||||
src={images[currentIndex]}
|
||||
alt={`图片 ${currentIndex + 1}`}
|
||||
maxH="90vh"
|
||||
maxW="90vw"
|
||||
objectFit="contain"
|
||||
transform={`scale(${scale})`}
|
||||
transition="transform 0.3s"
|
||||
cursor={scale > 1 ? 'grab' : 'default'}
|
||||
userSelect="none"
|
||||
/>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 左右切换按钮(仅多张图片时显示) */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<ChevronLeft size={32} />}
|
||||
position="absolute"
|
||||
left="20px"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
onClick={handlePrevious}
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
bg="blackAlpha.600"
|
||||
color="white"
|
||||
_hover={{ bg: 'blackAlpha.800', transform: 'translateY(-50%) scale(1.1)' }}
|
||||
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
|
||||
aria-label="上一张"
|
||||
zIndex="2"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
icon={<ChevronRight size={32} />}
|
||||
position="absolute"
|
||||
right="20px"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
onClick={handleNext}
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
bg="blackAlpha.600"
|
||||
color="white"
|
||||
_hover={{ bg: 'blackAlpha.800', transform: 'translateY(-50%) scale(1.1)' }}
|
||||
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
|
||||
aria-label="下一张"
|
||||
zIndex="2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 底部工具栏 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="30px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
bg="blackAlpha.700"
|
||||
borderRadius="full"
|
||||
px="6"
|
||||
py="3"
|
||||
backdropFilter="blur(10px)"
|
||||
zIndex="2"
|
||||
>
|
||||
<HStack spacing="4">
|
||||
{/* 缩放控制 */}
|
||||
<HStack spacing="2">
|
||||
<IconButton
|
||||
icon={<ZoomOut size={18} />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
onClick={handleZoomOut}
|
||||
isDisabled={scale <= 0.5}
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label="缩小"
|
||||
/>
|
||||
<Text color="white" fontSize="sm" fontWeight="500" minW="60px" textAlign="center">
|
||||
{Math.round(scale * 100)}%
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={<ZoomIn size={18} />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
onClick={handleZoomIn}
|
||||
isDisabled={scale >= 3}
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label="放大"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<IconButton
|
||||
icon={<Download size={18} />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
onClick={handleDownload}
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
aria-label="下载图片"
|
||||
/>
|
||||
|
||||
{/* 图片计数(仅多张图片时显示) */}
|
||||
{images.length > 1 && (
|
||||
<Text color="white" fontSize="sm" fontWeight="500">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 快捷键提示 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="80px"
|
||||
left="20px"
|
||||
bg="blackAlpha.600"
|
||||
borderRadius="md"
|
||||
px="4"
|
||||
py="2"
|
||||
backdropFilter="blur(10px)"
|
||||
>
|
||||
<Text color="whiteAlpha.800" fontSize="xs">
|
||||
快捷键: ← → 切换 | + - 缩放 | ESC 关闭
|
||||
</Text>
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePreviewModal;
|
||||
@@ -54,13 +54,11 @@ import { WatchlistMenu, FollowingEventsMenu } from './components/FeatureMenus';
|
||||
import { useWatchlist } from '../../hooks/useWatchlist';
|
||||
import { useFollowingEvents } from '../../hooks/useFollowingEvents';
|
||||
|
||||
// Phase 7 优化: 提取的二级导航、资料完整性、右侧功能区组件
|
||||
import SecondaryNav from './components/SecondaryNav';
|
||||
// Phase 7 优化: 提取的资料完整性、右侧功能区组件
|
||||
import ProfileCompletenessAlert from './components/ProfileCompletenessAlert';
|
||||
import { useProfileCompleteness } from '../../hooks/useProfileCompleteness';
|
||||
import NavbarActions from './components/NavbarActions';
|
||||
|
||||
// Phase 7: SecondaryNav 组件已提取到 ./components/SecondaryNav/index.js
|
||||
// Phase 4: MoreNavMenu 和 NavItems 组件已提取到 Navigation 目录
|
||||
|
||||
export default function HomeNavbar() {
|
||||
@@ -152,8 +150,10 @@ export default function HomeNavbar() {
|
||||
)}
|
||||
|
||||
<Box
|
||||
position="sticky"
|
||||
position="fixed"
|
||||
top={showCompletenessAlert ? "60px" : 0}
|
||||
left={0}
|
||||
right={0}
|
||||
zIndex={1000}
|
||||
bg={navbarBg}
|
||||
backdropFilter="blur(10px)"
|
||||
@@ -167,19 +167,8 @@ export default function HomeNavbar() {
|
||||
<BrandLogo />
|
||||
|
||||
{/* 中间导航区域 - 响应式 (Phase 4 优化) */}
|
||||
{isMobile ? (
|
||||
// 移动端:汉堡菜单
|
||||
<IconButton
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
aria-label="Open menu"
|
||||
/>
|
||||
) : isTablet ? (
|
||||
// 中屏(平板):"更多"下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
) : (
|
||||
// 大屏(桌面):完整导航菜单
|
||||
{isDesktop && (
|
||||
// 桌面端:完整导航菜单(移动端和平板端的汉堡菜单已移至右侧)
|
||||
<DesktopNav isAuthenticated={isAuthenticated} user={user} />
|
||||
)}
|
||||
|
||||
@@ -189,6 +178,9 @@ export default function HomeNavbar() {
|
||||
isAuthenticated={isAuthenticated}
|
||||
user={user}
|
||||
isDesktop={isDesktop}
|
||||
isTablet={isTablet}
|
||||
isMobile={isMobile}
|
||||
onMenuOpen={onOpen}
|
||||
handleLogout={handleLogout}
|
||||
watchlistQuotes={watchlistQuotes}
|
||||
followingEvents={followingEvents}
|
||||
@@ -207,9 +199,6 @@ export default function HomeNavbar() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
|
||||
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
|
||||
|
||||
{/* 投资日历 Modal - 已移至 CalendarButton 组件内部 */}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
// Navbar 右侧功能区组件
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { HStack, Spinner } from '@chakra-ui/react';
|
||||
import ThemeToggleButton from '../ThemeToggleButton';
|
||||
import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react';
|
||||
import { HamburgerIcon } from '@chakra-ui/icons';
|
||||
// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换
|
||||
import LoginButton from '../LoginButton';
|
||||
import CalendarButton from '../CalendarButton';
|
||||
import { WatchlistMenu, FollowingEventsMenu } from '../FeatureMenus';
|
||||
import { DesktopUserMenu, TabletUserMenu } from '../UserMenu';
|
||||
import { PersonalCenterMenu } from '../Navigation';
|
||||
import { PersonalCenterMenu, MoreMenu } from '../Navigation';
|
||||
|
||||
/**
|
||||
* Navbar 右侧功能区组件
|
||||
@@ -19,6 +20,9 @@ import { PersonalCenterMenu } from '../Navigation';
|
||||
* @param {boolean} props.isAuthenticated - 是否已登录
|
||||
* @param {Object} props.user - 用户对象
|
||||
* @param {boolean} props.isDesktop - 是否为桌面端
|
||||
* @param {boolean} props.isTablet - 是否为平板端
|
||||
* @param {boolean} props.isMobile - 是否为移动端
|
||||
* @param {Function} props.onMenuOpen - 打开移动端抽屉菜单的回调
|
||||
* @param {Function} props.handleLogout - 登出回调
|
||||
* @param {Array} props.watchlistQuotes - 自选股数据(用于 TabletUserMenu)
|
||||
* @param {Array} props.followingEvents - 关注事件数据(用于 TabletUserMenu)
|
||||
@@ -28,15 +32,15 @@ const NavbarActions = memo(({
|
||||
isAuthenticated,
|
||||
user,
|
||||
isDesktop,
|
||||
isTablet,
|
||||
isMobile,
|
||||
onMenuOpen,
|
||||
handleLogout,
|
||||
watchlistQuotes,
|
||||
followingEvents
|
||||
}) => {
|
||||
return (
|
||||
<HStack spacing={{ base: 2, md: 4 }}>
|
||||
{/* 主题切换按钮 */}
|
||||
<ThemeToggleButton />
|
||||
|
||||
{/* 显示加载状态 */}
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
@@ -64,13 +68,26 @@ const NavbarActions = memo(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个人中心下拉菜单 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
{/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */}
|
||||
{isDesktop ? (
|
||||
// 桌面端:个人中心下拉菜单
|
||||
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
||||
) : isTablet ? (
|
||||
// 平板端:MoreMenu 下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
) : (
|
||||
// 移动端:汉堡菜单(打开抽屉)
|
||||
<IconButton
|
||||
icon={<HamburgerIcon />}
|
||||
variant="ghost"
|
||||
onClick={onMenuOpen}
|
||||
aria-label="打开菜单"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
// 未登录状态 - 单一按钮
|
||||
// 未登录状态 - 仅显示登录按钮
|
||||
<LoginButton />
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// src/components/Navbars/components/SecondaryNav/config.js
|
||||
// 二级导航配置数据
|
||||
|
||||
/**
|
||||
* 二级导航配置结构
|
||||
* - key: 匹配的路径前缀
|
||||
* - title: 导航组标题
|
||||
* - items: 导航项列表
|
||||
* - path: 路径
|
||||
* - label: 显示文本
|
||||
* - badges: 徽章列表 (可选)
|
||||
* - external: 是否外部链接 (可选)
|
||||
*/
|
||||
export const secondaryNavConfig = {
|
||||
'/community': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{
|
||||
path: '/community',
|
||||
label: '事件中心',
|
||||
badges: [
|
||||
{ text: 'HOT', colorScheme: 'green' },
|
||||
{ text: 'NEW', colorScheme: 'red' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/concepts',
|
||||
label: '概念中心',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
'/concepts': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{
|
||||
path: '/community',
|
||||
label: '事件中心',
|
||||
badges: [
|
||||
{ text: 'HOT', colorScheme: 'green' },
|
||||
{ text: 'NEW', colorScheme: 'red' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/concepts',
|
||||
label: '概念中心',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
'/limit-analyse': {
|
||||
title: '行情复盘',
|
||||
items: [
|
||||
{
|
||||
path: '/limit-analyse',
|
||||
label: '涨停分析',
|
||||
badges: [{ text: 'FREE', colorScheme: 'blue' }]
|
||||
},
|
||||
{
|
||||
path: '/stocks',
|
||||
label: '个股中心',
|
||||
badges: [{ text: 'HOT', colorScheme: 'green' }]
|
||||
},
|
||||
{
|
||||
path: '/trading-simulation',
|
||||
label: '模拟盘',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
'/stocks': {
|
||||
title: '行情复盘',
|
||||
items: [
|
||||
{
|
||||
path: '/limit-analyse',
|
||||
label: '涨停分析',
|
||||
badges: [{ text: 'FREE', colorScheme: 'blue' }]
|
||||
},
|
||||
{
|
||||
path: '/stocks',
|
||||
label: '个股中心',
|
||||
badges: [{ text: 'HOT', colorScheme: 'green' }]
|
||||
},
|
||||
{
|
||||
path: '/trading-simulation',
|
||||
label: '模拟盘',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
'/trading-simulation': {
|
||||
title: '行情复盘',
|
||||
items: [
|
||||
{
|
||||
path: '/limit-analyse',
|
||||
label: '涨停分析',
|
||||
badges: [{ text: 'FREE', colorScheme: 'blue' }]
|
||||
},
|
||||
{
|
||||
path: '/stocks',
|
||||
label: '个股中心',
|
||||
badges: [{ text: 'HOT', colorScheme: 'green' }]
|
||||
},
|
||||
{
|
||||
path: '/trading-simulation',
|
||||
label: '模拟盘',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
// src/components/Navbars/components/SecondaryNav/index.js
|
||||
// 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Flex,
|
||||
Badge,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
|
||||
import { secondaryNavConfig } from './config';
|
||||
|
||||
/**
|
||||
* 二级导航栏组件
|
||||
* 根据当前路径显示对应的二级菜单项
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.showCompletenessAlert - 是否显示完整性提醒(影响 sticky top 位置)
|
||||
*/
|
||||
const SecondaryNav = memo(({ showCompletenessAlert }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 颜色模式
|
||||
const navbarBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const itemHoverBg = useColorModeValue('white', 'gray.600');
|
||||
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 导航埋点
|
||||
const navEvents = useNavigationEvents({ component: 'secondary_nav' });
|
||||
|
||||
// 找到当前路径对应的二级导航配置
|
||||
const currentConfig = Object.keys(secondaryNavConfig).find(key =>
|
||||
location.pathname.includes(key)
|
||||
);
|
||||
|
||||
// 如果没有匹配的二级导航,不显示
|
||||
if (!currentConfig) return null;
|
||||
|
||||
const config = secondaryNavConfig[currentConfig];
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={navbarBg}
|
||||
borderBottom="1px"
|
||||
borderColor={borderColorValue}
|
||||
py={2}
|
||||
position="sticky"
|
||||
top={showCompletenessAlert ? "120px" : "60px"}
|
||||
zIndex={100}
|
||||
>
|
||||
<Container maxW="container.xl" px={4}>
|
||||
<HStack spacing={1}>
|
||||
{/* 显示一级菜单标题 */}
|
||||
<Text fontSize="sm" color="gray.500" mr={2}>
|
||||
{config.title}:
|
||||
</Text>
|
||||
|
||||
{/* 二级菜单项 */}
|
||||
{config.items.map((item, index) => {
|
||||
const isActive = location.pathname.includes(item.path);
|
||||
|
||||
return item.external ? (
|
||||
<Button
|
||||
key={index}
|
||||
as="a"
|
||||
href={item.path}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg="transparent"
|
||||
color="inherit"
|
||||
fontWeight="normal"
|
||||
_hover={{ bg: itemHoverBg }}
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text>{item.label}</Text>
|
||||
{item.badges && item.badges.length > 0 && (
|
||||
<HStack spacing={1}>
|
||||
{item.badges.map((badge, bIndex) => (
|
||||
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
|
||||
{badge.text}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
// 追踪侧边栏菜单点击
|
||||
navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false);
|
||||
navigate(item.path);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg={isActive ? 'blue.50' : 'transparent'}
|
||||
color={isActive ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive ? 'bold' : 'normal'}
|
||||
borderBottom={isActive ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
borderRadius={isActive ? '0' : 'md'}
|
||||
_hover={{ bg: isActive ? 'blue.100' : itemHoverBg }}
|
||||
px={3}
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text>{item.label}</Text>
|
||||
{item.badges && item.badges.length > 0 && (
|
||||
<HStack spacing={1}>
|
||||
{item.badges.map((badge, bIndex) => (
|
||||
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
|
||||
{badge.text}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SecondaryNav.displayName = 'SecondaryNav';
|
||||
|
||||
export default SecondaryNav;
|
||||
@@ -1,51 +0,0 @@
|
||||
// src/components/Navbars/components/ThemeToggleButton.js
|
||||
// 主题切换按钮组件 - Phase 7 优化:添加导航埋点支持
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { IconButton, useColorMode } from '@chakra-ui/react';
|
||||
import { SunIcon, MoonIcon } from '@chakra-ui/icons';
|
||||
import { useNavigationEvents } from '../../../hooks/useNavigationEvents';
|
||||
|
||||
/**
|
||||
* 主题切换按钮组件
|
||||
* 支持在亮色和暗色主题之间切换,包含导航埋点
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 memo 避免父组件重新渲染时的不必要更新
|
||||
* - 只依赖 colorMode,当主题切换时才重新渲染
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.size - 按钮大小,默认 'sm'
|
||||
* @param {string} props.variant - 按钮样式,默认 'ghost'
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const ThemeToggleButton = memo(({ size = 'sm', variant = 'ghost' }) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const navEvents = useNavigationEvents({ component: 'theme_toggle' });
|
||||
|
||||
const handleToggle = () => {
|
||||
// 追踪主题切换
|
||||
const fromTheme = colorMode;
|
||||
const toTheme = colorMode === 'light' ? 'dark' : 'light';
|
||||
navEvents.trackThemeChanged(fromTheme, toTheme);
|
||||
|
||||
// 切换主题
|
||||
toggleColorMode();
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="切换主题"
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={handleToggle}
|
||||
variant={variant}
|
||||
size={size}
|
||||
minW={{ base: '36px', md: '40px' }}
|
||||
minH={{ base: '36px', md: '40px' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ThemeToggleButton.displayName = 'ThemeToggleButton';
|
||||
|
||||
export default ThemeToggleButton;
|
||||
384
src/components/PerformancePanel.tsx
Normal file
384
src/components/PerformancePanel.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
// src/components/PerformancePanel.tsx
|
||||
// 性能监控可视化面板 - 仅开发环境显示
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
IconButton,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
useDisclosure,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdSpeed, MdClose, MdRefresh, MdFileDownload } from 'react-icons/md';
|
||||
import { performanceMonitor } from '@/utils/performanceMonitor';
|
||||
|
||||
/**
|
||||
* 性能评分颜色映射
|
||||
*/
|
||||
const getScoreColor = (score: string): string => {
|
||||
switch (score) {
|
||||
case 'excellent':
|
||||
return 'green';
|
||||
case 'good':
|
||||
return 'blue';
|
||||
case 'needs improvement':
|
||||
return 'yellow';
|
||||
case 'poor':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化毫秒数
|
||||
*/
|
||||
const formatMs = (ms: number | undefined): string => {
|
||||
if (ms === undefined) return 'N/A';
|
||||
return `${ms.toFixed(0)}ms`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 性能面板组件
|
||||
*/
|
||||
export const PerformancePanel: React.FC = () => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [report, setReport] = useState<any>(null);
|
||||
|
||||
// 刷新性能数据
|
||||
const refreshData = () => {
|
||||
const newReport = performanceMonitor.getReport();
|
||||
setReport(newReport);
|
||||
};
|
||||
|
||||
// 导出 JSON
|
||||
const exportJSON = () => {
|
||||
const json = performanceMonitor.exportJSON();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `performance-report-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
}, []);
|
||||
|
||||
// 仅在开发环境显示
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 浮动按钮 */}
|
||||
<IconButton
|
||||
aria-label="Open performance panel"
|
||||
icon={<MdSpeed />}
|
||||
position="fixed"
|
||||
bottom="20px"
|
||||
right="20px"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
zIndex={9999}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
|
||||
{/* 抽屉面板 */}
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="lg">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader borderBottomWidth="1px">
|
||||
<HStack>
|
||||
<MdSpeed size={24} />
|
||||
<Text>性能监控面板</Text>
|
||||
</HStack>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
{report ? (
|
||||
<VStack spacing={4} align="stretch" py={4}>
|
||||
{/* 操作按钮 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
leftIcon={<MdRefresh />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={refreshData}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<MdFileDownload />}
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={exportJSON}
|
||||
>
|
||||
导出 JSON
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 总览 */}
|
||||
<Box p={4} bg="gray.50" borderRadius="md">
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="bold">性能评分</Text>
|
||||
<Badge
|
||||
colorScheme={getScoreColor(report.summary.performanceScore)}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{report.summary.performanceScore.toUpperCase()}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
性能标记: {report.summary.totalMarks}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
性能测量: {report.summary.totalMeasures}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 网络指标 */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
网络指标
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<MetricStat
|
||||
label="DNS 查询"
|
||||
value={formatMs(report.metrics.dns)}
|
||||
threshold={100}
|
||||
actualValue={report.metrics.dns}
|
||||
/>
|
||||
<MetricStat
|
||||
label="TCP 连接"
|
||||
value={formatMs(report.metrics.tcp)}
|
||||
threshold={100}
|
||||
actualValue={report.metrics.tcp}
|
||||
/>
|
||||
<MetricStat
|
||||
label="TTFB"
|
||||
value={formatMs(report.metrics.ttfb)}
|
||||
threshold={500}
|
||||
actualValue={report.metrics.ttfb}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 渲染指标 */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
渲染指标
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<MetricStat
|
||||
label="FP (首次绘制)"
|
||||
value={formatMs(report.metrics.fp)}
|
||||
threshold={1000}
|
||||
actualValue={report.metrics.fp}
|
||||
/>
|
||||
<MetricStat
|
||||
label="FCP (首次内容绘制)"
|
||||
value={formatMs(report.metrics.fcp)}
|
||||
threshold={1800}
|
||||
actualValue={report.metrics.fcp}
|
||||
/>
|
||||
<MetricStat
|
||||
label="LCP (最大内容绘制)"
|
||||
value={formatMs(report.metrics.lcp)}
|
||||
threshold={2500}
|
||||
actualValue={report.metrics.lcp}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* React 指标 */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
React 指标
|
||||
</Text>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<MetricStat
|
||||
label="React 初始化"
|
||||
value={formatMs(report.metrics.reactInit)}
|
||||
threshold={1000}
|
||||
actualValue={report.metrics.reactInit}
|
||||
/>
|
||||
<MetricStat
|
||||
label="认证检查"
|
||||
value={formatMs(report.metrics.authCheck)}
|
||||
threshold={300}
|
||||
actualValue={report.metrics.authCheck}
|
||||
/>
|
||||
<MetricStat
|
||||
label="首页渲染"
|
||||
value={formatMs(report.metrics.homepageRender)}
|
||||
threshold={500}
|
||||
actualValue={report.metrics.homepageRender}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 总白屏时间 */}
|
||||
<Box p={4} bg="blue.50" borderRadius="md" borderWidth="2px" borderColor="blue.200">
|
||||
<Stat>
|
||||
<StatLabel>总白屏时间</StatLabel>
|
||||
<StatNumber fontSize="3xl">
|
||||
{formatMs(report.metrics.totalWhiteScreen)}
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
{report.metrics.totalWhiteScreen && report.metrics.totalWhiteScreen < 1500
|
||||
? '✅ 优秀'
|
||||
: report.metrics.totalWhiteScreen && report.metrics.totalWhiteScreen < 2000
|
||||
? '⚠️ 良好'
|
||||
: '❌ 需要优化'}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</Box>
|
||||
|
||||
{/* 优化建议 */}
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left" fontWeight="bold">
|
||||
优化建议 ({report.recommendations.length})
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{report.recommendations.map((rec: string, index: number) => (
|
||||
<Text key={index} fontSize="sm">
|
||||
{rec}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 性能标记 */}
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left" fontWeight="bold">
|
||||
性能标记 ({report.marks.length})
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{report.marks.map((mark: any, index: number) => (
|
||||
<HStack key={index} justify="space-between" fontSize="sm">
|
||||
<Text>{mark.name}</Text>
|
||||
<Text color="gray.600">{mark.time.toFixed(2)}ms</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* 性能测量 */}
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left" fontWeight="bold">
|
||||
性能测量 ({report.measures.length})
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{report.measures.map((measure: any, index: number) => (
|
||||
<Box key={index} p={2} bg="gray.50" borderRadius="md">
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
{measure.name}
|
||||
</Text>
|
||||
<Badge>{measure.duration.toFixed(2)}ms</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{measure.startMark} → {measure.endMark}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</VStack>
|
||||
) : (
|
||||
<Text>加载中...</Text>
|
||||
)}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 指标统计组件
|
||||
*/
|
||||
interface MetricStatProps {
|
||||
label: string;
|
||||
value: string;
|
||||
threshold: number;
|
||||
actualValue?: number;
|
||||
}
|
||||
|
||||
const MetricStat: React.FC<MetricStatProps> = ({ label, value, threshold, actualValue }) => {
|
||||
const isGood = actualValue !== undefined && actualValue < threshold;
|
||||
|
||||
return (
|
||||
<HStack justify="space-between" p={2} bg="gray.50" borderRadius="md">
|
||||
<Text fontSize="sm">{label}</Text>
|
||||
<HStack>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{value}
|
||||
</Text>
|
||||
{actualValue !== undefined && (
|
||||
<Text fontSize="xs">{isGood ? '✅' : '⚠️'}</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformancePanel;
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { pricing } from "@/mocks/pricing";
|
||||
|
||||
type PricingListProps = {
|
||||
monthly?: boolean;
|
||||
};
|
||||
|
||||
const PricingList = ({ monthly = true }: PricingListProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Splide
|
||||
className="splide-pricing splide-visible"
|
||||
options={{
|
||||
mediaQuery: "min",
|
||||
autoWidth: true,
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
gap: "1rem",
|
||||
breakpoints: {
|
||||
1024: {
|
||||
destroy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{pricing.map((item, index) => (
|
||||
<SplideSlide
|
||||
className={`${index === 1 ? "" : "py-3"}`}
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={`w-[19rem] h-full px-6 ${
|
||||
index === 1 ? "py-12" : "py-8"
|
||||
} bg-n-8 border border-n-6 rounded-[2rem] lg:w-auto`}
|
||||
key={item.id}
|
||||
>
|
||||
<h4
|
||||
className={`h4 mb-4 ${
|
||||
index === 0 ? "text-color-2" : ""
|
||||
} ${index === 1 ? "text-color-1" : ""} ${
|
||||
index === 2 ? "text-color-3" : ""
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="body-2 min-h-[4rem] mb-3 text-n-1/50">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="flex items-center h-[5.5rem] mb-6">
|
||||
{item.price && (
|
||||
<>
|
||||
<div className="h3">$</div>
|
||||
<div className="text-[5.5rem] leading-none font-bold">
|
||||
{monthly
|
||||
? item.price
|
||||
: item.price !== "0"
|
||||
? (
|
||||
+item.price *
|
||||
12 *
|
||||
0.9
|
||||
).toFixed(1)
|
||||
: item.price}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full mb-6"
|
||||
href={
|
||||
item.price
|
||||
? "/pricing"
|
||||
: "mailto:info@ui8.net"
|
||||
}
|
||||
white={!!item.price}
|
||||
>
|
||||
{item.price ? "Get started" : "Contact us"}
|
||||
</Button>
|
||||
<ul>
|
||||
{item.features.map((feature, index) => (
|
||||
<li
|
||||
className="flex items-start py-5 border-t border-n-6"
|
||||
key={index}
|
||||
>
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<p className="body-2 ml-4">{feature}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
|
||||
{pricing.map((item, index) => (
|
||||
<button
|
||||
className="relative w-6 h-6 mx-2"
|
||||
onClick={() => handleClick(index)}
|
||||
key={item.id}
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
|
||||
index === activeIndex
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
></span>
|
||||
<span className="absolute inset-0.25 bg-n-8 rounded-full">
|
||||
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Splide>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingList;
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { pricing } from "@/mocks/pricing";
|
||||
|
||||
type PricingListProps = {
|
||||
monthly?: boolean;
|
||||
};
|
||||
|
||||
const PricingList = ({ monthly = true }: PricingListProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Splide
|
||||
className="splide-pricing splide-visible"
|
||||
options={{
|
||||
mediaQuery: "min",
|
||||
autoWidth: true,
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
gap: "1rem",
|
||||
breakpoints: {
|
||||
1024: {
|
||||
destroy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{pricing.map((item, index) => (
|
||||
<SplideSlide
|
||||
className={`${index === 1 ? "" : "py-3"}`}
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={`w-[19rem] h-full px-6 ${
|
||||
index === 1 ? "py-12" : "py-8"
|
||||
} bg-n-8 border border-n-6 rounded-[2rem] lg:w-auto`}
|
||||
key={item.id}
|
||||
>
|
||||
<h4
|
||||
className={`h4 mb-4 ${
|
||||
index === 0 ? "text-color-2" : ""
|
||||
} ${index === 1 ? "text-color-1" : ""} ${
|
||||
index === 2 ? "text-color-3" : ""
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="body-2 min-h-[4rem] mb-3 text-n-1/50">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="flex items-center h-[5.5rem] mb-6">
|
||||
{item.price && (
|
||||
<>
|
||||
<div className="h3">$</div>
|
||||
<div className="text-[5.5rem] leading-none font-bold">
|
||||
{monthly
|
||||
? item.price
|
||||
: item.price !== "0"
|
||||
? (
|
||||
+item.price *
|
||||
12 *
|
||||
0.9
|
||||
).toFixed(1)
|
||||
: item.price}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full mb-6"
|
||||
href={
|
||||
item.price
|
||||
? "/pricing"
|
||||
: "mailto:info@ui8.net"
|
||||
}
|
||||
white={!!item.price}
|
||||
>
|
||||
{item.price ? "Get started" : "Contact us"}
|
||||
</Button>
|
||||
<ul>
|
||||
{item.features.map((feature, index) => (
|
||||
<li
|
||||
className="flex items-start py-5 border-t border-n-6"
|
||||
key={index}
|
||||
>
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<p className="body-2 ml-4">{feature}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
|
||||
{pricing.map((item, index) => (
|
||||
<button
|
||||
className="relative w-6 h-6 mx-2"
|
||||
onClick={() => handleClick(index)}
|
||||
key={item.id}
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-0 bg-conic-gradient rounded-full transition-opacity ${
|
||||
index === activeIndex
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
></span>
|
||||
<span className="absolute inset-0.25 bg-n-8 rounded-full">
|
||||
<span className="absolute inset-2 bg-n-1 rounded-full"></span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Splide>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingList;
|
||||
614
src/components/StockChart/KLineChartModal.tsx
Normal file
614
src/components/StockChart/KLineChartModal.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as echarts from 'echarts';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
*/
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KLineChartModal 组件 Props
|
||||
*/
|
||||
export interface KLineChartModalProps {
|
||||
/** 模态框是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 股票信息 */
|
||||
stock: StockInfo | null;
|
||||
/** 事件时间 */
|
||||
eventTime?: string | null;
|
||||
/** 模态框大小 */
|
||||
size?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* K线数据点
|
||||
*/
|
||||
interface KLineDataPoint {
|
||||
time: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
stock,
|
||||
eventTime,
|
||||
size = '5xl',
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<KLineDataPoint[]>([]);
|
||||
|
||||
// 调试日志
|
||||
console.log('[KLineChartModal] 渲染状态:', {
|
||||
isOpen,
|
||||
stock,
|
||||
eventTime,
|
||||
dataLength: data.length,
|
||||
loading,
|
||||
error
|
||||
});
|
||||
|
||||
// 加载K线数据
|
||||
const loadData = async () => {
|
||||
if (!stock?.stock_code) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
|
||||
stockCode: stock.stock_code,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
const response = await stockService.getKlineData(
|
||||
stock.stock_code,
|
||||
'daily',
|
||||
eventTime || undefined
|
||||
);
|
||||
|
||||
console.log('[KLineChartModal] API响应:', response);
|
||||
|
||||
if (!response || !response.data || response.data.length === 0) {
|
||||
throw new Error('暂无K线数据');
|
||||
}
|
||||
|
||||
console.log('[KLineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
|
||||
dataCount: response.data.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||
logger.error('KLineChartModal', 'loadData', err as Error);
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// 延迟初始化,确保 Modal 动画完成后 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (!chartRef.current) {
|
||||
console.error('[KLineChartModal] DOM元素未找到,无法初始化图表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[KLineChartModal] 初始化图表...');
|
||||
|
||||
// 创建图表实例(不使用主题,直接在option中配置背景色)
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
|
||||
console.log('[KLineChartModal] 图表实例创建成功');
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
chartInstance.current?.resize();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, 100); // 延迟100ms等待Modal完全打开
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 更新图表数据
|
||||
useEffect(() => {
|
||||
if (data.length === 0) {
|
||||
console.log('[KLineChartModal] 无数据,跳过图表更新');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.current) {
|
||||
console.warn('[KLineChartModal] 图表实例不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[KLineChartModal] 开始更新图表,数据点:', data.length);
|
||||
|
||||
const dates = data.map((d) => d.time);
|
||||
const klineData = data.map((d) => [d.open, d.close, d.low, d.high]);
|
||||
const volumes = data.map((d) => d.volume);
|
||||
|
||||
// 计算成交量柱子颜色(涨为红,跌为绿)
|
||||
const volumeColors = data.map((d) =>
|
||||
d.close >= d.open ? '#ef5350' : '#26a69a'
|
||||
);
|
||||
|
||||
// 提取事件发生日期(YYYY-MM-DD格式)
|
||||
let eventDateStr: string | null = null;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventDate = new Date(eventTime);
|
||||
const year = eventDate.getFullYear();
|
||||
const month = (eventDate.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = eventDate.getDate().toString().padStart(2, '0');
|
||||
eventDateStr = `${year}-${month}-${day}`;
|
||||
console.log('[KLineChartModal] 事件发生日期:', eventDateStr);
|
||||
} catch (e) {
|
||||
console.error('[KLineChartModal] 解析事件日期失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 图表配置
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
borderColor: '#404040',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#e0e0e0',
|
||||
},
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: {
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const dataIndex = params[0]?.dataIndex;
|
||||
if (dataIndex === undefined) return '';
|
||||
|
||||
const item = data[dataIndex];
|
||||
const change = item.close - item.open;
|
||||
const changePercent = (change / item.open) * 100;
|
||||
const changeColor = change >= 0 ? '#ef5350' : '#26a69a';
|
||||
const changeSign = change >= 0 ? '+' : '';
|
||||
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${item.time}</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>开盘:</span>
|
||||
<span style="margin-left: 20px;">${item.open.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>收盘:</span>
|
||||
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.close.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>最高:</span>
|
||||
<span style="margin-left: 20px;">${item.high.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>最低:</span>
|
||||
<span style="margin-left: 20px;">${item.low.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>涨跌额:</span>
|
||||
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${change.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>涨跌幅:</span>
|
||||
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${changePercent.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>成交量:</span>
|
||||
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '12%',
|
||||
height: '60%',
|
||||
},
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '77%',
|
||||
height: '18%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: dates,
|
||||
gridIndex: 0,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(dates.length / 8),
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
data: dates,
|
||||
gridIndex: 1,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(dates.length / 8),
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 0,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#2a2a2a',
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
formatter: (value: number) => value.toFixed(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
formatter: (value: number) => {
|
||||
if (value >= 100000000) {
|
||||
return (value / 100000000).toFixed(1) + '亿';
|
||||
} else if (value >= 10000) {
|
||||
return (value / 10000).toFixed(1) + '万';
|
||||
}
|
||||
return value.toFixed(0);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
data: klineData,
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
itemStyle: {
|
||||
color: '#ef5350', // 涨
|
||||
color0: '#26a69a', // 跌
|
||||
borderColor: '#ef5350',
|
||||
borderColor0: '#26a69a',
|
||||
},
|
||||
markLine: eventDateStr ? {
|
||||
silent: false,
|
||||
symbol: 'none',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideEndTop',
|
||||
formatter: '事件发生',
|
||||
color: '#ffd700',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4,
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#ffd700',
|
||||
width: 2,
|
||||
type: 'solid',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
xAxis: eventDateStr,
|
||||
label: {
|
||||
formatter: '⚡ 事件发生',
|
||||
},
|
||||
},
|
||||
],
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
data: volumes,
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
return volumeColors[params.dataIndex];
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: [0, 1],
|
||||
start: 0,
|
||||
end: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option);
|
||||
console.log('[KLineChartModal] 图表option已设置');
|
||||
|
||||
// 强制resize以确保图表正确显示
|
||||
setTimeout(() => {
|
||||
chartInstance.current?.resize();
|
||||
console.log('[KLineChartModal] 图表已resize');
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 立即尝试更新,如果失败则重试
|
||||
if (!updateChart()) {
|
||||
console.log('[KLineChartModal] 第一次更新失败,200ms后重试...');
|
||||
const retryTimer = setTimeout(() => {
|
||||
updateChart();
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(retryTimer);
|
||||
}
|
||||
}, [data, stock]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, stock?.stock_code, eventTime]);
|
||||
|
||||
// 创建或获取 Portal 容器
|
||||
useEffect(() => {
|
||||
let container = document.getElementById('kline-modal-root');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'kline-modal-root';
|
||||
container.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 10000;';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return () => {
|
||||
// 组件卸载时不删除容器,因为可能会被复用
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!stock) return null;
|
||||
|
||||
console.log('[KLineChartModal] 渲染 Modal, isOpen:', isOpen);
|
||||
|
||||
// 获取 Portal 容器
|
||||
const portalContainer = document.getElementById('kline-modal-root') || document.body;
|
||||
|
||||
// 如果不显示则返回 null
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalContent = (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 10001,
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* 弹窗内容 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '90vw',
|
||||
maxWidth: '1400px',
|
||||
maxHeight: '85vh',
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '2px solid #ffd700',
|
||||
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
|
||||
borderRadius: '8px',
|
||||
zIndex: 10002,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #404040',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
|
||||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||||
</span>
|
||||
{data.length > 0 && (
|
||||
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
|
||||
共{data.length}个交易日(最多1年)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
|
||||
<span style={{ fontSize: '14px', color: '#999' }}>日K线图</span>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
💡 鼠标滚轮缩放 | 拖动查看不同时间段
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#999',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.color = '#e0e0e0')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.color = '#999')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#2a1a1a',
|
||||
border: '1px solid #ef5350',
|
||||
borderRadius: '4px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#ef5350' }}>⚠</span>
|
||||
<span style={{ color: '#e0e0e0' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.7)',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #404040',
|
||||
borderTop: '3px solid #3182ce',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: '#e0e0e0' }}>加载K线数据...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 添加旋转动画的 CSS */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, portalContainer);
|
||||
};
|
||||
|
||||
export default KLineChartModal;
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
|
||||
// 计算事件标记线位置
|
||||
let markLineData = [];
|
||||
if (eventTime && times.length > 0) {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
if (activeChartType === 'timeline') {
|
||||
@@ -507,8 +507,9 @@ const StockChartAntdModal = ({
|
||||
footer={null}
|
||||
onCancel={onCancel}
|
||||
width={width}
|
||||
style={{ position: fixed ? 'fixed' : 'absolute', left: fixed ? 50 : 0, top: fixed ? 50 : 80, zIndex: 2000 }}
|
||||
mask={false}
|
||||
centered
|
||||
zIndex={2500}
|
||||
mask={true}
|
||||
destroyOnClose={true}
|
||||
bodyStyle={{ maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', padding: '16px' }}
|
||||
>
|
||||
|
||||
287
src/components/StockChart/StockChartKLineModal.tsx
Normal file
287
src/components/StockChart/StockChartKLineModal.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* StockChartKLineModal - K 线图表模态框组件
|
||||
*
|
||||
* 使用 KLineChart 库实现的专业金融图表组件
|
||||
* 替换原有的 ECharts 实现(StockChartAntdModal.js)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Modal, Button, Radio, Select, Space, Spin, Alert } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import {
|
||||
LineChartOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 自定义 Hooks
|
||||
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
|
||||
|
||||
// 类型定义
|
||||
import type { ChartType, StockInfo } from './types';
|
||||
|
||||
// 配置常量
|
||||
import {
|
||||
CHART_TYPE_CONFIG,
|
||||
CHART_HEIGHTS,
|
||||
INDICATORS,
|
||||
DEFAULT_SUB_INDICATORS,
|
||||
} from './config';
|
||||
|
||||
// 工具函数
|
||||
import { createSubIndicators } from './utils';
|
||||
|
||||
// 日志
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// ==================== 组件 Props ====================
|
||||
|
||||
export interface StockChartKLineModalProps {
|
||||
/** 是否显示模态框 */
|
||||
visible: boolean;
|
||||
/** 关闭模态框回调 */
|
||||
onClose: () => void;
|
||||
/** 股票信息 */
|
||||
stock: StockInfo;
|
||||
/** 事件时间(ISO 字符串,可选) */
|
||||
eventTime?: string;
|
||||
/** 事件标题(用于标记标签,可选) */
|
||||
eventTitle?: string;
|
||||
}
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
|
||||
const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
stock,
|
||||
eventTime,
|
||||
eventTitle,
|
||||
}) => {
|
||||
// ==================== 状态管理 ====================
|
||||
|
||||
/** 图表类型(分时图/日K线) */
|
||||
const [chartType, setChartType] = useState<ChartType>('daily');
|
||||
|
||||
/** 选中的副图指标 */
|
||||
const [selectedIndicators, setSelectedIndicators] = useState<string[]>(
|
||||
DEFAULT_SUB_INDICATORS
|
||||
);
|
||||
|
||||
// ==================== 自定义 Hooks ====================
|
||||
|
||||
/** 图表实例管理 */
|
||||
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
|
||||
containerId: `kline-chart-${stock.stock_code}`,
|
||||
height: CHART_HEIGHTS.main,
|
||||
autoResize: true,
|
||||
});
|
||||
|
||||
/** 数据加载管理 */
|
||||
const {
|
||||
data,
|
||||
loading: dataLoading,
|
||||
error: dataError,
|
||||
loadData,
|
||||
} = useKLineData({
|
||||
chart,
|
||||
stockCode: stock.stock_code,
|
||||
chartType,
|
||||
eventTime,
|
||||
autoLoad: visible, // 模态框打开时自动加载
|
||||
});
|
||||
|
||||
/** 事件标记管理 */
|
||||
const { marker } = useEventMarker({
|
||||
chart,
|
||||
data,
|
||||
eventTime,
|
||||
eventTitle,
|
||||
autoCreate: true,
|
||||
});
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
/**
|
||||
* 切换图表类型(分时图 ↔ 日K线)
|
||||
*/
|
||||
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
|
||||
const newType = e.target.value as ChartType;
|
||||
setChartType(newType);
|
||||
|
||||
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
|
||||
newType,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换副图指标
|
||||
*/
|
||||
const handleIndicatorChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setSelectedIndicators(values);
|
||||
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先移除所有副图指标(KLineChart 会自动移除)
|
||||
// 然后创建新的指标
|
||||
createSubIndicators(chart, values);
|
||||
|
||||
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
|
||||
indicators: values,
|
||||
});
|
||||
},
|
||||
[chart]
|
||||
);
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadData();
|
||||
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
|
||||
}, [loadData]);
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/** 是否有错误 */
|
||||
const hasError = useMemo(() => {
|
||||
return !!chartError || !!dataError;
|
||||
}, [chartError, dataError]);
|
||||
|
||||
/** 错误消息 */
|
||||
const errorMessage = useMemo(() => {
|
||||
if (chartError) {
|
||||
return `图表初始化失败: ${chartError.message}`;
|
||||
}
|
||||
if (dataError) {
|
||||
return `数据加载失败: ${dataError.message}`;
|
||||
}
|
||||
return null;
|
||||
}, [chartError, dataError]);
|
||||
|
||||
/** 模态框标题 */
|
||||
const modalTitle = useMemo(() => {
|
||||
return `${stock.stock_name}(${stock.stock_code}) - ${CHART_TYPE_CONFIG[chartType].label}`;
|
||||
}, [stock, chartType]);
|
||||
|
||||
/** 是否显示加载状态 */
|
||||
const showLoading = useMemo(() => {
|
||||
return dataLoading || !isInitialized;
|
||||
}, [dataLoading, isInitialized]);
|
||||
|
||||
// ==================== 副作用 ====================
|
||||
|
||||
// 无副作用,都在 Hooks 中管理
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={1200}
|
||||
footer={null}
|
||||
centered
|
||||
destroyOnClose // 关闭时销毁组件(释放图表资源)
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<Box mb={4}>
|
||||
<Space wrap>
|
||||
{/* 图表类型切换 */}
|
||||
<Radio.Group value={chartType} onChange={handleChartTypeChange}>
|
||||
<Radio.Button value="timeline">
|
||||
<LineChartOutlined /> 分时图
|
||||
</Radio.Button>
|
||||
<Radio.Button value="daily">
|
||||
<BarChartOutlined /> 日K线
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{/* 副图指标选择 */}
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择副图指标"
|
||||
value={selectedIndicators}
|
||||
onChange={handleIndicatorChange}
|
||||
style={{ minWidth: 200 }}
|
||||
maxTagCount={2}
|
||||
>
|
||||
{INDICATORS.sub.map((indicator) => (
|
||||
<Select.Option key={indicator.name} value={indicator.name}>
|
||||
<SettingOutlined /> {indicator.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={dataLoading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</Box>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{hasError && (
|
||||
<Alert
|
||||
message="加载失败"
|
||||
description={errorMessage}
|
||||
type="error"
|
||||
closable
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box position="relative">
|
||||
{/* 加载遮罩 */}
|
||||
{showLoading && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
zIndex={10}
|
||||
>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* KLineChart 容器 */}
|
||||
<div
|
||||
ref={chartRef}
|
||||
id={`kline-chart-${stock.stock_code}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${CHART_HEIGHTS.main}px`,
|
||||
opacity: showLoading ? 0.5 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 数据信息(调试用,生产环境可移除) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box mt={2} fontSize="12px" color="gray.500">
|
||||
<Space split="|">
|
||||
<span>数据点数: {data.length}</span>
|
||||
<span>事件标记: {marker ? marker.label : '无'}</span>
|
||||
<span>图表ID: {chart?.id || '未初始化'}</span>
|
||||
</Space>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChartKLineModal;
|
||||
@@ -1,12 +1,13 @@
|
||||
// src/components/StockChart/StockChartModal.js - 统一的股票图表组件
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
import { RelationDescription } from '../StockRelation';
|
||||
|
||||
const StockChartModal = ({
|
||||
isOpen,
|
||||
@@ -14,34 +15,16 @@ const StockChartModal = ({
|
||||
stock,
|
||||
eventTime,
|
||||
isChakraUI = true, // 是否使用Chakra UI,默认true;如果false则使用Antd
|
||||
size = "6xl"
|
||||
size = "6xl",
|
||||
initialChartType = 'timeline' // 初始图表类型(timeline/daily)
|
||||
}) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstanceRef = useRef(null);
|
||||
const [chartType, setChartType] = useState('timeline');
|
||||
const [chartType, setChartType] = useState(initialChartType);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [preloadedData, setPreloadedData] = useState({});
|
||||
|
||||
// 处理关联描述(兼容对象和字符串格式)
|
||||
const getRelationDesc = () => {
|
||||
const relationDesc = stock?.relation_desc;
|
||||
|
||||
if (!relationDesc) return null;
|
||||
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
|
||||
return relationDesc.data
|
||||
.map(item => item.query_part || item.sentences || '')
|
||||
.filter(s => s)
|
||||
.join(';') || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 预加载数据
|
||||
const preloadData = async (type) => {
|
||||
if (!stock || preloadedData[type]) return;
|
||||
@@ -50,7 +33,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -111,7 +94,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -182,7 +165,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && times.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
const eventTime = eventMoment.format('HH:mm');
|
||||
|
||||
@@ -357,7 +340,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置(重要修复)
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && dates.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
// 找到事件发生日期或最接近的交易日
|
||||
@@ -539,7 +522,8 @@ const StockChartModal = ({
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
|
||||
<Box h="400px" w="100%" position="relative">
|
||||
{/* 图表区域 */}
|
||||
<Box h="500px" w="100%" position="relative">
|
||||
{loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
@@ -558,27 +542,13 @@ const StockChartModal = ({
|
||||
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
|
||||
</Box>
|
||||
|
||||
{getRelationDesc() && (
|
||||
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
|
||||
<Text fontSize="sm" color="gray.600">{getRelationDesc()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{/* 关联描述 */}
|
||||
<RelationDescription relationDesc={stock?.relation_desc} />
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={4} pb={4}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</Box>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && chartData && (
|
||||
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
|
||||
<Text fontWeight="bold">调试信息:</Text>
|
||||
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
|
||||
<Text>交易日期: {chartData.trade_date}</Text>
|
||||
<Text>图表类型: {chartType}</Text>
|
||||
<Text>原始事件时间: {eventTime}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
207
src/components/StockChart/StockChartModal.tsx
Normal file
207
src/components/StockChart/StockChartModal.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件(KLineChart 实现)
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Box,
|
||||
Flex,
|
||||
CircularProgress,
|
||||
} from '@chakra-ui/react';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
|
||||
import { Alert, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 图表类型
|
||||
*/
|
||||
type ChartType = 'timeline' | 'daily';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
*/
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StockChartModal 组件 Props
|
||||
*/
|
||||
export interface StockChartModalProps {
|
||||
/** 模态框是否打开 */
|
||||
isOpen: boolean;
|
||||
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
|
||||
/** 股票信息 */
|
||||
stock: StockInfo | null;
|
||||
|
||||
/** 事件时间 */
|
||||
eventTime?: string | null;
|
||||
|
||||
/** 是否使用 Chakra UI(保留字段,当前未使用) */
|
||||
isChakraUI?: boolean;
|
||||
|
||||
/** 模态框大小 */
|
||||
size?: string;
|
||||
|
||||
/** 初始图表类型 */
|
||||
initialChartType?: ChartType;
|
||||
}
|
||||
|
||||
|
||||
const StockChartModal: React.FC<StockChartModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
stock,
|
||||
eventTime,
|
||||
isChakraUI = true,
|
||||
size = '6xl',
|
||||
initialChartType = 'timeline',
|
||||
}) => {
|
||||
// 状态管理
|
||||
const [chartType, setChartType] = useState<ChartType>(initialChartType);
|
||||
|
||||
// KLineChart Hooks
|
||||
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
|
||||
containerId: `kline-chart-${stock?.stock_code || 'default'}`,
|
||||
height: 500,
|
||||
autoResize: true,
|
||||
chartType, // ✅ 传递 chartType,让 Hook 根据类型应用不同样式
|
||||
});
|
||||
|
||||
const { data, loading, error: dataError } = useKLineData({
|
||||
chart,
|
||||
stockCode: stock?.stock_code || '',
|
||||
chartType,
|
||||
eventTime: eventTime || undefined,
|
||||
autoLoad: true, // 改为 true,让 Hook 内部根据 stockCode 和 chart 判断是否加载
|
||||
});
|
||||
|
||||
const { marker } = useEventMarker({
|
||||
chart,
|
||||
data,
|
||||
eventTime: eventTime || undefined,
|
||||
eventTitle: '事件发生',
|
||||
autoCreate: true,
|
||||
});
|
||||
|
||||
// 守卫子句
|
||||
if (!stock) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
|
||||
<ModalHeader pb={4} position="relative">
|
||||
<VStack align="flex-start" spacing={2}>
|
||||
<HStack>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
{stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情
|
||||
</Text>
|
||||
{data.length > 0 && <Badge colorScheme="blue">数据点: {data.length}</Badge>}
|
||||
</HStack>
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
variant={chartType === 'timeline' ? 'solid' : 'outline'}
|
||||
onClick={() => setChartType('timeline')}
|
||||
colorScheme="blue"
|
||||
>
|
||||
分时线
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartType === 'daily' ? 'solid' : 'outline'}
|
||||
onClick={() => setChartType('daily')}
|
||||
colorScheme="blue"
|
||||
>
|
||||
日K线
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</VStack>
|
||||
|
||||
{/* 重件发生标签 - 仅在有 eventTime 时显示 */}
|
||||
{eventTime && (
|
||||
<Badge
|
||||
colorScheme="yellow"
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
position="absolute"
|
||||
top="4"
|
||||
right="12"
|
||||
boxShadow="sm"
|
||||
>
|
||||
重件发生(影响日)
|
||||
</Badge>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
|
||||
{/* 错误提示 */}
|
||||
{(chartError || dataError) && (
|
||||
<Alert status="error" mx={4} mt={4}>
|
||||
<AlertIcon />
|
||||
图表加载失败:{chartError?.message || dataError?.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 图表区域 - 响应式高度 */}
|
||||
<Box
|
||||
h={{
|
||||
base: "calc(60vh - 100px)", // 移动端:60% 视口高度 - 100px
|
||||
md: "calc(70vh - 150px)", // 平板:70% 视口高度 - 150px
|
||||
lg: "calc(80vh - 200px)" // 桌面:80% 视口高度 - 200px
|
||||
}}
|
||||
minH="350px" // 最小高度:确保可用性
|
||||
maxH="650px" // 最大高度:避免过大
|
||||
w="100%"
|
||||
position="relative"
|
||||
>
|
||||
{loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="rgba(255, 255, 255, 0.7)"
|
||||
zIndex="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<CircularProgress isIndeterminate color="blue.300" />
|
||||
<Text>加载图表数据...</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
<div
|
||||
ref={chartRef}
|
||||
id={`kline-chart-${stock.stock_code}`}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={4} pb={4}>
|
||||
<RiskDisclaimer text="" variant="default" sx={{}} />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChartModal;
|
||||
514
src/components/StockChart/TimelineChartModal.tsx
Normal file
514
src/components/StockChart/TimelineChartModal.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
*/
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TimelineChartModal 组件 Props
|
||||
*/
|
||||
export interface TimelineChartModalProps {
|
||||
/** 模态框是否打开 */
|
||||
isOpen: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 股票信息 */
|
||||
stock: StockInfo | null;
|
||||
/** 事件时间 */
|
||||
eventTime?: string | null;
|
||||
/** 模态框大小 */
|
||||
size?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分时图数据点
|
||||
*/
|
||||
interface TimelineDataPoint {
|
||||
time: string;
|
||||
price: number;
|
||||
avg_price: number;
|
||||
volume: number;
|
||||
change_percent: number;
|
||||
}
|
||||
|
||||
const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
stock,
|
||||
eventTime,
|
||||
size = '5xl',
|
||||
}) => {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
||||
|
||||
// 加载分时图数据
|
||||
const loadData = async () => {
|
||||
if (!stock?.stock_code) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
|
||||
stockCode: stock.stock_code,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
const response = await stockService.getKlineData(
|
||||
stock.stock_code,
|
||||
'timeline',
|
||||
eventTime || undefined
|
||||
);
|
||||
|
||||
console.log('[TimelineChartModal] API响应:', response);
|
||||
|
||||
if (!response || !response.data || response.data.length === 0) {
|
||||
throw new Error('暂无分时数据');
|
||||
}
|
||||
|
||||
console.log('[TimelineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
|
||||
dataCount: response.data.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||
logger.error('TimelineChartModal', 'loadData', err as Error);
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// 延迟初始化,确保 Modal 动画完成后 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (!chartRef.current) {
|
||||
console.error('[TimelineChartModal] DOM元素未找到,无法初始化图表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TimelineChartModal] 初始化图表...');
|
||||
|
||||
// 创建图表实例(不使用主题,直接在option中配置背景色)
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
|
||||
console.log('[TimelineChartModal] 图表实例创建成功');
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
chartInstance.current?.resize();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, 100); // 延迟100ms等待Modal完全打开
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 更新图表数据
|
||||
useEffect(() => {
|
||||
if (data.length === 0) {
|
||||
console.log('[TimelineChartModal] 无数据,跳过图表更新');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果图表还没初始化,等待200ms后重试(给图表初始化留出时间)
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.current) {
|
||||
console.warn('[TimelineChartModal] 图表实例不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[TimelineChartModal] 开始更新图表,数据点:', data.length);
|
||||
|
||||
const times = data.map((d) => d.time);
|
||||
const prices = data.map((d) => d.price);
|
||||
const avgPrices = data.map((d) => d.avg_price);
|
||||
const volumes = data.map((d) => d.volume);
|
||||
|
||||
// 计算涨跌颜色
|
||||
const basePrice = data[0]?.price || 0;
|
||||
const volumeColors = data.map((d) =>
|
||||
d.price >= basePrice ? '#ef5350' : '#26a69a'
|
||||
);
|
||||
|
||||
// 提取事件发生时间(HH:MM格式)
|
||||
let eventTimeStr: string | null = null;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventDate = new Date(eventTime);
|
||||
const hours = eventDate.getHours().toString().padStart(2, '0');
|
||||
const minutes = eventDate.getMinutes().toString().padStart(2, '0');
|
||||
eventTimeStr = `${hours}:${minutes}`;
|
||||
console.log('[TimelineChartModal] 事件发生时间:', eventTimeStr);
|
||||
} catch (e) {
|
||||
console.error('[TimelineChartModal] 解析事件时间失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 图表配置
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||
borderColor: '#404040',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#e0e0e0',
|
||||
},
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: {
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const dataIndex = params[0]?.dataIndex;
|
||||
if (dataIndex === undefined) return '';
|
||||
|
||||
const item = data[dataIndex];
|
||||
const changeColor = item.change_percent >= 0 ? '#ef5350' : '#26a69a';
|
||||
const changeSign = item.change_percent >= 0 ? '+' : '';
|
||||
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px;">${item.time}</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>价格:</span>
|
||||
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>均价:</span>
|
||||
<span style="color: #ffa726; margin-left: 20px;">${item.avg_price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span>涨跌幅:</span>
|
||||
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${item.change_percent.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span>成交量:</span>
|
||||
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '15%',
|
||||
height: '55%',
|
||||
},
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '75%',
|
||||
height: '15%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
gridIndex: 0,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(times.length / 6),
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#2a2a2a',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
gridIndex: 1,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(times.length / 6),
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 0,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#2a2a2a',
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
formatter: (value: number) => value.toFixed(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#404040',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
formatter: (value: number) => {
|
||||
if (value >= 10000) {
|
||||
return (value / 10000).toFixed(1) + '万';
|
||||
}
|
||||
return value.toFixed(0);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '价格',
|
||||
type: 'line',
|
||||
data: prices,
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#2196f3',
|
||||
width: 2,
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(33, 150, 243, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(33, 150, 243, 0.05)' },
|
||||
]),
|
||||
},
|
||||
markLine: eventTimeStr ? {
|
||||
silent: false,
|
||||
symbol: 'none',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideEndTop',
|
||||
formatter: '事件发生',
|
||||
color: '#ffd700',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4,
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#ffd700',
|
||||
width: 2,
|
||||
type: 'solid',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
xAxis: eventTimeStr,
|
||||
label: {
|
||||
formatter: '⚡ 事件发生',
|
||||
},
|
||||
},
|
||||
],
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: '均价',
|
||||
type: 'line',
|
||||
data: avgPrices,
|
||||
xAxisIndex: 0,
|
||||
yAxisIndex: 0,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#ffa726',
|
||||
width: 1.5,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
data: volumes,
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
return volumeColors[params.dataIndex];
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: [0, 1],
|
||||
start: 0,
|
||||
end: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option);
|
||||
console.log('[TimelineChartModal] 图表option已设置');
|
||||
|
||||
// 强制resize以确保图表正确显示
|
||||
setTimeout(() => {
|
||||
chartInstance.current?.resize();
|
||||
console.log('[TimelineChartModal] 图表已resize');
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 立即尝试更新,如果失败则重试
|
||||
if (!updateChart()) {
|
||||
console.log('[TimelineChartModal] 第一次更新失败,200ms后重试...');
|
||||
const retryTimer = setTimeout(() => {
|
||||
updateChart();
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(retryTimer);
|
||||
}
|
||||
}, [data, stock]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, stock?.stock_code, eventTime]);
|
||||
|
||||
if (!stock) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent
|
||||
maxW="90vw"
|
||||
maxH="85vh"
|
||||
bg="#1a1a1a"
|
||||
borderColor="#404040"
|
||||
borderWidth="1px"
|
||||
>
|
||||
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<HStack>
|
||||
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
|
||||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="#999">
|
||||
分时走势图
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
|
||||
<ModalBody p={4}>
|
||||
{error && (
|
||||
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
|
||||
<AlertIcon color="#ef5350" />
|
||||
<Text color="#e0e0e0">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box position="relative" h="600px" w="100%">
|
||||
{loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="rgba(26, 26, 26, 0.7)"
|
||||
zIndex="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<CircularProgress isIndeterminate color="blue.400" />
|
||||
<Text color="#e0e0e0">加载分时数据...</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineChartModal;
|
||||
205
src/components/StockChart/config/chartConfig.ts
Normal file
205
src/components/StockChart/config/chartConfig.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* KLineChart 图表常量配置
|
||||
*
|
||||
* 包含图表默认配置、技术指标列表、事件标记配置等
|
||||
*/
|
||||
|
||||
import type { ChartConfig, ChartType } from '../types';
|
||||
|
||||
/**
|
||||
* 图表默认高度(px)
|
||||
*/
|
||||
export const CHART_HEIGHTS = {
|
||||
/** 主图高度 */
|
||||
main: 400,
|
||||
/** 副图高度(技术指标) */
|
||||
sub: 150,
|
||||
/** 移动端主图高度 */
|
||||
mainMobile: 300,
|
||||
/** 移动端副图高度 */
|
||||
subMobile: 100,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 技术指标配置
|
||||
*/
|
||||
export const INDICATORS = {
|
||||
/** 主图指标(叠加在 K 线图上) */
|
||||
main: [
|
||||
{
|
||||
name: 'MA',
|
||||
label: '均线',
|
||||
params: [5, 10, 20, 30],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
|
||||
},
|
||||
{
|
||||
name: 'EMA',
|
||||
label: '指数移动平均',
|
||||
params: [5, 10, 20, 30],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
|
||||
},
|
||||
{
|
||||
name: 'BOLL',
|
||||
label: '布林带',
|
||||
params: [20, 2],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
],
|
||||
|
||||
/** 副图指标(单独窗口显示) */
|
||||
sub: [
|
||||
{
|
||||
name: 'VOL',
|
||||
label: '成交量',
|
||||
params: [5, 10, 20],
|
||||
colors: ['#ef5350', '#26a69a'],
|
||||
},
|
||||
{
|
||||
name: 'MACD',
|
||||
label: 'MACD',
|
||||
params: [12, 26, 9],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
{
|
||||
name: 'KDJ',
|
||||
label: 'KDJ',
|
||||
params: [9, 3, 3],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
{
|
||||
name: 'RSI',
|
||||
label: 'RSI',
|
||||
params: [6, 12, 24],
|
||||
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 默认主图指标(初始显示)
|
||||
*/
|
||||
export const DEFAULT_MAIN_INDICATOR = 'MA';
|
||||
|
||||
/**
|
||||
* 默认副图指标(初始显示)
|
||||
*/
|
||||
export const DEFAULT_SUB_INDICATORS = ['VOL', 'MACD'];
|
||||
|
||||
/**
|
||||
* 图表类型配置
|
||||
*/
|
||||
export const CHART_TYPE_CONFIG: Record<ChartType, { label: string; dateFormat: string }> = {
|
||||
timeline: {
|
||||
label: '分时图',
|
||||
dateFormat: 'HH:mm', // 时间格式:09:30
|
||||
},
|
||||
daily: {
|
||||
label: '日K线',
|
||||
dateFormat: 'YYYY-MM-DD', // 日期格式:2024-01-01
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 事件标记配置
|
||||
*/
|
||||
export const EVENT_MARKER_CONFIG = {
|
||||
/** 默认颜色 */
|
||||
defaultColor: '#ff9800',
|
||||
/** 默认位置 */
|
||||
defaultPosition: 'top' as const,
|
||||
/** 默认图标 */
|
||||
defaultIcon: '📌',
|
||||
/** 标记大小 */
|
||||
size: {
|
||||
point: 8, // 标记点半径
|
||||
icon: 20, // 图标大小
|
||||
},
|
||||
/** 文本配置 */
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Helvetica, Arial, sans-serif',
|
||||
color: '#ffffff',
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 数据加载配置
|
||||
*/
|
||||
export const DATA_LOADER_CONFIG = {
|
||||
/** 最大数据点数(避免性能问题) */
|
||||
maxDataPoints: 1000,
|
||||
/** 初始加载数据点数 */
|
||||
initialLoadCount: 100,
|
||||
/** 加载更多时的数据点数 */
|
||||
loadMoreCount: 50,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 缩放配置
|
||||
*/
|
||||
export const ZOOM_CONFIG = {
|
||||
/** 最小缩放比例(显示更多 K 线) */
|
||||
minZoom: 0.5,
|
||||
/** 最大缩放比例(显示更少 K 线) */
|
||||
maxZoom: 2.0,
|
||||
/** 默认缩放比例 */
|
||||
defaultZoom: 1.0,
|
||||
/** 缩放步长 */
|
||||
zoomStep: 0.1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 默认图表配置
|
||||
*/
|
||||
export const DEFAULT_CHART_CONFIG: ChartConfig = {
|
||||
type: 'daily',
|
||||
showIndicators: true,
|
||||
defaultIndicators: DEFAULT_SUB_INDICATORS,
|
||||
height: CHART_HEIGHTS.main,
|
||||
showGrid: true,
|
||||
showCrosshair: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 图表初始化选项(传递给 KLineChart.init)
|
||||
*/
|
||||
export const CHART_INIT_OPTIONS = {
|
||||
/** 时区(中国标准时间) */
|
||||
timezone: 'Asia/Shanghai',
|
||||
/** 语言 */
|
||||
locale: 'zh-CN',
|
||||
/** 自定义配置 */
|
||||
customApi: {
|
||||
formatDate: (timestamp: number, format: string) => {
|
||||
// 可在此处自定义日期格式化逻辑
|
||||
return new Date(timestamp).toLocaleString('zh-CN');
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 分时图特殊配置
|
||||
*/
|
||||
export const TIMELINE_CONFIG = {
|
||||
/** 交易时段(A 股) */
|
||||
tradingSessions: [
|
||||
{ start: '09:30', end: '11:30' }, // 上午
|
||||
{ start: '13:00', end: '15:00' }, // 下午
|
||||
],
|
||||
/** 是否显示均价线 */
|
||||
showAverageLine: true,
|
||||
/** 均价线颜色 */
|
||||
averageLineColor: '#FFB74D',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 日K线特殊配置
|
||||
*/
|
||||
export const DAILY_KLINE_CONFIG = {
|
||||
/** 最大显示天数 */
|
||||
maxDays: 250, // 约一年交易日
|
||||
/** 默认显示天数 */
|
||||
defaultDays: 60,
|
||||
} as const;
|
||||
32
src/components/StockChart/config/index.ts
Normal file
32
src/components/StockChart/config/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* StockChart 配置统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { lightTheme, DEFAULT_CHART_CONFIG } from '@components/StockChart/config';
|
||||
*/
|
||||
|
||||
// 主题配置(仅浅色主题)
|
||||
export {
|
||||
CHART_COLORS,
|
||||
lightTheme,
|
||||
// darkTheme, // ❌ 已删除深色主题
|
||||
timelineTheme,
|
||||
getTheme,
|
||||
getTimelineTheme,
|
||||
} from './klineTheme';
|
||||
|
||||
// 图表配置
|
||||
export {
|
||||
CHART_HEIGHTS,
|
||||
INDICATORS,
|
||||
DEFAULT_MAIN_INDICATOR,
|
||||
DEFAULT_SUB_INDICATORS,
|
||||
CHART_TYPE_CONFIG,
|
||||
EVENT_MARKER_CONFIG,
|
||||
DATA_LOADER_CONFIG,
|
||||
ZOOM_CONFIG,
|
||||
DEFAULT_CHART_CONFIG,
|
||||
CHART_INIT_OPTIONS,
|
||||
TIMELINE_CONFIG,
|
||||
DAILY_KLINE_CONFIG,
|
||||
} from './chartConfig';
|
||||
370
src/components/StockChart/config/klineTheme.ts
Normal file
370
src/components/StockChart/config/klineTheme.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* KLineChart 主题配置(仅浅色主题)
|
||||
*
|
||||
* 适配 klinecharts@10.0.0-beta1
|
||||
* 参考: https://github.com/klinecharts/KLineChart/blob/main/docs/en-US/guide/styles.md
|
||||
*
|
||||
* ⚠️ 重要说明:
|
||||
* - 本项目已移除深色模式支持(2025-01)
|
||||
* - 应用通过 colorModeManager 强制使用浅色主题
|
||||
* - 已删除 darkTheme 和 timelineThemeDark 配置
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ⚠️ 使用 any 类型绕过 KLineChart 类型定义的限制(beta 版本类型不完整)
|
||||
// import type { DeepPartial, Styles } from 'klinecharts'; // ⚠️ 未使用(保留以便将来扩展)
|
||||
|
||||
/**
|
||||
* 图表主题颜色配置(浅色主题)
|
||||
* ⚠️ 已移除深色模式相关颜色常量
|
||||
*/
|
||||
export const CHART_COLORS = {
|
||||
// 涨跌颜色(中国市场习惯:红涨绿跌)
|
||||
up: '#ef5350', // 上涨红色
|
||||
down: '#26a69a', // 下跌绿色
|
||||
neutral: '#888888', // 平盘灰色
|
||||
|
||||
// 主题色(继承自 Argon Dashboard)
|
||||
primary: '#1b3bbb', // Navy 500
|
||||
secondary: '#728fea', // Navy 300
|
||||
background: '#ffffff',
|
||||
|
||||
// 文本颜色
|
||||
text: '#333333',
|
||||
textSecondary: '#888888',
|
||||
|
||||
// 网格颜色
|
||||
grid: '#e0e0e0',
|
||||
|
||||
// 边框颜色
|
||||
border: '#e0e0e0',
|
||||
|
||||
// 事件标记颜色
|
||||
eventMarker: '#ff9800',
|
||||
eventMarkerText: '#ffffff',
|
||||
};
|
||||
|
||||
/**
|
||||
* 浅色主题配置(默认)
|
||||
*/
|
||||
export const lightTheme: any = {
|
||||
candle: {
|
||||
type: 'candle_solid', // 实心蜡烛图
|
||||
bar: {
|
||||
upColor: CHART_COLORS.up,
|
||||
downColor: CHART_COLORS.down,
|
||||
noChangeColor: CHART_COLORS.neutral,
|
||||
},
|
||||
priceMark: {
|
||||
show: true,
|
||||
high: {
|
||||
color: CHART_COLORS.up,
|
||||
},
|
||||
low: {
|
||||
color: CHART_COLORS.down,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
showType: 'standard',
|
||||
// labels: ['时间: ', '开: ', '收: ', '高: ', '低: ', '成交量: '], // ❌ KLineChart 类型不支持自定义 labels
|
||||
text: {
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
color: CHART_COLORS.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
indicator: {
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
showType: 'standard',
|
||||
text: {
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
color: CHART_COLORS.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickLine: {
|
||||
show: true,
|
||||
length: 3,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickText: {
|
||||
show: true,
|
||||
color: CHART_COLORS.textSecondary,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
axisLine: {
|
||||
show: true,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickLine: {
|
||||
show: true,
|
||||
length: 3,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
tickText: {
|
||||
show: true,
|
||||
color: CHART_COLORS.textSecondary,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
size: 12,
|
||||
},
|
||||
type: 'normal', // 'normal' | 'percentage' | 'log'
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
horizontal: {
|
||||
show: true,
|
||||
size: 1,
|
||||
color: CHART_COLORS.grid,
|
||||
style: 'dashed',
|
||||
},
|
||||
vertical: {
|
||||
show: false, // 垂直网格线通常关闭,避免过于密集
|
||||
},
|
||||
},
|
||||
separator: {
|
||||
size: 1,
|
||||
color: CHART_COLORS.border,
|
||||
},
|
||||
crosshair: {
|
||||
show: true,
|
||||
horizontal: {
|
||||
show: true,
|
||||
line: {
|
||||
show: true,
|
||||
style: 'dashed',
|
||||
dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue
|
||||
size: 1,
|
||||
color: CHART_COLORS.primary,
|
||||
},
|
||||
text: {
|
||||
show: true,
|
||||
color: '#ffffff', // 白色文字(十字线标签背景是深蓝色)
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
backgroundColor: CHART_COLORS.primary,
|
||||
},
|
||||
},
|
||||
vertical: {
|
||||
show: true,
|
||||
line: {
|
||||
show: true,
|
||||
style: 'dashed',
|
||||
dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue
|
||||
size: 1,
|
||||
color: CHART_COLORS.primary,
|
||||
},
|
||||
text: {
|
||||
show: true,
|
||||
color: '#ffffff', // 白色文字(十字线标签背景是深蓝色)
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
backgroundColor: CHART_COLORS.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
overlay: {
|
||||
// 事件标记覆盖层样式
|
||||
point: {
|
||||
color: CHART_COLORS.eventMarker,
|
||||
borderColor: CHART_COLORS.eventMarker,
|
||||
borderSize: 1,
|
||||
radius: 5,
|
||||
activeColor: CHART_COLORS.eventMarker,
|
||||
activeBorderColor: CHART_COLORS.eventMarker,
|
||||
activeBorderSize: 2,
|
||||
activeRadius: 6,
|
||||
},
|
||||
line: {
|
||||
style: 'solid',
|
||||
smooth: false,
|
||||
color: CHART_COLORS.eventMarker,
|
||||
size: 1,
|
||||
dashedValue: [2, 2],
|
||||
},
|
||||
text: {
|
||||
style: 'fill',
|
||||
color: CHART_COLORS.eventMarkerText,
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
offset: [0, 0],
|
||||
},
|
||||
rect: {
|
||||
style: 'fill',
|
||||
color: CHART_COLORS.eventMarker,
|
||||
borderColor: CHART_COLORS.eventMarker,
|
||||
borderSize: 1,
|
||||
borderRadius: 4,
|
||||
borderStyle: 'solid',
|
||||
borderDashedValue: [2, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ❌ 已删除 darkTheme 配置(不再支持深色模式)
|
||||
|
||||
/**
|
||||
* 分时图专用主题配置
|
||||
* 特点:面积图样式、均价线、百分比Y轴
|
||||
*/
|
||||
export const timelineTheme: any = {
|
||||
...lightTheme,
|
||||
candle: {
|
||||
type: 'area', // ✅ 面积图模式(分时线)
|
||||
area: {
|
||||
lineSize: 2,
|
||||
lineColor: CHART_COLORS.up, // 默认红色,实际会根据涨跌动态调整
|
||||
value: 'close',
|
||||
backgroundColor: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(239, 83, 80, 0.2)', // 红色半透明渐变(顶部)
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(239, 83, 80, 0.01)', // 红色几乎透明(底部)
|
||||
},
|
||||
],
|
||||
},
|
||||
priceMark: {
|
||||
show: true,
|
||||
high: {
|
||||
show: false, // 分时图不显示最高最低价标记
|
||||
},
|
||||
low: {
|
||||
show: false,
|
||||
},
|
||||
last: {
|
||||
show: true,
|
||||
upColor: CHART_COLORS.up,
|
||||
downColor: CHART_COLORS.down,
|
||||
noChangeColor: CHART_COLORS.neutral,
|
||||
line: {
|
||||
show: true,
|
||||
style: 'dashed',
|
||||
dashedValue: [4, 2], // ✅ 修复: 使用 dashedValue 而非 dashValue
|
||||
size: 1,
|
||||
},
|
||||
text: {
|
||||
show: true,
|
||||
size: 12,
|
||||
paddingLeft: 4,
|
||||
paddingTop: 2,
|
||||
paddingRight: 4,
|
||||
paddingBottom: 2,
|
||||
borderRadius: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
showType: 'standard',
|
||||
// ❌ KLineChart 类型不支持自定义 labels 和 formatter(需要在运行时通过 API 设置)
|
||||
// labels: ['时间: ', '现价: ', '涨跌: ', '均价: ', '昨收: ', '成交量: '],
|
||||
// formatter: (data: any, indicator: any) => { ... },
|
||||
text: {
|
||||
size: 12,
|
||||
family: 'Helvetica, Arial, sans-serif',
|
||||
weight: 'normal',
|
||||
color: CHART_COLORS.text,
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
...lightTheme.yAxis,
|
||||
type: 'percentage', // ✅ 百分比模式
|
||||
position: 'left', // Y轴在左侧
|
||||
inside: false,
|
||||
reverse: false,
|
||||
tickText: {
|
||||
...lightTheme.yAxis?.tickText,
|
||||
// ❌ KLineChart 类型不支持自定义 formatter(需要在运行时通过 API 设置)
|
||||
// formatter: (value: any) => {
|
||||
// const percent = (value * 100).toFixed(2);
|
||||
// if (Math.abs(value) < 0.0001) return '0.00%';
|
||||
// return value > 0 ? `+${percent}%` : `${percent}%`;
|
||||
// },
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
horizontal: {
|
||||
show: true,
|
||||
size: 1,
|
||||
color: CHART_COLORS.grid,
|
||||
style: 'solid', // 分时图使用实线网格
|
||||
},
|
||||
vertical: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ❌ 已删除 timelineThemeDark 配置(不再支持深色模式)
|
||||
|
||||
/**
|
||||
* 获取主题配置(固定返回浅色主题)
|
||||
* ⚠️ 已移除深色模式支持
|
||||
* @deprecated colorMode 参数已废弃,始终返回浅色主题
|
||||
*/
|
||||
export const getTheme = (_colorMode?: 'light' | 'dark'): any => {
|
||||
// ✅ 始终返回浅色主题
|
||||
return lightTheme;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取分时图主题配置(固定返回浅色主题)
|
||||
* ⚠️ 已移除深色模式支持
|
||||
* @deprecated colorMode 参数已废弃,始终返回浅色主题
|
||||
*/
|
||||
export const getTimelineTheme = (_colorMode?: 'light' | 'dark'): any => {
|
||||
// ✅ 始终使用浅色主题
|
||||
const baseTheme = timelineTheme;
|
||||
|
||||
// ✅ 添加成交量指标样式(蓝色渐变柱状图)+ 成交量单位格式化
|
||||
return {
|
||||
...baseTheme,
|
||||
indicator: {
|
||||
...baseTheme.indicator,
|
||||
bars: [
|
||||
{
|
||||
upColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(涨)
|
||||
downColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(跌)- 分时图成交量统一蓝色
|
||||
noChangeColor: 'rgba(59, 130, 246, 0.6)',
|
||||
}
|
||||
],
|
||||
// ❌ KLineChart 类型不支持自定义 formatter(需要在运行时通过 API 设置)
|
||||
tooltip: {
|
||||
...baseTheme.indicator?.tooltip,
|
||||
// formatter: (params: any) => {
|
||||
// if (params.name === 'VOL' && params.calcParamsText) {
|
||||
// const volume = params.calcParamsText.match(/\d+/)?.[0];
|
||||
// if (volume) {
|
||||
// const hands = Math.floor(Number(volume) / 100);
|
||||
// return `成交量: ${hands.toLocaleString()}手`;
|
||||
// }
|
||||
// }
|
||||
// return params.calcParamsText || '';
|
||||
// },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
15
src/components/StockChart/hooks/index.ts
Normal file
15
src/components/StockChart/hooks/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* StockChart 自定义 Hooks 统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { useKLineChart, useKLineData, useEventMarker } from '@components/StockChart/hooks';
|
||||
*/
|
||||
|
||||
export { useKLineChart } from './useKLineChart';
|
||||
export type { UseKLineChartOptions, UseKLineChartReturn } from './useKLineChart';
|
||||
|
||||
export { useKLineData } from './useKLineData';
|
||||
export type { UseKLineDataOptions, UseKLineDataReturn } from './useKLineData';
|
||||
|
||||
export { useEventMarker } from './useEventMarker';
|
||||
export type { UseEventMarkerOptions, UseEventMarkerReturn } from './useEventMarker';
|
||||
238
src/components/StockChart/hooks/useEventMarker.ts
Normal file
238
src/components/StockChart/hooks/useEventMarker.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* useEventMarker Hook
|
||||
*
|
||||
* 管理事件标记的创建、更新和删除
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Chart } from 'klinecharts';
|
||||
import type { EventMarker, KLineDataPoint } from '../types';
|
||||
import {
|
||||
createEventMarkerFromTime,
|
||||
createEventMarkerOverlay,
|
||||
createEventHighlightOverlay,
|
||||
removeAllEventMarkers,
|
||||
} from '../utils/eventMarkerUtils';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
export interface UseEventMarkerOptions {
|
||||
/** KLineChart 实例 */
|
||||
chart: Chart | null;
|
||||
/** K 线数据(用于定位标记) */
|
||||
data: KLineDataPoint[];
|
||||
/** 事件时间(ISO 字符串) */
|
||||
eventTime?: string;
|
||||
/** 事件标题(用于标记标签) */
|
||||
eventTitle?: string;
|
||||
/** 是否自动创建标记 */
|
||||
autoCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface UseEventMarkerReturn {
|
||||
/** 当前标记 */
|
||||
marker: EventMarker | null;
|
||||
/** 标记 ID(已添加到图表) */
|
||||
markerId: string | null;
|
||||
/** 创建标记 */
|
||||
createMarker: (time: string, label: string, color?: string) => void;
|
||||
/** 移除标记 */
|
||||
removeMarker: () => void;
|
||||
/** 移除所有标记 */
|
||||
removeAllMarkers: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件标记管理 Hook
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns UseEventMarkerReturn
|
||||
*
|
||||
* @example
|
||||
* const { marker, createMarker, removeMarker } = useEventMarker({
|
||||
* chart,
|
||||
* data,
|
||||
* eventTime: '2024-01-01 10:00:00',
|
||||
* eventTitle: '重大公告',
|
||||
* autoCreate: true,
|
||||
* });
|
||||
*/
|
||||
export const useEventMarker = (
|
||||
options: UseEventMarkerOptions
|
||||
): UseEventMarkerReturn => {
|
||||
const {
|
||||
chart,
|
||||
data,
|
||||
eventTime,
|
||||
eventTitle = '事件发生',
|
||||
autoCreate = true,
|
||||
} = options;
|
||||
|
||||
const [marker, setMarker] = useState<EventMarker | null>(null);
|
||||
const [markerId, setMarkerId] = useState<string | null>(null);
|
||||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 创建事件标记
|
||||
*/
|
||||
const createMarker = useCallback(
|
||||
(time: string, label: string, color?: string) => {
|
||||
if (!chart || !data || data.length === 0) {
|
||||
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
|
||||
hasChart: !!chart,
|
||||
dataLength: data?.length || 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 创建事件标记配置
|
||||
const eventMarker = createEventMarkerFromTime(time, label, color);
|
||||
setMarker(eventMarker);
|
||||
|
||||
// 2. 创建 Overlay
|
||||
const overlay = createEventMarkerOverlay(eventMarker, data);
|
||||
|
||||
if (!overlay) {
|
||||
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
|
||||
eventMarker,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 添加到图表
|
||||
const id = chart.createOverlay(overlay);
|
||||
|
||||
if (!id || (Array.isArray(id) && id.length === 0)) {
|
||||
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
|
||||
overlay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualId = Array.isArray(id) ? id[0] : id;
|
||||
setMarkerId(actualId as string);
|
||||
|
||||
// 4. 创建黄色高亮背景(事件影响日)
|
||||
const highlightOverlay = createEventHighlightOverlay(time, data);
|
||||
if (highlightOverlay) {
|
||||
const highlightResult = chart.createOverlay(highlightOverlay);
|
||||
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
|
||||
setHighlightId(actualHighlightId as string);
|
||||
|
||||
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
|
||||
highlightId: actualHighlightId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
|
||||
markerId: actualId,
|
||||
label,
|
||||
time,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'createMarker', err as Error, {
|
||||
time,
|
||||
label,
|
||||
});
|
||||
}
|
||||
},
|
||||
[chart, data]
|
||||
);
|
||||
|
||||
/**
|
||||
* 移除事件标记
|
||||
*/
|
||||
const removeMarker = useCallback(() => {
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (markerId) {
|
||||
chart.removeOverlay(markerId);
|
||||
}
|
||||
if (highlightId) {
|
||||
chart.removeOverlay(highlightId);
|
||||
}
|
||||
|
||||
setMarker(null);
|
||||
setMarkerId(null);
|
||||
setHighlightId(null);
|
||||
|
||||
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
|
||||
markerId,
|
||||
highlightId,
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'removeMarker', err as Error, {
|
||||
markerId,
|
||||
highlightId,
|
||||
});
|
||||
}
|
||||
}, [chart, markerId, highlightId]);
|
||||
|
||||
/**
|
||||
* 移除所有标记
|
||||
*/
|
||||
const removeAllMarkers = useCallback(() => {
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllEventMarkers(chart);
|
||||
setMarker(null);
|
||||
setMarkerId(null);
|
||||
setHighlightId(null);
|
||||
|
||||
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
|
||||
chartId: chart.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useEventMarker', 'removeAllMarkers', err as Error);
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
// 自动创建标记(当 eventTime 和数据都准备好时)
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoCreate &&
|
||||
eventTime &&
|
||||
chart &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
!markerId // 避免重复创建
|
||||
) {
|
||||
createMarker(eventTime, eventTitle);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventTime, chart, data, autoCreate]);
|
||||
|
||||
// 清理:组件卸载时移除所有标记
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (chart) {
|
||||
try {
|
||||
if (markerId) {
|
||||
chart.removeOverlay(markerId);
|
||||
}
|
||||
if (highlightId) {
|
||||
chart.removeOverlay(highlightId);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略清理时的错误
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [chart, markerId, highlightId]);
|
||||
|
||||
return {
|
||||
marker,
|
||||
markerId,
|
||||
createMarker,
|
||||
removeMarker,
|
||||
removeAllMarkers,
|
||||
};
|
||||
};
|
||||
247
src/components/StockChart/hooks/useKLineChart.ts
Normal file
247
src/components/StockChart/hooks/useKLineChart.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* useKLineChart Hook
|
||||
*
|
||||
* 管理 KLineChart 实例的初始化、配置和销毁
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { init, dispose, registerIndicator } from 'klinecharts';
|
||||
import type { Chart } from 'klinecharts';
|
||||
// import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持
|
||||
import { getTheme, getTimelineTheme } from '../config/klineTheme';
|
||||
import { CHART_INIT_OPTIONS } from '../config';
|
||||
import { logger } from '@utils/logger';
|
||||
import { avgPriceIndicator } from '../indicators/avgPriceIndicator';
|
||||
|
||||
export interface UseKLineChartOptions {
|
||||
/** 图表容器 ID */
|
||||
containerId: string;
|
||||
/** 图表高度(px) */
|
||||
height?: number;
|
||||
/** 是否自动调整大小 */
|
||||
autoResize?: boolean;
|
||||
/** 图表类型(timeline/daily) */
|
||||
chartType?: 'timeline' | 'daily';
|
||||
}
|
||||
|
||||
export interface UseKLineChartReturn {
|
||||
/** KLineChart 实例 */
|
||||
chart: Chart | null;
|
||||
/** 容器 Ref */
|
||||
chartRef: React.RefObject<HTMLDivElement>;
|
||||
/** 是否已初始化 */
|
||||
isInitialized: boolean;
|
||||
/** 初始化错误 */
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* KLineChart 初始化和生命周期管理 Hook
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns UseKLineChartReturn
|
||||
*
|
||||
* @example
|
||||
* const { chart, chartRef, isInitialized } = useKLineChart({
|
||||
* containerId: 'kline-chart',
|
||||
* height: 400,
|
||||
* autoResize: true,
|
||||
* });
|
||||
*/
|
||||
export const useKLineChart = (
|
||||
options: UseKLineChartOptions
|
||||
): UseKLineChartReturn => {
|
||||
const { containerId, height = 400, autoResize = true, chartType = 'daily' } = options;
|
||||
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstanceRef = useRef<Chart | null>(null);
|
||||
const [chartInstance, setChartInstance] = useState<Chart | null>(null); // ✅ 新增:chart state(触发重渲染)
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// ✅ 固定使用浅色主题(已移除 useColorMode)
|
||||
const colorMode = 'light';
|
||||
|
||||
// 全局注册自定义均价线指标(只执行一次)
|
||||
useEffect(() => {
|
||||
try {
|
||||
registerIndicator(avgPriceIndicator);
|
||||
logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功');
|
||||
} catch (err) {
|
||||
// 如果已注册会报错,忽略即可
|
||||
logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 图表初始化(添加延迟重试机制,处理 Modal 动画延迟)
|
||||
useEffect(() => {
|
||||
// 图表初始化函数
|
||||
const initChart = (): boolean => {
|
||||
if (!chartRef.current) {
|
||||
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('useKLineChart', 'init', '开始初始化图表', {
|
||||
containerId,
|
||||
height,
|
||||
colorMode,
|
||||
});
|
||||
|
||||
// 初始化图表实例(KLineChart 10.0 API)
|
||||
// ✅ 根据 chartType 选择主题
|
||||
const themeStyles = chartType === 'timeline'
|
||||
? getTimelineTheme(colorMode)
|
||||
: getTheme(colorMode);
|
||||
|
||||
const chartInstance = init(chartRef.current, {
|
||||
...CHART_INIT_OPTIONS,
|
||||
// 设置初始样式(根据主题和图表类型)
|
||||
styles: themeStyles,
|
||||
});
|
||||
|
||||
if (!chartInstance) {
|
||||
throw new Error('图表初始化失败:返回 null');
|
||||
}
|
||||
|
||||
chartInstanceRef.current = chartInstance;
|
||||
setChartInstance(chartInstance); // ✅ 新增:更新 state,触发重渲染
|
||||
setIsInitialized(true);
|
||||
setError(null);
|
||||
|
||||
// ✅ 新增:创建成交量指标窗格
|
||||
try {
|
||||
const volumePaneId = chartInstance.createIndicator('VOL', false, {
|
||||
height: 100, // 固定高度 100px(约占整体的 20-25%)
|
||||
});
|
||||
|
||||
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
|
||||
volumePaneId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
|
||||
error: err,
|
||||
});
|
||||
// 不阻塞主流程,继续执行
|
||||
}
|
||||
|
||||
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
|
||||
containerId,
|
||||
chartId: chartInstance.id,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error('useKLineChart', 'init', error, { containerId });
|
||||
setError(error);
|
||||
setIsInitialized(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 立即尝试初始化
|
||||
if (initChart()) {
|
||||
// 成功,直接返回清理函数
|
||||
return () => {
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
|
||||
dispose(chartInstanceRef.current);
|
||||
chartInstanceRef.current = null;
|
||||
setChartInstance(null); // ✅ 新增:清空 state
|
||||
setIsInitialized(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
|
||||
const timer = setTimeout(() => {
|
||||
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
|
||||
initChart();
|
||||
}, 50);
|
||||
|
||||
// 清理函数:清除定时器和销毁图表实例
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (chartInstanceRef.current) {
|
||||
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
|
||||
containerId,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
|
||||
dispose(chartInstanceRef.current);
|
||||
chartInstanceRef.current = null;
|
||||
setChartInstance(null); // ✅ 新增:清空 state
|
||||
setIsInitialized(false);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerId, chartType]); // containerId 或 chartType 变化时重新初始化
|
||||
|
||||
// 主题切换:更新图表样式
|
||||
useEffect(() => {
|
||||
if (!chartInstanceRef.current || !isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ 根据 chartType 选择主题
|
||||
const newTheme = chartType === 'timeline'
|
||||
? getTimelineTheme(colorMode)
|
||||
: getTheme(colorMode);
|
||||
chartInstanceRef.current.setStyles(newTheme);
|
||||
|
||||
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
|
||||
colorMode,
|
||||
chartType,
|
||||
chartId: chartInstanceRef.current.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType });
|
||||
}
|
||||
}, [colorMode, chartType, isInitialized]);
|
||||
|
||||
// 容器尺寸变化:调整图表大小
|
||||
useEffect(() => {
|
||||
if (!chartInstanceRef.current || !isInitialized || !autoResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (chartInstanceRef.current) {
|
||||
chartInstanceRef.current.resize();
|
||||
logger.debug('useKLineChart', 'resize', '调整图表大小');
|
||||
}
|
||||
};
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 使用 ResizeObserver 监听容器大小变化(更精确)
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (chartRef.current && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(chartRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeObserver && chartRef.current) {
|
||||
resizeObserver.unobserve(chartRef.current);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [isInitialized, autoResize]);
|
||||
|
||||
return {
|
||||
chart: chartInstance, // ✅ 返回 state 而非 ref,确保变化触发重渲染
|
||||
chartRef,
|
||||
isInitialized,
|
||||
error,
|
||||
};
|
||||
};
|
||||
329
src/components/StockChart/hooks/useKLineData.ts
Normal file
329
src/components/StockChart/hooks/useKLineData.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* useKLineData Hook
|
||||
*
|
||||
* 管理 K 线数据的加载、转换和更新
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Chart } from 'klinecharts';
|
||||
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
|
||||
import { processChartData } from '../utils/dataAdapter';
|
||||
import { logger } from '@utils/logger';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||||
|
||||
export interface UseKLineDataOptions {
|
||||
/** KLineChart 实例 */
|
||||
chart: Chart | null;
|
||||
/** 股票代码 */
|
||||
stockCode: string;
|
||||
/** 图表类型 */
|
||||
chartType: ChartType;
|
||||
/** 事件时间(用于调整数据加载范围) */
|
||||
eventTime?: string;
|
||||
/** 是否自动加载数据 */
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
export interface UseKLineDataReturn {
|
||||
/** 处理后的 K 线数据 */
|
||||
data: KLineDataPoint[];
|
||||
/** 原始数据 */
|
||||
rawData: RawDataPoint[];
|
||||
/** 是否加载中 */
|
||||
loading: boolean;
|
||||
/** 加载错误 */
|
||||
error: Error | null;
|
||||
/** 手动加载数据 */
|
||||
loadData: () => Promise<void>;
|
||||
/** 更新数据 */
|
||||
updateData: (newData: KLineDataPoint[]) => void;
|
||||
/** 清空数据 */
|
||||
clearData: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* K 线数据加载和管理 Hook
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns UseKLineDataReturn
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error, loadData } = useKLineData({
|
||||
* chart,
|
||||
* stockCode: '600000.SH',
|
||||
* chartType: 'daily',
|
||||
* eventTime: '2024-01-01 10:00:00',
|
||||
* autoLoad: true,
|
||||
* });
|
||||
*/
|
||||
export const useKLineData = (
|
||||
options: UseKLineDataOptions
|
||||
): UseKLineDataReturn => {
|
||||
const {
|
||||
chart,
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime,
|
||||
autoLoad = true,
|
||||
} = options;
|
||||
|
||||
const [data, setData] = useState<KLineDataPoint[]>([]);
|
||||
const [rawData, setRawData] = useState<RawDataPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
/**
|
||||
* 加载数据(从后端 API)
|
||||
*/
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stockCode) {
|
||||
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.debug('useKLineData', 'loadData', '开始加载数据', {
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
// 1. 先检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
let rawDataList;
|
||||
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
// 使用缓存数据
|
||||
rawDataList = cachedData;
|
||||
} else {
|
||||
// 2. 缓存没有数据,调用 API 请求
|
||||
const response = await stockService.getKlineData(
|
||||
stockCode,
|
||||
chartType,
|
||||
eventTime
|
||||
);
|
||||
|
||||
if (!response || !response.data) {
|
||||
throw new Error('后端返回数据为空');
|
||||
}
|
||||
|
||||
rawDataList = response.data;
|
||||
|
||||
// 3. 将数据写入缓存(避免下次重复请求)
|
||||
klineDataCache.set(cacheKey, rawDataList);
|
||||
}
|
||||
|
||||
setRawData(rawDataList);
|
||||
|
||||
// 数据转换和处理
|
||||
const processedData = processChartData(rawDataList, chartType, eventTime);
|
||||
|
||||
setData(processedData);
|
||||
|
||||
logger.info('useKLineData', 'loadData', '数据加载成功', {
|
||||
stockCode,
|
||||
chartType,
|
||||
rawCount: rawDataList.length,
|
||||
processedCount: processedData.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error('useKLineData', 'loadData', error, {
|
||||
stockCode,
|
||||
chartType,
|
||||
});
|
||||
setError(error);
|
||||
setData([]);
|
||||
setRawData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stockCode, chartType, eventTime]);
|
||||
|
||||
/**
|
||||
* 更新图表数据(使用 setDataLoader 方法)
|
||||
*/
|
||||
const updateChartData = useCallback(
|
||||
(klineData: KLineDataPoint[]) => {
|
||||
if (!chart || klineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 步骤 1: 设置 symbol(必需!getBars 调用的前置条件)
|
||||
(chart as any).setSymbol({
|
||||
ticker: stockCode || 'UNKNOWN', // 股票代码
|
||||
pricePrecision: 2, // 价格精度(2位小数)
|
||||
volumePrecision: 0 // 成交量精度(整数)
|
||||
});
|
||||
|
||||
// 步骤 2: 设置 period(必需!getBars 调用的前置条件)
|
||||
const periodType = chartType === 'timeline' ? 'minute' : 'day';
|
||||
(chart as any).setPeriod({
|
||||
type: periodType, // 分时图=minute,日K=day
|
||||
span: 1 // 周期跨度(1分钟/1天)
|
||||
});
|
||||
|
||||
// 步骤 3: 设置 DataLoader(同步数据加载器)
|
||||
(chart as any).setDataLoader({
|
||||
getBars: (params: any) => {
|
||||
if (params.type === 'init') {
|
||||
// 初始化加载:返回完整数据
|
||||
params.callback(klineData, false); // false = 无更多数据可加载
|
||||
} else if (params.type === 'forward' || params.type === 'backward') {
|
||||
// 向前/向后加载:我们没有更多数据,返回空数组
|
||||
params.callback([], false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 步骤 4: 触发初始化加载(这会调用 getBars with type="init")
|
||||
(chart as any).resetData();
|
||||
|
||||
// 步骤 5: 根据数据量调整可见范围和柱子间距(让 K 线柱子填满图表区域)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const dataLength = klineData.length;
|
||||
|
||||
if (dataLength > 0) {
|
||||
// 获取图表容器宽度
|
||||
const chartDom = (chart as any).getDom();
|
||||
const chartWidth = chartDom?.clientWidth || 1200;
|
||||
|
||||
// 计算最优柱子间距
|
||||
// 公式:barSpace = (图表宽度 / 数据数量) * 0.7
|
||||
// 0.7 是为了留出一些间距,让图表不会太拥挤
|
||||
const optimalBarSpace = Math.max(8, Math.min(50, (chartWidth / dataLength) * 0.7));
|
||||
|
||||
(chart as any).setBarSpace(optimalBarSpace);
|
||||
|
||||
// 减少右侧空白(默认值可能是 100-200,调小会减少右侧空白)
|
||||
(chart as any).setOffsetRightDistance(50);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useKLineData', 'updateChartData', err as Error, {
|
||||
step: '调整可见范围失败',
|
||||
});
|
||||
}
|
||||
}, 100); // 延迟 100ms 确保数据已加载和渲染
|
||||
|
||||
// ✅ 步骤 4: 分时图添加均价线(使用自定义 AVG 指标)
|
||||
if (chartType === 'timeline' && klineData.length > 0) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// 在主图窗格创建 AVG 均价线指标
|
||||
(chart as any).createIndicator('AVG', true, {
|
||||
id: 'candle_pane', // 主图窗格
|
||||
});
|
||||
|
||||
console.log('[DEBUG] ✅ 均价线(AVG指标)添加成功');
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] ❌ 均价线添加失败:', err);
|
||||
}
|
||||
}, 150); // 延迟 150ms,确保数据加载完成后再创建指标
|
||||
|
||||
// ✅ 步骤 5: 添加昨收价基准线(灰色虚线)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const prevClose = klineData[0]?.prev_close;
|
||||
if (prevClose && prevClose > 0) {
|
||||
// 创建水平线覆盖层
|
||||
(chart as any).createOverlay({
|
||||
name: 'horizontalStraightLine',
|
||||
id: 'prev_close_line',
|
||||
points: [{ value: prevClose }],
|
||||
styles: {
|
||||
line: {
|
||||
style: 'dashed',
|
||||
dashValue: [4, 2],
|
||||
size: 1,
|
||||
color: '#888888', // 灰色虚线
|
||||
},
|
||||
},
|
||||
extendData: {
|
||||
label: `昨收: ${prevClose.toFixed(2)}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[DEBUG] ✅ 昨收价基准线添加成功:', prevClose);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] ❌ 昨收价基准线添加失败:', err);
|
||||
}
|
||||
}, 200); // 延迟 200ms,确保均价线创建完成后再添加
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'useKLineData',
|
||||
`updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('useKLineData', 'updateChartData', err as Error, {
|
||||
dataCount: klineData.length,
|
||||
});
|
||||
}
|
||||
},
|
||||
[chart, stockCode, chartType]
|
||||
);
|
||||
|
||||
/**
|
||||
* 手动更新数据(外部调用)
|
||||
*/
|
||||
const updateData = useCallback(
|
||||
(newData: KLineDataPoint[]) => {
|
||||
setData(newData);
|
||||
updateChartData(newData);
|
||||
|
||||
logger.debug(
|
||||
'useKLineData',
|
||||
`updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
|
||||
);
|
||||
},
|
||||
[updateChartData]
|
||||
);
|
||||
|
||||
/**
|
||||
* 清空数据
|
||||
*/
|
||||
const clearData = useCallback(() => {
|
||||
setData([]);
|
||||
setRawData([]);
|
||||
setError(null);
|
||||
|
||||
if (chart) {
|
||||
chart.resetData();
|
||||
logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
// 自动加载数据(当 stockCode/chartType/eventTime 变化时)
|
||||
useEffect(() => {
|
||||
if (autoLoad && stockCode && chart) {
|
||||
loadData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stockCode, chartType, eventTime, autoLoad, chart]);
|
||||
|
||||
// 数据变化时更新图表
|
||||
useEffect(() => {
|
||||
if (data.length > 0 && chart) {
|
||||
updateChartData(data);
|
||||
}
|
||||
}, [data, chart, updateChartData]);
|
||||
|
||||
return {
|
||||
data,
|
||||
rawData,
|
||||
loading,
|
||||
error,
|
||||
loadData,
|
||||
updateData,
|
||||
clearData,
|
||||
};
|
||||
};
|
||||
93
src/components/StockChart/indicators/avgPriceIndicator.ts
Normal file
93
src/components/StockChart/indicators/avgPriceIndicator.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 自定义均价线指标
|
||||
*
|
||||
* 用于分时图显示橙黄色均价线
|
||||
* 计算公式:累计成交额 / 累计成交量
|
||||
*/
|
||||
|
||||
import type { Indicator, KLineData } from 'klinecharts';
|
||||
|
||||
export const avgPriceIndicator: Indicator = {
|
||||
name: 'AVG',
|
||||
shortName: 'AVG',
|
||||
calcParams: [],
|
||||
shouldOhlc: false, // 不显示 OHLC 信息
|
||||
shouldFormatBigNumber: false,
|
||||
precision: 2,
|
||||
minValue: null,
|
||||
maxValue: null,
|
||||
|
||||
figures: [
|
||||
{
|
||||
key: 'avg',
|
||||
title: '均价: ',
|
||||
type: 'line',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* 计算均价
|
||||
* @param dataList K线数据列表
|
||||
* @returns 均价数据
|
||||
*/
|
||||
calc: (dataList: KLineData[]) => {
|
||||
let totalAmount = 0; // 累计成交额
|
||||
let totalVolume = 0; // 累计成交量
|
||||
|
||||
return dataList.map((kLineData) => {
|
||||
const { close = 0, volume = 0 } = kLineData;
|
||||
|
||||
totalAmount += close * volume;
|
||||
totalVolume += volume;
|
||||
|
||||
const avgPrice = totalVolume > 0 ? totalAmount / totalVolume : close;
|
||||
|
||||
return { avg: avgPrice };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 绘制样式配置
|
||||
*/
|
||||
styles: {
|
||||
lines: [
|
||||
{
|
||||
color: '#FF9800', // 橙黄色
|
||||
size: 2,
|
||||
style: 'solid',
|
||||
smooth: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Tooltip 格式化(显示均价 + 涨跌幅)
|
||||
*/
|
||||
createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => {
|
||||
if (!indicator?.avg) {
|
||||
return {
|
||||
title: { text: '均价', color: defaultStyles.tooltip.text.color },
|
||||
value: { text: '--', color: '#FF9800' },
|
||||
};
|
||||
}
|
||||
|
||||
const avgPrice = indicator.avg;
|
||||
const prevClose = kLineData?.prev_close;
|
||||
|
||||
// 计算均价涨跌幅
|
||||
let changeText = `¥${avgPrice.toFixed(2)}`;
|
||||
if (prevClose && prevClose > 0) {
|
||||
const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2);
|
||||
const changeValue = (avgPrice - prevClose).toFixed(2);
|
||||
changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`;
|
||||
}
|
||||
|
||||
return {
|
||||
title: { text: '均价', color: defaultStyles.tooltip.text.color },
|
||||
value: {
|
||||
text: changeText,
|
||||
color: '#FF9800',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
126
src/components/StockChart/types/chart.types.ts
Normal file
126
src/components/StockChart/types/chart.types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* KLineChart 图表类型定义
|
||||
*
|
||||
* 适配 klinecharts@10.0.0-beta1
|
||||
* 文档: https://github.com/klinecharts/KLineChart
|
||||
*/
|
||||
|
||||
/**
|
||||
* K 线数据点(符合 KLineChart 10.0 规范)
|
||||
*
|
||||
* 注意: 10.0 版本要求 timestamp 为数字类型(毫秒时间戳)
|
||||
*/
|
||||
export interface KLineDataPoint {
|
||||
/** 时间戳(毫秒) */
|
||||
timestamp: number;
|
||||
/** 开盘价 */
|
||||
open: number;
|
||||
/** 最高价 */
|
||||
high: number;
|
||||
/** 最低价 */
|
||||
low: number;
|
||||
/** 收盘价 */
|
||||
close: number;
|
||||
/** 成交量 */
|
||||
volume: number;
|
||||
/** 成交额(可选) */
|
||||
turnover?: number;
|
||||
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */
|
||||
prev_close?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端原始数据格式
|
||||
*
|
||||
* 支持多种时间字段格式(time/date/timestamp)
|
||||
*/
|
||||
export interface RawDataPoint {
|
||||
/** 时间字符串(分时图格式:HH:mm) */
|
||||
time?: string;
|
||||
/** 日期字符串(日线格式:YYYY-MM-DD) */
|
||||
date?: string;
|
||||
/** 时间戳字符串或数字 */
|
||||
timestamp?: string | number;
|
||||
/** 开盘价 */
|
||||
open: number;
|
||||
/** 最高价 */
|
||||
high: number;
|
||||
/** 最低价 */
|
||||
low: number;
|
||||
/** 收盘价 */
|
||||
close: number;
|
||||
/** 成交量 */
|
||||
volume: number;
|
||||
/** 均价(分时图专用) */
|
||||
avg_price?: number;
|
||||
/** 昨收价(用于百分比计算和基准线)- 分时图专用 */
|
||||
prev_close?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表类型枚举
|
||||
*/
|
||||
export type ChartType = 'timeline' | 'daily';
|
||||
|
||||
/**
|
||||
* 图表配置接口
|
||||
*/
|
||||
export interface ChartConfig {
|
||||
/** 图表类型 */
|
||||
type: ChartType;
|
||||
/** 显示技术指标 */
|
||||
showIndicators: boolean;
|
||||
/** 默认技术指标列表 */
|
||||
defaultIndicators?: string[];
|
||||
/** 图表高度(px) */
|
||||
height?: number;
|
||||
/** 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** 是否显示十字光标 */
|
||||
showCrosshair?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件标记接口
|
||||
*
|
||||
* 用于在 K 线图上标记重要事件发生时间点
|
||||
*/
|
||||
export interface EventMarker {
|
||||
/** 唯一标识 */
|
||||
id: string;
|
||||
/** 时间戳(毫秒) */
|
||||
timestamp: number;
|
||||
/** 标签文本 */
|
||||
label: string;
|
||||
/** 标记位置 */
|
||||
position: 'top' | 'middle' | 'bottom';
|
||||
/** 标记颜色 */
|
||||
color: string;
|
||||
/** 图标(可选) */
|
||||
icon?: string;
|
||||
/** 是否可拖动(默认 false) */
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLoader 回调参数(KLineChart 10.0 新增)
|
||||
*/
|
||||
export interface DataLoaderCallbackParams {
|
||||
/** K 线数据 */
|
||||
data: KLineDataPoint[];
|
||||
/** 是否还有更多数据 */
|
||||
more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLoader getBars 参数(KLineChart 10.0 新增)
|
||||
*/
|
||||
export interface DataLoaderGetBarsParams {
|
||||
/** 回调函数 */
|
||||
callback: (data: KLineDataPoint[], options?: { more: boolean }) => void;
|
||||
/** 范围参数(可选) */
|
||||
range?: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
}
|
||||
25
src/components/StockChart/types/index.ts
Normal file
25
src/components/StockChart/types/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* StockChart 类型定义统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import type { KLineDataPoint, StockInfo } from '@components/StockChart/types';
|
||||
*/
|
||||
|
||||
// 图表相关类型
|
||||
export type {
|
||||
KLineDataPoint,
|
||||
RawDataPoint,
|
||||
ChartType,
|
||||
ChartConfig,
|
||||
EventMarker,
|
||||
DataLoaderCallbackParams,
|
||||
DataLoaderGetBarsParams,
|
||||
} from './chart.types';
|
||||
|
||||
// 股票相关类型
|
||||
export type {
|
||||
StockInfo,
|
||||
ChartDataResponse,
|
||||
StockQuote,
|
||||
EventInfo,
|
||||
} from './stock.types';
|
||||
80
src/components/StockChart/types/stock.types.ts
Normal file
80
src/components/StockChart/types/stock.types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 股票相关类型定义
|
||||
*
|
||||
* 用于股票信息和图表数据的类型声明
|
||||
*/
|
||||
|
||||
import type { RawDataPoint } from './chart.types';
|
||||
|
||||
/**
|
||||
* 股票基础信息
|
||||
*/
|
||||
export interface StockInfo {
|
||||
/** 股票代码(如:600000.SH) */
|
||||
stock_code: string;
|
||||
/** 股票名称(如:浦发银行) */
|
||||
stock_name: string;
|
||||
/** 关联描述(可能是字符串或对象) */
|
||||
relation_desc?:
|
||||
| string
|
||||
| {
|
||||
/** 数据字段 */
|
||||
data?: string;
|
||||
/** 内容字段 */
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图表数据 API 响应格式
|
||||
*/
|
||||
export interface ChartDataResponse {
|
||||
/** K 线数据数组 */
|
||||
data: RawDataPoint[];
|
||||
/** 交易日期(YYYY-MM-DD) */
|
||||
trade_date?: string;
|
||||
/** 昨收价 */
|
||||
prev_close?: number;
|
||||
/** 状态码(可选) */
|
||||
code?: number;
|
||||
/** 消息(可选) */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票实时行情
|
||||
*/
|
||||
export interface StockQuote {
|
||||
/** 股票代码 */
|
||||
stock_code: string;
|
||||
/** 当前价 */
|
||||
price: number;
|
||||
/** 涨跌幅(%) */
|
||||
change_percent: number;
|
||||
/** 涨跌额 */
|
||||
change_amount: number;
|
||||
/** 成交量 */
|
||||
volume: number;
|
||||
/** 成交额 */
|
||||
turnover: number;
|
||||
/** 更新时间 */
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件信息(用于事件中心)
|
||||
*/
|
||||
export interface EventInfo {
|
||||
/** 事件 ID */
|
||||
id: number | string;
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
/** 事件内容 */
|
||||
content: string;
|
||||
/** 事件发生时间(ISO 字符串) */
|
||||
event_time: string;
|
||||
/** 重要性等级(1-5) */
|
||||
importance?: number;
|
||||
/** 关联股票列表 */
|
||||
related_stocks?: StockInfo[];
|
||||
}
|
||||
295
src/components/StockChart/utils/chartUtils.ts
Normal file
295
src/components/StockChart/utils/chartUtils.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 图表通用工具函数
|
||||
*
|
||||
* 包含图表初始化、技术指标管理等通用逻辑
|
||||
*/
|
||||
|
||||
import type { Chart } from 'klinecharts';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 安全地执行图表操作(捕获异常)
|
||||
*
|
||||
* @param operation 操作名称
|
||||
* @param fn 执行函数
|
||||
* @returns T | null 执行结果或 null
|
||||
*/
|
||||
export const safeChartOperation = <T>(
|
||||
operation: string,
|
||||
fn: () => T
|
||||
): T | null => {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
logger.error('chartUtils', operation, error as Error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建技术指标
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param indicatorName 指标名称(如 'MA', 'MACD', 'VOL')
|
||||
* @param params 指标参数(可选)
|
||||
* @param isStack 是否叠加(主图指标为 true,副图为 false)
|
||||
* @returns string | null 指标 ID
|
||||
*/
|
||||
export const createIndicator = (
|
||||
chart: Chart,
|
||||
indicatorName: string,
|
||||
params?: number[],
|
||||
isStack: boolean = false
|
||||
): string | null => {
|
||||
return safeChartOperation(`createIndicator:${indicatorName}`, () => {
|
||||
const indicatorId = chart.createIndicator(
|
||||
{
|
||||
name: indicatorName,
|
||||
...(params && { calcParams: params }),
|
||||
},
|
||||
isStack
|
||||
);
|
||||
|
||||
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
|
||||
indicatorName,
|
||||
params,
|
||||
isStack,
|
||||
indicatorId,
|
||||
});
|
||||
|
||||
return indicatorId;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除技术指标
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param indicatorId 指标 ID(不传则移除所有指标)
|
||||
*/
|
||||
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
|
||||
safeChartOperation('removeIndicator', () => {
|
||||
chart.removeIndicator(indicatorId);
|
||||
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量创建副图指标
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param indicators 指标名称数组
|
||||
* @returns string[] 指标 ID 数组
|
||||
*/
|
||||
export const createSubIndicators = (
|
||||
chart: Chart,
|
||||
indicators: string[]
|
||||
): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
indicators.forEach((name) => {
|
||||
const id = createIndicator(chart, name, undefined, false);
|
||||
if (id) {
|
||||
ids.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
|
||||
indicators,
|
||||
createdIds: ids,
|
||||
});
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置图表缩放级别
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param zoom 缩放级别(0.5 - 2.0)
|
||||
*/
|
||||
export const setChartZoom = (chart: Chart, zoom: number): void => {
|
||||
safeChartOperation('setChartZoom', () => {
|
||||
// KLineChart 10.0: 使用 setBarSpace 方法调整 K 线宽度(实现缩放效果)
|
||||
const baseBarSpace = 8; // 默认 K 线宽度(px)
|
||||
const newBarSpace = Math.max(4, Math.min(16, baseBarSpace * zoom));
|
||||
|
||||
// 注意:KLineChart 10.0 可能没有直接的 zoom API,需要通过调整样式实现
|
||||
chart.setStyles({
|
||||
candle: {
|
||||
bar: {
|
||||
upBorderColor: undefined, // 保持默认
|
||||
upColor: undefined,
|
||||
downBorderColor: undefined,
|
||||
downColor: undefined,
|
||||
},
|
||||
// 通过调整蜡烛图宽度实现缩放效果
|
||||
tooltip: {
|
||||
showRule: 'always',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
|
||||
zoom,
|
||||
newBarSpace,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 滚动到指定时间
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param timestamp 目标时间戳
|
||||
*/
|
||||
export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
|
||||
safeChartOperation('scrollToTimestamp', () => {
|
||||
// KLineChart 10.0: 使用 scrollToTimestamp 方法
|
||||
chart.scrollToTimestamp(timestamp);
|
||||
|
||||
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 调整图表大小(响应式)
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
*/
|
||||
export const resizeChart = (chart: Chart): void => {
|
||||
safeChartOperation('resizeChart', () => {
|
||||
chart.resize();
|
||||
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取图表可见数据范围
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @returns { from: number, to: number } | null 可见范围
|
||||
*/
|
||||
export const getVisibleRange = (chart: Chart): { from: number; to: number } | null => {
|
||||
return safeChartOperation('getVisibleRange', () => {
|
||||
const data = chart.getDataList();
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 简化实现:返回所有数据范围
|
||||
// 实际项目中可通过 chart 的内部状态获取可见范围
|
||||
return {
|
||||
from: 0,
|
||||
to: data.length - 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空图表数据
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
*/
|
||||
export const clearChartData = (chart: Chart): void => {
|
||||
safeChartOperation('clearChartData', () => {
|
||||
chart.resetData();
|
||||
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 截图(导出图表为图片)
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param includeOverlay 是否包含 overlay
|
||||
* @returns string | null Base64 图片数据
|
||||
*/
|
||||
export const exportChartImage = (
|
||||
chart: Chart,
|
||||
includeOverlay: boolean = true
|
||||
): string | null => {
|
||||
return safeChartOperation('exportChartImage', () => {
|
||||
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
|
||||
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
|
||||
|
||||
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
|
||||
includeOverlay,
|
||||
hasData: !!imageData,
|
||||
});
|
||||
|
||||
return imageData;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换十字光标显示
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param show 是否显示
|
||||
*/
|
||||
export const toggleCrosshair = (chart: Chart, show: boolean): void => {
|
||||
safeChartOperation('toggleCrosshair', () => {
|
||||
chart.setStyles({
|
||||
crosshair: {
|
||||
show,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换网格显示
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param show 是否显示
|
||||
*/
|
||||
export const toggleGrid = (chart: Chart, show: boolean): void => {
|
||||
safeChartOperation('toggleGrid', () => {
|
||||
chart.setStyles({
|
||||
grid: {
|
||||
show,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅图表事件
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param eventName 事件名称
|
||||
* @param handler 事件处理函数
|
||||
*/
|
||||
export const subscribeChartEvent = (
|
||||
chart: Chart,
|
||||
eventName: string,
|
||||
handler: (...args: any[]) => void
|
||||
): void => {
|
||||
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
|
||||
chart.subscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消订阅图表事件
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param eventName 事件名称
|
||||
* @param handler 事件处理函数
|
||||
*/
|
||||
export const unsubscribeChartEvent = (
|
||||
chart: Chart,
|
||||
eventName: string,
|
||||
handler: (...args: any[]) => void
|
||||
): void => {
|
||||
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
|
||||
chart.unsubscribeAction(eventName, handler);
|
||||
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
|
||||
});
|
||||
};
|
||||
320
src/components/StockChart/utils/dataAdapter.ts
Normal file
320
src/components/StockChart/utils/dataAdapter.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 数据转换适配器
|
||||
*
|
||||
* 将后端返回的各种格式数据转换为 KLineChart 10.0 所需的标准格式
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 将后端原始数据转换为 KLineChart 标准格式
|
||||
*
|
||||
* @param rawData 后端原始数据数组
|
||||
* @param chartType 图表类型(timeline/daily)
|
||||
* @param eventTime 事件时间(用于日期基准)
|
||||
* @returns KLineDataPoint[] 标准K线数据
|
||||
*/
|
||||
export const convertToKLineData = (
|
||||
rawData: RawDataPoint[],
|
||||
chartType: ChartType,
|
||||
eventTime?: string
|
||||
): KLineDataPoint[] => {
|
||||
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
|
||||
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return rawData.map((item, index) => {
|
||||
const timestamp = parseTimestamp(item, chartType, eventTime, index);
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
open: Number(item.open) || 0,
|
||||
high: Number(item.high) || 0,
|
||||
low: Number(item.low) || 0,
|
||||
close: Number(item.close) || 0,
|
||||
volume: Number(item.volume) || 0,
|
||||
turnover: item.turnover ? Number(item.turnover) : undefined,
|
||||
prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线)
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
|
||||
chartType,
|
||||
dataLength: rawData.length,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析时间戳(兼容多种时间格式)
|
||||
*
|
||||
* @param item 原始数据项
|
||||
* @param chartType 图表类型
|
||||
* @param eventTime 事件时间
|
||||
* @param index 数据索引(用于分时图时间推算)
|
||||
* @returns number 毫秒时间戳
|
||||
*/
|
||||
const parseTimestamp = (
|
||||
item: RawDataPoint,
|
||||
chartType: ChartType,
|
||||
eventTime?: string,
|
||||
index?: number
|
||||
): number => {
|
||||
// 优先级1: 使用 timestamp 字段
|
||||
if (item.timestamp) {
|
||||
const ts = typeof item.timestamp === 'number' ? item.timestamp : Number(item.timestamp);
|
||||
// 判断是秒级还是毫秒级时间戳
|
||||
return ts > 10000000000 ? ts : ts * 1000;
|
||||
}
|
||||
|
||||
// 优先级2: 使用 date 字段(日K线)
|
||||
if (item.date) {
|
||||
return dayjs(item.date).valueOf();
|
||||
}
|
||||
|
||||
// 优先级3: 使用 time 字段(分时图)
|
||||
if (item.time && eventTime) {
|
||||
return parseTimelineTimestamp(item.time, eventTime);
|
||||
}
|
||||
|
||||
// 优先级4: 根据 chartType 和 index 推算(兜底逻辑)
|
||||
if (chartType === 'timeline' && eventTime && typeof index === 'number') {
|
||||
// 分时图:从事件时间推算(假设 09:30 开盘)
|
||||
const baseTime = dayjs(eventTime).startOf('day').add(9, 'hour').add(30, 'minute');
|
||||
return baseTime.add(index, 'minute').valueOf();
|
||||
}
|
||||
|
||||
// 默认返回当前时间(避免图表崩溃)
|
||||
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析分时图时间戳
|
||||
*
|
||||
* 将 "HH:mm" 格式转换为完整时间戳
|
||||
*
|
||||
* @param time 时间字符串(如 "09:30")
|
||||
* @param eventTime 事件时间(YYYY-MM-DD HH:mm:ss)
|
||||
* @returns number 毫秒时间戳
|
||||
*/
|
||||
const parseTimelineTimestamp = (time: string, eventTime: string): number => {
|
||||
try {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const eventDate = dayjs(eventTime).startOf('day');
|
||||
return eventDate.hour(hours).minute(minutes).second(0).valueOf();
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
|
||||
return dayjs(eventTime).valueOf();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据验证和清洗
|
||||
*
|
||||
* 移除无效数据(价格/成交量异常)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns KLineDataPoint[] 清洗后的数据
|
||||
*/
|
||||
export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] => {
|
||||
return data.filter((item) => {
|
||||
// 移除价格为 0 或负数的数据
|
||||
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除 high < low 的数据(数据错误)
|
||||
if (item.high < item.low) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 移除成交量为负数的数据
|
||||
if (item.volume < 0) {
|
||||
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据排序(按时间升序)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns KLineDataPoint[] 排序后的数据
|
||||
*/
|
||||
export const sortDataByTime = (data: KLineDataPoint[]): KLineDataPoint[] => {
|
||||
return [...data].sort((a, b) => a.timestamp - b.timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据去重(移除时间戳重复的数据,保留最后一条)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns KLineDataPoint[] 去重后的数据
|
||||
*/
|
||||
export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => {
|
||||
const map = new Map<number, KLineDataPoint>();
|
||||
|
||||
data.forEach((item) => {
|
||||
map.set(item.timestamp, item); // 相同时间戳会覆盖
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据事件时间裁剪数据范围(前后2周)
|
||||
*
|
||||
* @param data K线数据
|
||||
* @param eventTime 事件时间(ISO字符串)
|
||||
* @param chartType 图表类型
|
||||
* @returns KLineDataPoint[] 裁剪后的数据
|
||||
*/
|
||||
export const trimDataByEventTime = (
|
||||
data: KLineDataPoint[],
|
||||
eventTime: string,
|
||||
chartType: ChartType
|
||||
): KLineDataPoint[] => {
|
||||
if (!eventTime || !data || data.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventTimestamp = dayjs(eventTime).valueOf();
|
||||
|
||||
// 根据图表类型设置不同的时间范围
|
||||
let beforeDays: number;
|
||||
let afterDays: number;
|
||||
|
||||
if (chartType === 'timeline') {
|
||||
// 分时图:只显示事件当天(前后0天)
|
||||
beforeDays = 0;
|
||||
afterDays = 0;
|
||||
} else {
|
||||
// 日K线:显示前后14天(2周)
|
||||
beforeDays = 14;
|
||||
afterDays = 14;
|
||||
}
|
||||
|
||||
const startTime = dayjs(eventTime).subtract(beforeDays, 'day').startOf('day').valueOf();
|
||||
const endTime = dayjs(eventTime).add(afterDays, 'day').endOf('day').valueOf();
|
||||
|
||||
const trimmedData = data.filter((item) => {
|
||||
return item.timestamp >= startTime && item.timestamp <= endTime;
|
||||
});
|
||||
|
||||
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
|
||||
originalLength: data.length,
|
||||
trimmedLength: trimmedData.length,
|
||||
eventTime,
|
||||
chartType,
|
||||
dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`,
|
||||
});
|
||||
|
||||
return trimmedData;
|
||||
} catch (error) {
|
||||
logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime });
|
||||
return data; // 出错时返回原始数据
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的数据处理流程
|
||||
*
|
||||
* 转换 → 验证 → 去重 → 排序 → 时间裁剪(如果有 eventTime)
|
||||
*
|
||||
* @param rawData 后端原始数据
|
||||
* @param chartType 图表类型
|
||||
* @param eventTime 事件时间
|
||||
* @returns KLineDataPoint[] 处理后的数据
|
||||
*/
|
||||
export const processChartData = (
|
||||
rawData: RawDataPoint[],
|
||||
chartType: ChartType,
|
||||
eventTime?: string
|
||||
): KLineDataPoint[] => {
|
||||
// 1. 转换数据格式
|
||||
let data = convertToKLineData(rawData, chartType, eventTime);
|
||||
|
||||
// 2. 验证和清洗
|
||||
data = validateAndCleanData(data);
|
||||
|
||||
// 3. 去重
|
||||
data = deduplicateData(data);
|
||||
|
||||
// 4. 排序
|
||||
data = sortDataByTime(data);
|
||||
|
||||
// 5. 根据事件时间裁剪范围(如果提供了 eventTime)
|
||||
if (eventTime) {
|
||||
data = trimDataByEventTime(data, eventTime, chartType);
|
||||
}
|
||||
|
||||
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
|
||||
rawLength: rawData.length,
|
||||
processedLength: data.length,
|
||||
chartType,
|
||||
hasEventTime: !!eventTime,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据时间范围
|
||||
*
|
||||
* @param data K线数据
|
||||
* @returns { start: number, end: number } 时间范围(毫秒时间戳)
|
||||
*/
|
||||
export const getDataTimeRange = (
|
||||
data: KLineDataPoint[]
|
||||
): { start: number; end: number } | null => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = data.map((item) => item.timestamp);
|
||||
return {
|
||||
start: Math.min(...timestamps),
|
||||
end: Math.max(...timestamps),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找最接近指定时间的数据点
|
||||
*
|
||||
* @param data K线数据
|
||||
* @param targetTime 目标时间戳
|
||||
* @returns KLineDataPoint | null 最接近的数据点
|
||||
*/
|
||||
export const findClosestDataPoint = (
|
||||
data: KLineDataPoint[],
|
||||
targetTime: number
|
||||
): KLineDataPoint | null => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let closest = data[0];
|
||||
let minDiff = Math.abs(data[0].timestamp - targetTime);
|
||||
|
||||
data.forEach((item) => {
|
||||
const diff = Math.abs(item.timestamp - targetTime);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = item;
|
||||
}
|
||||
});
|
||||
|
||||
return closest;
|
||||
};
|
||||
360
src/components/StockChart/utils/eventMarkerUtils.ts
Normal file
360
src/components/StockChart/utils/eventMarkerUtils.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 事件标记工具函数
|
||||
*
|
||||
* 用于在 K 线图上创建、管理事件标记(Overlay)
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import type { OverlayCreate } from 'klinecharts';
|
||||
import type { EventMarker, KLineDataPoint } from '../types';
|
||||
import { EVENT_MARKER_CONFIG } from '../config';
|
||||
import { findClosestDataPoint } from './dataAdapter';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* 创建事件标记 Overlay(KLineChart 10.0 格式)
|
||||
*
|
||||
* @param marker 事件标记配置
|
||||
* @param data K线数据(用于定位标记位置)
|
||||
* @returns OverlayCreate | null Overlay 配置对象
|
||||
*/
|
||||
export const createEventMarkerOverlay = (
|
||||
marker: EventMarker,
|
||||
data: KLineDataPoint[]
|
||||
): OverlayCreate | null => {
|
||||
try {
|
||||
// 查找最接近事件时间的数据点
|
||||
const closestPoint = findClosestDataPoint(data, marker.timestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
|
||||
markerId: marker.id,
|
||||
timestamp: marker.timestamp,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据位置计算 Y 坐标
|
||||
const yValue = calculateMarkerYPosition(closestPoint, marker.position);
|
||||
|
||||
// 创建 Overlay 配置(KLineChart 10.0 规范)
|
||||
const overlay: OverlayCreate = {
|
||||
name: 'simpleAnnotation', // 使用内置的简单标注类型
|
||||
id: marker.id,
|
||||
points: [
|
||||
{
|
||||
timestamp: closestPoint.timestamp,
|
||||
value: yValue,
|
||||
},
|
||||
],
|
||||
styles: {
|
||||
point: {
|
||||
color: marker.color,
|
||||
borderColor: marker.color,
|
||||
borderSize: 2,
|
||||
radius: EVENT_MARKER_CONFIG.size.point,
|
||||
},
|
||||
text: {
|
||||
color: EVENT_MARKER_CONFIG.text.color,
|
||||
size: EVENT_MARKER_CONFIG.text.fontSize,
|
||||
family: EVENT_MARKER_CONFIG.text.fontFamily,
|
||||
weight: 'bold',
|
||||
},
|
||||
rect: {
|
||||
style: 'fill',
|
||||
color: marker.color,
|
||||
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
|
||||
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingRight: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingTop: EVENT_MARKER_CONFIG.text.padding,
|
||||
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
|
||||
},
|
||||
},
|
||||
// 标记文本内容
|
||||
extendData: {
|
||||
label: marker.label,
|
||||
icon: marker.icon,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
|
||||
markerId: marker.id,
|
||||
timestamp: closestPoint.timestamp,
|
||||
label: marker.label,
|
||||
});
|
||||
|
||||
return overlay;
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
|
||||
markerId: marker.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建事件日K线黄色高亮覆盖层(垂直矩形背景)
|
||||
*
|
||||
* @param eventTime 事件时间(ISO字符串)
|
||||
* @param data K线数据
|
||||
* @returns OverlayCreate | null 高亮覆盖层配置
|
||||
*/
|
||||
export const createEventHighlightOverlay = (
|
||||
eventTime: string,
|
||||
data: KLineDataPoint[]
|
||||
): OverlayCreate | null => {
|
||||
try {
|
||||
const eventTimestamp = dayjs(eventTime).valueOf();
|
||||
const closestPoint = findClosestDataPoint(data, eventTimestamp);
|
||||
|
||||
if (!closestPoint) {
|
||||
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建垂直矩形覆盖层(从图表顶部到底部的黄色半透明背景)
|
||||
const overlay: OverlayCreate = {
|
||||
name: 'rect', // 矩形覆盖层
|
||||
id: `event-highlight-${eventTimestamp}`,
|
||||
points: [
|
||||
{
|
||||
timestamp: closestPoint.timestamp,
|
||||
value: closestPoint.high * 1.05, // 顶部位置(高于最高价5%)
|
||||
},
|
||||
{
|
||||
timestamp: closestPoint.timestamp,
|
||||
value: closestPoint.low * 0.95, // 底部位置(低于最低价5%)
|
||||
},
|
||||
],
|
||||
styles: {
|
||||
style: 'fill',
|
||||
color: 'rgba(255, 193, 7, 0.15)', // 黄色半透明背景(15%透明度)
|
||||
borderColor: '#FFD54F', // 黄色边框
|
||||
borderSize: 2,
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
|
||||
timestamp: closestPoint.timestamp,
|
||||
eventTime,
|
||||
});
|
||||
|
||||
return overlay;
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算标记的 Y 轴位置
|
||||
*
|
||||
* @param dataPoint K线数据点
|
||||
* @param position 标记位置(top/middle/bottom)
|
||||
* @returns number Y轴数值
|
||||
*/
|
||||
const calculateMarkerYPosition = (
|
||||
dataPoint: KLineDataPoint,
|
||||
position: 'top' | 'middle' | 'bottom'
|
||||
): number => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return dataPoint.high * 1.02; // 在最高价上方 2%
|
||||
case 'bottom':
|
||||
return dataPoint.low * 0.98; // 在最低价下方 2%
|
||||
case 'middle':
|
||||
default:
|
||||
return (dataPoint.high + dataPoint.low) / 2; // 中间位置
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从事件时间创建标记配置
|
||||
*
|
||||
* @param eventTime 事件时间字符串(ISO 格式)
|
||||
* @param label 标记标签(可选,默认为"事件发生")
|
||||
* @param color 标记颜色(可选,使用默认颜色)
|
||||
* @returns EventMarker 事件标记配置
|
||||
*/
|
||||
export const createEventMarkerFromTime = (
|
||||
eventTime: string,
|
||||
label: string = '事件发生',
|
||||
color: string = EVENT_MARKER_CONFIG.defaultColor
|
||||
): EventMarker => {
|
||||
const timestamp = dayjs(eventTime).valueOf();
|
||||
|
||||
return {
|
||||
id: `event-${timestamp}`,
|
||||
timestamp,
|
||||
label,
|
||||
position: EVENT_MARKER_CONFIG.defaultPosition,
|
||||
color,
|
||||
icon: EVENT_MARKER_CONFIG.defaultIcon,
|
||||
draggable: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量创建事件标记 Overlays
|
||||
*
|
||||
* @param markers 事件标记配置数组
|
||||
* @param data K线数据
|
||||
* @returns OverlayCreate[] Overlay 配置数组
|
||||
*/
|
||||
export const createEventMarkerOverlays = (
|
||||
markers: EventMarker[],
|
||||
data: KLineDataPoint[]
|
||||
): OverlayCreate[] => {
|
||||
if (!markers || markers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const overlays: OverlayCreate[] = [];
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const overlay = createEventMarkerOverlay(marker, data);
|
||||
if (overlay) {
|
||||
overlays.push(overlay);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
|
||||
totalMarkers: markers.length,
|
||||
createdOverlays: overlays.length,
|
||||
});
|
||||
|
||||
return overlays;
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除事件标记
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param markerId 标记 ID
|
||||
*/
|
||||
export const removeEventMarker = (chart: any, markerId: string): void => {
|
||||
try {
|
||||
chart.removeOverlay(markerId);
|
||||
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除所有事件标记
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
*/
|
||||
export const removeAllEventMarkers = (chart: any): void => {
|
||||
try {
|
||||
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
|
||||
chart.removeOverlay();
|
||||
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新事件标记
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param markerId 标记 ID
|
||||
* @param updates 更新内容(部分字段)
|
||||
*/
|
||||
export const updateEventMarker = (
|
||||
chart: any,
|
||||
markerId: string,
|
||||
updates: Partial<EventMarker>
|
||||
): void => {
|
||||
try {
|
||||
// 先移除旧标记
|
||||
removeEventMarker(chart, markerId);
|
||||
|
||||
// 重新创建标记(KLineChart 10.0 不支持直接更新 overlay)
|
||||
// 注意:需要在调用方重新创建并添加 overlay
|
||||
|
||||
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
|
||||
markerId,
|
||||
updates,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 高亮事件标记(改变样式)
|
||||
*
|
||||
* @param chart KLineChart 实例
|
||||
* @param markerId 标记 ID
|
||||
* @param highlight 是否高亮
|
||||
*/
|
||||
export const highlightEventMarker = (
|
||||
chart: any,
|
||||
markerId: string,
|
||||
highlight: boolean
|
||||
): void => {
|
||||
try {
|
||||
// KLineChart 10.0: 通过 overrideOverlay 修改样式
|
||||
chart.overrideOverlay({
|
||||
id: markerId,
|
||||
styles: {
|
||||
point: {
|
||||
activeRadius: highlight ? 10 : EVENT_MARKER_CONFIG.size.point,
|
||||
activeBorderSize: highlight ? 3 : 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
|
||||
markerId,
|
||||
highlight,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化事件标记标签
|
||||
*
|
||||
* @param eventTitle 事件标题
|
||||
* @param maxLength 最大长度(默认 10)
|
||||
* @returns string 格式化后的标签
|
||||
*/
|
||||
export const formatEventMarkerLabel = (eventTitle: string, maxLength: number = 10): string => {
|
||||
if (!eventTitle) {
|
||||
return '事件';
|
||||
}
|
||||
|
||||
if (eventTitle.length <= maxLength) {
|
||||
return eventTitle;
|
||||
}
|
||||
|
||||
return `${eventTitle.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件时间是否在数据范围内
|
||||
*
|
||||
* @param eventTime 事件时间戳
|
||||
* @param data K线数据
|
||||
* @returns boolean 是否在范围内
|
||||
*/
|
||||
export const isEventTimeInDataRange = (
|
||||
eventTime: number,
|
||||
data: KLineDataPoint[]
|
||||
): boolean => {
|
||||
if (!data || data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamps = data.map((item) => item.timestamp);
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
|
||||
return eventTime >= minTime && eventTime <= maxTime;
|
||||
};
|
||||
48
src/components/StockChart/utils/index.ts
Normal file
48
src/components/StockChart/utils/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* StockChart 工具函数统一导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { processChartData, createEventMarkerOverlay } from '@components/StockChart/utils';
|
||||
*/
|
||||
|
||||
// 数据转换适配器
|
||||
export {
|
||||
convertToKLineData,
|
||||
validateAndCleanData,
|
||||
sortDataByTime,
|
||||
deduplicateData,
|
||||
processChartData,
|
||||
getDataTimeRange,
|
||||
findClosestDataPoint,
|
||||
} from './dataAdapter';
|
||||
|
||||
// 事件标记工具
|
||||
export {
|
||||
createEventMarkerOverlay,
|
||||
createEventMarkerFromTime,
|
||||
createEventMarkerOverlays,
|
||||
removeEventMarker,
|
||||
removeAllEventMarkers,
|
||||
updateEventMarker,
|
||||
highlightEventMarker,
|
||||
formatEventMarkerLabel,
|
||||
isEventTimeInDataRange,
|
||||
} from './eventMarkerUtils';
|
||||
|
||||
// 图表通用工具
|
||||
export {
|
||||
safeChartOperation,
|
||||
createIndicator,
|
||||
removeIndicator,
|
||||
createSubIndicators,
|
||||
setChartZoom,
|
||||
scrollToTimestamp,
|
||||
resizeChart,
|
||||
getVisibleRange,
|
||||
clearChartData,
|
||||
exportChartImage,
|
||||
toggleCrosshair,
|
||||
toggleGrid,
|
||||
subscribeChartEvent,
|
||||
unsubscribeChartEvent,
|
||||
} from './chartUtils';
|
||||
121
src/components/StockRelation/RelationDescription.tsx
Normal file
121
src/components/StockRelation/RelationDescription.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 关联描述组件
|
||||
*
|
||||
* 用于显示股票与事件的关联描述信息
|
||||
* 固定标题为"关联描述:"
|
||||
* 自动处理多种数据格式(字符串、对象数组)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 基础使用 - 传入原始 relation_desc 数据
|
||||
* <RelationDescription relationDesc={stock.relation_desc} />
|
||||
*
|
||||
* // 自定义样式
|
||||
* <RelationDescription
|
||||
* relationDesc={stock.relation_desc}
|
||||
* fontSize="md"
|
||||
* titleColor="blue.700"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, BoxProps } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 关联描述数据类型
|
||||
* - 字符串格式:直接的描述文本
|
||||
* - 对象格式:包含多个句子的数组
|
||||
*/
|
||||
export type RelationDescType =
|
||||
| string
|
||||
| {
|
||||
data: Array<{
|
||||
query_part?: string;
|
||||
sentences?: string;
|
||||
}>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export interface RelationDescriptionProps {
|
||||
/** 原始关联描述数据(支持字符串或对象格式) */
|
||||
relationDesc: RelationDescType;
|
||||
|
||||
/** 字体大小,默认 'sm' */
|
||||
fontSize?: string;
|
||||
|
||||
/** 标题颜色,默认 'gray.700' */
|
||||
titleColor?: string;
|
||||
|
||||
/** 文本颜色,默认 'gray.600' */
|
||||
textColor?: string;
|
||||
|
||||
/** 行高,默认 '1.7' */
|
||||
lineHeight?: string;
|
||||
|
||||
/** 容器额外属性 */
|
||||
containerProps?: BoxProps;
|
||||
}
|
||||
|
||||
export const RelationDescription: React.FC<RelationDescriptionProps> = ({
|
||||
relationDesc,
|
||||
fontSize = 'sm',
|
||||
titleColor = 'gray.700',
|
||||
textColor = 'gray.600',
|
||||
lineHeight = '1.7',
|
||||
containerProps = {}
|
||||
}) => {
|
||||
// 处理关联描述(兼容对象和字符串格式)
|
||||
const processedDesc = useMemo(() => {
|
||||
if (!relationDesc) return null;
|
||||
|
||||
// 字符串格式:直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 对象格式:提取并拼接文本
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
return (
|
||||
relationDesc.data
|
||||
.map((item) => item.query_part || item.sentences || '')
|
||||
.filter((s) => s)
|
||||
.join(';') || null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [relationDesc]);
|
||||
|
||||
// 如果没有有效的描述内容,不渲染组件
|
||||
if (!processedDesc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
borderTop="1px solid"
|
||||
borderTopColor="gray.200"
|
||||
{...containerProps}
|
||||
>
|
||||
<Text
|
||||
fontSize={fontSize}
|
||||
fontWeight="bold"
|
||||
mb={2}
|
||||
color={titleColor}
|
||||
>
|
||||
关联描述:
|
||||
</Text>
|
||||
<Text
|
||||
fontSize={fontSize}
|
||||
color={textColor}
|
||||
lineHeight={lineHeight}
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{processedDesc}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
6
src/components/StockRelation/index.ts
Normal file
6
src/components/StockRelation/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* StockRelation 组件导出入口
|
||||
*/
|
||||
|
||||
export { RelationDescription } from './RelationDescription';
|
||||
export type { RelationDescriptionProps, RelationDescType } from './RelationDescription';
|
||||
2
src/components/Subscription/SubscriptionContentNew.d.ts
vendored
Normal file
2
src/components/Subscription/SubscriptionContentNew.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Type declarations for SubscriptionContentNew component
|
||||
export {};
|
||||
1446
src/components/Subscription/SubscriptionContentNew.tsx
Normal file
1446
src/components/Subscription/SubscriptionContentNew.tsx
Normal file
File diff suppressed because it is too large
Load Diff
306
src/constants/performanceThresholds.js
Normal file
306
src/constants/performanceThresholds.js
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 性能指标阈值配置
|
||||
* 基于 Google Web Vitals 标准和项目实际情况
|
||||
*
|
||||
* 评级标准:
|
||||
* - good: 绿色,性能优秀
|
||||
* - needs-improvement: 黄色,需要改进
|
||||
* - poor: 红色,性能较差
|
||||
*
|
||||
* @see https://web.dev/defining-core-web-vitals-thresholds/
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Web Vitals 官方阈值(Google 标准)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Largest Contentful Paint (LCP) - 最大内容绘制
|
||||
* 衡量加载性能,理想情况下应在 2.5 秒内完成
|
||||
*/
|
||||
export const LCP_THRESHOLDS = {
|
||||
good: 2500, // < 2.5s 为优秀
|
||||
needsImprovement: 4000, // 2.5s - 4s 需要改进
|
||||
// > 4s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* First Contentful Paint (FCP) - 首次内容绘制
|
||||
* 衡量首次渲染任何内容的速度
|
||||
*/
|
||||
export const FCP_THRESHOLDS = {
|
||||
good: 1800, // < 1.8s 为优秀
|
||||
needsImprovement: 3000, // 1.8s - 3s 需要改进
|
||||
// > 3s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* Cumulative Layout Shift (CLS) - 累积布局偏移
|
||||
* 衡量视觉稳定性(无单位,分数值)
|
||||
*/
|
||||
export const CLS_THRESHOLDS = {
|
||||
good: 0.1, // < 0.1 为优秀
|
||||
needsImprovement: 0.25, // 0.1 - 0.25 需要改进
|
||||
// > 0.25 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* First Input Delay (FID) - 首次输入延迟
|
||||
* 衡量交互性能
|
||||
*/
|
||||
export const FID_THRESHOLDS = {
|
||||
good: 100, // < 100ms 为优秀
|
||||
needsImprovement: 300, // 100ms - 300ms 需要改进
|
||||
// > 300ms 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* Time to First Byte (TTFB) - 首字节时间
|
||||
* 衡量服务器响应速度
|
||||
*/
|
||||
export const TTFB_THRESHOLDS = {
|
||||
good: 800, // < 0.8s 为优秀
|
||||
needsImprovement: 1800, // 0.8s - 1.8s 需要改进
|
||||
// > 1.8s 为较差
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 自定义指标阈值(项目特定)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Time to Interactive (TTI) - 首屏可交互时间
|
||||
* 自定义指标:从页面加载到用户可以交互的时间
|
||||
*/
|
||||
export const TTI_THRESHOLDS = {
|
||||
good: 3500, // < 3.5s 为优秀
|
||||
needsImprovement: 7300, // 3.5s - 7.3s 需要改进
|
||||
// > 7.3s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* 骨架屏展示时长阈值
|
||||
*/
|
||||
export const SKELETON_DURATION_THRESHOLDS = {
|
||||
good: 300, // < 0.3s 为优秀(骨架屏展示时间短)
|
||||
needsImprovement: 1000, // 0.3s - 1s 需要改进
|
||||
// > 1s 为较差(骨架屏展示太久)
|
||||
};
|
||||
|
||||
/**
|
||||
* API 响应时间阈值
|
||||
*/
|
||||
export const API_RESPONSE_TIME_THRESHOLDS = {
|
||||
good: 500, // < 500ms 为优秀
|
||||
needsImprovement: 1500, // 500ms - 1.5s 需要改进
|
||||
// > 1.5s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源加载时间阈值
|
||||
*/
|
||||
export const RESOURCE_LOAD_TIME_THRESHOLDS = {
|
||||
good: 2000, // < 2s 为优秀
|
||||
needsImprovement: 5000, // 2s - 5s 需要改进
|
||||
// > 5s 为较差
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle 大小阈值(KB)
|
||||
*/
|
||||
export const BUNDLE_SIZE_THRESHOLDS = {
|
||||
js: {
|
||||
good: 500, // < 500KB 为优秀
|
||||
needsImprovement: 1000, // 500KB - 1MB 需要改进
|
||||
// > 1MB 为较差
|
||||
},
|
||||
css: {
|
||||
good: 100, // < 100KB 为优秀
|
||||
needsImprovement: 200, // 100KB - 200KB 需要改进
|
||||
// > 200KB 为较差
|
||||
},
|
||||
image: {
|
||||
good: 1500, // < 1.5MB 为优秀
|
||||
needsImprovement: 3000, // 1.5MB - 3MB 需要改进
|
||||
// > 3MB 为较差
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 缓存命中率阈值(百分比)
|
||||
*/
|
||||
export const CACHE_HIT_RATE_THRESHOLDS = {
|
||||
good: 80, // > 80% 为优秀
|
||||
needsImprovement: 50, // 50% - 80% 需要改进
|
||||
// < 50% 为较差
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 综合阈值配置对象
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 所有性能指标的阈值配置(用于类型化访问)
|
||||
*/
|
||||
export const PERFORMANCE_THRESHOLDS = {
|
||||
LCP: LCP_THRESHOLDS,
|
||||
FCP: FCP_THRESHOLDS,
|
||||
CLS: CLS_THRESHOLDS,
|
||||
FID: FID_THRESHOLDS,
|
||||
TTFB: TTFB_THRESHOLDS,
|
||||
TTI: TTI_THRESHOLDS,
|
||||
SKELETON_DURATION: SKELETON_DURATION_THRESHOLDS,
|
||||
API_RESPONSE_TIME: API_RESPONSE_TIME_THRESHOLDS,
|
||||
RESOURCE_LOAD_TIME: RESOURCE_LOAD_TIME_THRESHOLDS,
|
||||
BUNDLE_SIZE: BUNDLE_SIZE_THRESHOLDS,
|
||||
CACHE_HIT_RATE: CACHE_HIT_RATE_THRESHOLDS,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 根据指标值和阈值计算评级
|
||||
* @param {number} value - 指标值
|
||||
* @param {Object} thresholds - 阈值配置对象 { good, needsImprovement }
|
||||
* @param {boolean} reverse - 是否反向评级(值越大越好,如缓存命中率)
|
||||
* @returns {'good' | 'needs-improvement' | 'poor'}
|
||||
*/
|
||||
export const calculateRating = (value, thresholds, reverse = false) => {
|
||||
if (!thresholds || typeof value !== 'number') {
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
const { good, needsImprovement } = thresholds;
|
||||
|
||||
if (reverse) {
|
||||
// 反向评级:值越大越好(如缓存命中率)
|
||||
if (value >= good) return 'good';
|
||||
if (value >= needsImprovement) return 'needs-improvement';
|
||||
return 'poor';
|
||||
} else {
|
||||
// 正常评级:值越小越好(如加载时间)
|
||||
if (value <= good) return 'good';
|
||||
if (value <= needsImprovement) return 'needs-improvement';
|
||||
return 'poor';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评级对应的颜色(Chakra UI 颜色方案)
|
||||
* @param {'good' | 'needs-improvement' | 'poor'} rating
|
||||
* @returns {string} Chakra UI 颜色名称
|
||||
*/
|
||||
export const getRatingColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good':
|
||||
return 'green';
|
||||
case 'needs-improvement':
|
||||
return 'yellow';
|
||||
case 'poor':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评级对应的控制台颜色代码
|
||||
* @param {'good' | 'needs-improvement' | 'poor'} rating
|
||||
* @returns {string} ANSI 颜色代码
|
||||
*/
|
||||
export const getRatingConsoleColor = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good':
|
||||
return '\x1b[32m'; // 绿色
|
||||
case 'needs-improvement':
|
||||
return '\x1b[33m'; // 黄色
|
||||
case 'poor':
|
||||
return '\x1b[31m'; // 红色
|
||||
default:
|
||||
return '\x1b[0m'; // 重置
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评级对应的图标
|
||||
* @param {'good' | 'needs-improvement' | 'poor'} rating
|
||||
* @returns {string} Emoji 图标
|
||||
*/
|
||||
export const getRatingIcon = (rating) => {
|
||||
switch (rating) {
|
||||
case 'good':
|
||||
return '✅';
|
||||
case 'needs-improvement':
|
||||
return '⚠️';
|
||||
case 'poor':
|
||||
return '❌';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化指标值(添加单位)
|
||||
* @param {string} metricName - 指标名称
|
||||
* @param {number} value - 指标值
|
||||
* @returns {string} 格式化后的字符串
|
||||
*/
|
||||
export const formatMetricValue = (metricName, value) => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
switch (metricName) {
|
||||
case 'LCP':
|
||||
case 'FCP':
|
||||
case 'FID':
|
||||
case 'TTFB':
|
||||
case 'TTI':
|
||||
case 'SKELETON_DURATION':
|
||||
case 'API_RESPONSE_TIME':
|
||||
case 'RESOURCE_LOAD_TIME':
|
||||
// 时间类指标:转换为秒或毫秒
|
||||
return value >= 1000
|
||||
? `${(value / 1000).toFixed(2)}s`
|
||||
: `${Math.round(value)}ms`;
|
||||
|
||||
case 'CLS':
|
||||
// CLS 是无单位的分数
|
||||
return value.toFixed(3);
|
||||
|
||||
case 'CACHE_HIT_RATE':
|
||||
// 百分比
|
||||
return `${value.toFixed(1)}%`;
|
||||
|
||||
default:
|
||||
// 默认保留两位小数
|
||||
return value.toFixed(2);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量计算所有 Web Vitals 指标的评级
|
||||
* @param {Object} metrics - 指标对象 { LCP: value, FCP: value, ... }
|
||||
* @returns {Object} 评级对象 { LCP: 'good', FCP: 'needs-improvement', ... }
|
||||
*/
|
||||
export const calculateAllRatings = (metrics) => {
|
||||
const ratings = {};
|
||||
|
||||
Object.entries(metrics).forEach(([metricName, value]) => {
|
||||
const thresholds = PERFORMANCE_THRESHOLDS[metricName];
|
||||
if (thresholds) {
|
||||
const isReverse = metricName === 'CACHE_HIT_RATE'; // 缓存命中率是反向评级
|
||||
ratings[metricName] = calculateRating(value, thresholds, isReverse);
|
||||
}
|
||||
});
|
||||
|
||||
return ratings;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default PERFORMANCE_THRESHOLDS;
|
||||
204
src/constants/tracking.js
Normal file
204
src/constants/tracking.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/constants/tracking.js
|
||||
// PostHog 事件追踪优先级配置
|
||||
|
||||
/**
|
||||
* 事件优先级枚举
|
||||
*
|
||||
* 用于决定事件的追踪时机,优化性能和用户体验。
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const EVENT_PRIORITY = {
|
||||
/**
|
||||
* 关键事件 - 立即发送,不可延迟
|
||||
* 示例:登录、注册、支付、订阅购买
|
||||
*/
|
||||
CRITICAL: 'critical',
|
||||
|
||||
/**
|
||||
* 高优先级事件 - 立即发送
|
||||
* 示例:详情打开、搜索提交、关注操作、分享操作
|
||||
*/
|
||||
HIGH: 'high',
|
||||
|
||||
/**
|
||||
* 普通优先级事件 - 空闲时发送
|
||||
* 示例:列表查看、筛选应用、排序变更
|
||||
*/
|
||||
NORMAL: 'normal',
|
||||
|
||||
/**
|
||||
* 低优先级事件 - 空闲时发送,可批量合并
|
||||
* 示例:鼠标移动、滚动事件、hover 事件
|
||||
*/
|
||||
LOW: 'low',
|
||||
};
|
||||
|
||||
/**
|
||||
* Community 页面(新闻催化分析)事件优先级映射
|
||||
*
|
||||
* 映射规则:
|
||||
* - CRITICAL: 无(Community 页面无关键业务操作)
|
||||
* - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转)
|
||||
* - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序)
|
||||
* - LOW: 暂未使用
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
export const COMMUNITY_EVENT_PRIORITIES = {
|
||||
// ==================== 普通优先级(空闲时追踪)====================
|
||||
|
||||
/**
|
||||
* 页面浏览事件 - NORMAL
|
||||
* 触发时机:用户进入 Community 页面
|
||||
* 延迟原因:页面加载时避免阻塞渲染
|
||||
*/
|
||||
'Community Page Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻列表查看 - NORMAL
|
||||
* 触发时机:新闻列表加载完成
|
||||
* 延迟原因:避免阻塞列表渲染
|
||||
*/
|
||||
'News List Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻筛选应用 - NORMAL
|
||||
* 触发时机:用户应用筛选条件(重要性、日期、行业)
|
||||
* 延迟原因:筛选操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Filter Applied': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻排序变更 - NORMAL
|
||||
* 触发时机:用户切换排序方式(最新、最热、收益率)
|
||||
* 延迟原因:排序操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Sorted': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻标签页点击 - NORMAL
|
||||
* 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线)
|
||||
* 延迟原因:标签切换高频,延迟追踪不影响用户体验
|
||||
*/
|
||||
'News Tab Clicked': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
// ==================== 高优先级(立即追踪)====================
|
||||
|
||||
/**
|
||||
* 新闻文章点击 - HIGH
|
||||
* 触发时机:用户点击新闻卡片
|
||||
* 立即追踪原因:关键交互操作,需要准确记录点击位置和时间
|
||||
*/
|
||||
'News Article Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 新闻详情打开 - HIGH
|
||||
* 触发时机:打开新闻详情弹窗或页面
|
||||
* 立即追踪原因:关键交互操作,需要准确记录查看时间
|
||||
*/
|
||||
'News Detail Opened': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索查询提交 - HIGH
|
||||
* 触发时机:用户提交搜索关键词
|
||||
* 立即追踪原因:用户明确操作,需要准确记录搜索意图
|
||||
*/
|
||||
'Search Query Submitted': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索无结果 - HIGH
|
||||
* 触发时机:搜索返回 0 个结果
|
||||
* 立即追踪原因:重要的用户体验指标,需要及时发现问题
|
||||
*/
|
||||
'Search No Results': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关股票点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关股票
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Stock Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关概念点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关概念
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Concept Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件关注操作 - HIGH
|
||||
* 触发时机:用户点击关注按钮
|
||||
* 立即追踪原因:关键业务操作,需要准确记录关注行为
|
||||
*/
|
||||
'Event Followed': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件取消关注 - HIGH
|
||||
* 触发时机:用户取消关注事件
|
||||
* 立即追踪原因:关键业务操作,需要准确记录取关原因
|
||||
*/
|
||||
'Event Unfollowed': EVENT_PRIORITY.HIGH,
|
||||
};
|
||||
|
||||
/**
|
||||
* requestIdleCallback 配置
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const IDLE_CALLBACK_CONFIG = {
|
||||
/**
|
||||
* 超时时间(毫秒)
|
||||
* 即使浏览器不空闲,也会在此时间后强制执行追踪
|
||||
*
|
||||
* 设置为 2000ms 的原因:
|
||||
* - 足够长:避免在用户快速操作时阻塞主线程
|
||||
* - 足够短:确保用户快速关闭页面前也能发送事件
|
||||
* - 平衡点:2 秒是用户注意力的典型持续时间
|
||||
*/
|
||||
timeout: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取事件优先级
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {string} 事件优先级(CRITICAL | HIGH | NORMAL | LOW)
|
||||
*/
|
||||
export const getEventPriority = (eventName) => {
|
||||
return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否需要立即追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否立即追踪
|
||||
*/
|
||||
export const shouldTrackImmediately = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否可以延迟追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否可以延迟追踪
|
||||
*/
|
||||
export const canTrackIdle = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW;
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
EVENT_PRIORITY,
|
||||
COMMUNITY_EVENT_PRIORITIES,
|
||||
IDLE_CALLBACK_CONFIG,
|
||||
getEventPriority,
|
||||
shouldTrackImmediately,
|
||||
canTrackIdle,
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
|
||||
if (prevUser && prevUser.id === data.user.id) {
|
||||
return prevUser;
|
||||
}
|
||||
|
||||
// ✅ 识别用户身份到 PostHog
|
||||
identifyUser(data.user.id, {
|
||||
email: data.user.email,
|
||||
username: data.user.username,
|
||||
subscription_tier: data.user.subscription_tier,
|
||||
role: data.user.role,
|
||||
registration_date: data.user.created_at
|
||||
});
|
||||
|
||||
return data.user;
|
||||
});
|
||||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||||
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Logged In' 或 'User Signed Up'
|
||||
// 属性名:login_method (不是 loginType)
|
||||
|
||||
// ⚡ 移除toast,让调用者处理UI反馈,避免并发更新冲突
|
||||
// toast({
|
||||
// title: "登录成功",
|
||||
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Signed Up'(不是 'user_registered')
|
||||
// 属性名:login_method(不是 method)
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
@@ -286,58 +308,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 邮箱注册
|
||||
const registerWithEmail = async (email, code, username, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch(`/api/auth/register/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code,
|
||||
username,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '注册失败');
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'registerWithEmail', error);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送手机验证码
|
||||
const sendSmsCode = async (phone) => {
|
||||
try {
|
||||
@@ -367,35 +337,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const sendEmailCode = async (email) => {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/send-email-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '发送失败');
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast
|
||||
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast
|
||||
logger.error('AuthContext', 'sendEmailCode', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// 登出方法
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: user?.id || null,
|
||||
session_duration_minutes: user?.session_start
|
||||
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
|
||||
: null,
|
||||
});
|
||||
|
||||
// ✅ 重置 PostHog 用户会话
|
||||
resetUser();
|
||||
|
||||
// 清除本地状态
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
|
||||
updateUser,
|
||||
login,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
sendEmailCode,
|
||||
logout,
|
||||
hasRole,
|
||||
refreshSession,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
selectRedirectUrl
|
||||
} from '../store/slices/authModalSlice';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 认证弹窗自定义 Hook
|
||||
@@ -49,27 +48,19 @@ export const useAuthModal = () => {
|
||||
const openAuthModal = useCallback((url = null, callback = null) => {
|
||||
onSuccessCallbackRef.current = callback;
|
||||
dispatch(openModal({ redirectUrl: url }));
|
||||
|
||||
logger.debug('useAuthModal', '打开认证弹窗', {
|
||||
redirectUrl: url || '无',
|
||||
hasCallback: !!callback
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 关闭认证弹窗
|
||||
* 如果用户未登录,跳转到首页
|
||||
* 如果用户未登录且不在首页,跳转到首页
|
||||
*/
|
||||
const closeAuthModal = useCallback(() => {
|
||||
dispatch(closeModal());
|
||||
onSuccessCallbackRef.current = null;
|
||||
|
||||
// ⭐ 如果用户关闭弹窗时仍未登录,跳转到首页
|
||||
if (!isAuthenticated) {
|
||||
// ⭐ 如果用户关闭弹窗时仍未登录,且当前不在首页,才跳转到首页
|
||||
if (!isAuthenticated && window.location.pathname !== '/home') {
|
||||
navigate('/home');
|
||||
logger.debug('useAuthModal', '未登录关闭弹窗,跳转到首页');
|
||||
} else {
|
||||
logger.debug('useAuthModal', '关闭认证弹窗');
|
||||
}
|
||||
}, [dispatch, isAuthenticated, navigate]);
|
||||
|
||||
@@ -82,14 +73,8 @@ export const useAuthModal = () => {
|
||||
if (onSuccessCallbackRef.current) {
|
||||
try {
|
||||
onSuccessCallbackRef.current(user);
|
||||
logger.debug('useAuthModal', '执行成功回调', {
|
||||
userId: user?.id
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('useAuthModal', 'handleLoginSuccess 回调执行失败', error, {
|
||||
userId: user?.id,
|
||||
hasCallback: !!onSuccessCallbackRef.current
|
||||
});
|
||||
console.error('useAuthModal: handleLoginSuccess 回调执行失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,10 +82,6 @@ export const useAuthModal = () => {
|
||||
// 移除了原有的 redirectUrl 跳转逻辑
|
||||
dispatch(closeModal());
|
||||
onSuccessCallbackRef.current = null;
|
||||
|
||||
logger.debug('useAuthModal', '登录成功,关闭弹窗', {
|
||||
userId: user?.id
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
|
||||
291
src/hooks/useFirstScreenMetrics.ts
Normal file
291
src/hooks/useFirstScreenMetrics.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 首屏性能指标收集 Hook
|
||||
* 整合 Web Vitals、资源加载、API 请求等指标
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const { metrics, isLoading, remeasure, exportMetrics } = useFirstScreenMetrics({
|
||||
* pageType: 'home',
|
||||
* enableConsoleLog: process.env.NODE_ENV === 'development'
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @module hooks/useFirstScreenMetrics
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals';
|
||||
import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor';
|
||||
import posthog from 'posthog-js';
|
||||
import type {
|
||||
FirstScreenMetrics,
|
||||
UseFirstScreenMetricsOptions,
|
||||
UseFirstScreenMetricsResult,
|
||||
FirstScreenInteractiveEventProperties,
|
||||
} from '@/types/metrics';
|
||||
|
||||
// ============================================================
|
||||
// Hook 实现
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 首屏性能指标收集 Hook
|
||||
*/
|
||||
export const useFirstScreenMetrics = (
|
||||
options: UseFirstScreenMetricsOptions
|
||||
): UseFirstScreenMetricsResult => {
|
||||
const {
|
||||
pageType,
|
||||
enableConsoleLog = process.env.NODE_ENV === 'development',
|
||||
trackToPostHog = process.env.NODE_ENV === 'production',
|
||||
customProperties = {},
|
||||
} = options;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<FirstScreenMetrics | null>(null);
|
||||
|
||||
// 使用 ref 记录页面加载开始时间
|
||||
const pageLoadStartRef = useRef<number>(performance.now());
|
||||
const skeletonStartRef = useRef<number>(performance.now());
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 收集所有首屏指标
|
||||
*/
|
||||
const collectAllMetrics = useCallback((): FirstScreenMetrics => {
|
||||
try {
|
||||
// 1. 初始化 Web Vitals 监控
|
||||
initWebVitalsTracking({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false, // Web Vitals 自己会上报,这里不重复
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 2. 获取 Web Vitals 指标(延迟获取,等待 LCP/FCP 等指标完成)
|
||||
const webVitalsCache = getCachedMetrics();
|
||||
const webVitals = Object.fromEntries(webVitalsCache.entries());
|
||||
|
||||
// 3. 收集资源加载统计
|
||||
const resourceStats = collectResourceStats({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false, // 避免重复上报
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 4. 收集 API 请求统计
|
||||
const apiStats = collectApiStats({
|
||||
enableConsoleLog,
|
||||
trackToPostHog: false,
|
||||
pageType,
|
||||
customProperties,
|
||||
});
|
||||
|
||||
// 5. 计算首屏可交互时间(TTI)
|
||||
const now = performance.now();
|
||||
const timeToInteractive = now - pageLoadStartRef.current;
|
||||
|
||||
// 6. 计算骨架屏展示时长
|
||||
const skeletonDisplayDuration = now - skeletonStartRef.current;
|
||||
|
||||
const firstScreenMetrics: FirstScreenMetrics = {
|
||||
webVitals,
|
||||
resourceStats,
|
||||
apiStats,
|
||||
timeToInteractive,
|
||||
skeletonDisplayDuration,
|
||||
measuredAt: Date.now(),
|
||||
};
|
||||
|
||||
return firstScreenMetrics;
|
||||
} catch (error) {
|
||||
console.error('Failed to collect first screen metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [pageType, enableConsoleLog, trackToPostHog, customProperties]);
|
||||
|
||||
/**
|
||||
* 上报首屏可交互事件到 PostHog
|
||||
*/
|
||||
const trackFirstScreenInteractive = useCallback(
|
||||
(metrics: FirstScreenMetrics) => {
|
||||
if (!trackToPostHog || process.env.NODE_ENV !== 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventProperties: FirstScreenInteractiveEventProperties = {
|
||||
tti_seconds: metrics.timeToInteractive / 1000,
|
||||
skeleton_duration_seconds: metrics.skeletonDisplayDuration / 1000,
|
||||
api_request_count: metrics.apiStats.totalRequests,
|
||||
api_avg_response_time_ms: metrics.apiStats.avgResponseTime,
|
||||
page_type: pageType,
|
||||
measured_at: metrics.measuredAt,
|
||||
...customProperties,
|
||||
};
|
||||
|
||||
posthog.capture('First Screen Interactive', eventProperties);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.log('📊 Tracked First Screen Interactive to PostHog', eventProperties);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to track first screen interactive:', error);
|
||||
}
|
||||
},
|
||||
[pageType, trackToPostHog, enableConsoleLog, customProperties]
|
||||
);
|
||||
|
||||
/**
|
||||
* 手动触发重新测量
|
||||
*/
|
||||
const remeasure = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 重置计时器
|
||||
pageLoadStartRef.current = performance.now();
|
||||
skeletonStartRef.current = performance.now();
|
||||
|
||||
// 延迟收集指标(等待 Web Vitals 完成)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const newMetrics = collectAllMetrics();
|
||||
setMetrics(newMetrics);
|
||||
trackFirstScreenInteractive(newMetrics);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.group('🎯 First Screen Metrics (Re-measured)');
|
||||
console.log('TTI:', `${(newMetrics.timeToInteractive / 1000).toFixed(2)}s`);
|
||||
console.log('Skeleton Duration:', `${(newMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`);
|
||||
console.log('API Requests:', newMetrics.apiStats.totalRequests);
|
||||
console.groupEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remeasure metrics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 1000); // 延迟 1 秒收集
|
||||
}, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]);
|
||||
|
||||
/**
|
||||
* 导出指标为 JSON
|
||||
*/
|
||||
const exportMetrics = useCallback((): string => {
|
||||
if (!metrics) {
|
||||
return JSON.stringify({ error: 'No metrics available' }, null, 2);
|
||||
}
|
||||
|
||||
return JSON.stringify(metrics, null, 2);
|
||||
}, [metrics]);
|
||||
|
||||
/**
|
||||
* 初始化:在组件挂载时自动收集指标
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 防止重复初始化
|
||||
if (hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasInitializedRef.current = true;
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.log('🚀 useFirstScreenMetrics initialized', { pageType });
|
||||
}
|
||||
|
||||
// 延迟收集指标,等待页面渲染完成和 Web Vitals 指标就绪
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
const firstScreenMetrics = collectAllMetrics();
|
||||
setMetrics(firstScreenMetrics);
|
||||
trackFirstScreenInteractive(firstScreenMetrics);
|
||||
|
||||
if (enableConsoleLog) {
|
||||
console.group('🎯 First Screen Metrics');
|
||||
console.log('━'.repeat(50));
|
||||
console.log(`✅ TTI: ${(firstScreenMetrics.timeToInteractive / 1000).toFixed(2)}s`);
|
||||
console.log(`✅ Skeleton Duration: ${(firstScreenMetrics.skeletonDisplayDuration / 1000).toFixed(2)}s`);
|
||||
console.log(`✅ API Requests: ${firstScreenMetrics.apiStats.totalRequests}`);
|
||||
console.log(`✅ API Avg Response: ${firstScreenMetrics.apiStats.avgResponseTime.toFixed(0)}ms`);
|
||||
console.log('━'.repeat(50));
|
||||
console.groupEnd();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to collect initial metrics:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 2000); // 延迟 2 秒收集(确保 LCP/FCP 等指标已触发)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []); // 空依赖数组,只在挂载时执行一次
|
||||
|
||||
// ============================================================
|
||||
// 返回值
|
||||
// ============================================================
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
metrics,
|
||||
remeasure,
|
||||
exportMetrics,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 辅助 Hook:标记骨架屏结束
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 标记骨架屏结束的 Hook
|
||||
* 用于在骨架屏消失时记录时间点
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const { markSkeletonEnd } = useSkeletonTiming();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (!loading) {
|
||||
* markSkeletonEnd();
|
||||
* }
|
||||
* }, [loading, markSkeletonEnd]);
|
||||
* ```
|
||||
*/
|
||||
export const useSkeletonTiming = () => {
|
||||
const skeletonStartRef = useRef<number>(performance.now());
|
||||
const skeletonEndRef = useRef<number | null>(null);
|
||||
|
||||
const markSkeletonEnd = useCallback(() => {
|
||||
if (!skeletonEndRef.current) {
|
||||
skeletonEndRef.current = performance.now();
|
||||
const duration = skeletonEndRef.current - skeletonStartRef.current;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSkeletonDuration = useCallback((): number | null => {
|
||||
if (skeletonEndRef.current) {
|
||||
return skeletonEndRef.current - skeletonStartRef.current;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
markSkeletonEnd,
|
||||
getSkeletonDuration,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 默认导出
|
||||
// ============================================================
|
||||
|
||||
export default useFirstScreenMetrics;
|
||||
@@ -2,9 +2,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
// 导入 Tailwind CSS 和 Brainwave 样式
|
||||
|
||||
// 导入 Brainwave 样式(空文件,保留以避免错误)
|
||||
import './styles/brainwave.css';
|
||||
import './styles/brainwave-colors.css';
|
||||
|
||||
// 导入 Select 下拉框颜色修复样式
|
||||
import './styles/select-fix.css';
|
||||
|
||||
// 导入 Bytedesk 客服系统 z-index 覆盖样式(必须在所有样式之后导入)
|
||||
import './styles/bytedesk-override.css';
|
||||
@@ -124,6 +127,7 @@ async function startApp() {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render the app with Router wrapper
|
||||
// ✅ StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router
|
||||
|
||||
@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<Box minH="100vh" display="flex" flexDirection="column">
|
||||
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column">
|
||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" w="100%" position="relative" overflow="hidden">
|
||||
<Box flex="1" pt="72px">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
@@ -47,11 +47,11 @@ export default function MainLayout() {
|
||||
<MemoizedAppFooter />
|
||||
|
||||
{/* 返回顶部按钮 - 滚动超过阈值时显示 */}
|
||||
<BackToTopButton
|
||||
{/* <BackToTopButton
|
||||
scrollThreshold={BACK_TO_TOP_CONFIG.scrollThreshold}
|
||||
position={BACK_TO_TOP_CONFIG.position}
|
||||
zIndex={BACK_TO_TOP_CONFIG.zIndex}
|
||||
/>
|
||||
/> */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,9 +225,19 @@ export const SPECIAL_EVENTS = {
|
||||
API_ERROR: 'API Error',
|
||||
NOT_FOUND_404: '404 Not Found',
|
||||
|
||||
// Performance
|
||||
// Performance - Web Vitals
|
||||
PAGE_LOAD_TIME: 'Page Load Time',
|
||||
API_RESPONSE_TIME: 'API Response Time',
|
||||
WEB_VITALS_LCP: 'Web Vitals - LCP', // Largest Contentful Paint
|
||||
WEB_VITALS_FCP: 'Web Vitals - FCP', // First Contentful Paint
|
||||
WEB_VITALS_CLS: 'Web Vitals - CLS', // Cumulative Layout Shift
|
||||
WEB_VITALS_FID: 'Web Vitals - FID', // First Input Delay
|
||||
WEB_VITALS_TTFB: 'Web Vitals - TTFB', // Time to First Byte
|
||||
|
||||
// Performance - First Screen
|
||||
FIRST_SCREEN_INTERACTIVE: 'First Screen Interactive', // 首屏可交互时间
|
||||
RESOURCE_LOAD_COMPLETE: 'Resource Load Complete', // 资源加载完成
|
||||
SKELETON_DISPLAYED: 'Skeleton Displayed', // 骨架屏展示
|
||||
|
||||
// Scroll depth
|
||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||
|
||||
@@ -10,6 +10,11 @@ let isInitialized = false;
|
||||
* Should be called once when the app starts
|
||||
*/
|
||||
export const initPostHog = () => {
|
||||
// 开发环境禁用 PostHog(减少日志噪音,仅生产环境启用)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复初始化
|
||||
if (isInitializing || isInitialized) {
|
||||
console.log('📊 PostHog 已初始化或正在初始化中,跳过重复调用');
|
||||
@@ -33,79 +38,68 @@ export const initPostHog = () => {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
// 📄 页面浏览追踪
|
||||
capture_pageview: true, // 自动捕获页面浏览事件
|
||||
capture_pageleave: true, // 自动捕获用户离开页面事件
|
||||
|
||||
// Session Recording Configuration
|
||||
// 📹 会话录制配置(Session Recording)
|
||||
session_recording: {
|
||||
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
|
||||
// Privacy: Mask sensitive input fields
|
||||
// 🔒 隐私保护:遮蔽敏感输入字段(录制时会自动打码)
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
'data-sensitive': true, // Custom attribute for sensitive fields
|
||||
password: true, // 遮蔽密码输入框
|
||||
email: true, // 遮蔽邮箱输入框
|
||||
phone: true, // 遮蔽手机号输入框
|
||||
'data-sensitive': true, // 遮蔽带有 data-sensitive 属性的字段(可在 HTML 中自定义)
|
||||
},
|
||||
|
||||
// Record canvas for charts/graphs
|
||||
// 📊 录制 Canvas 画布内容(用于记录图表、图形等可视化内容)
|
||||
recordCanvas: true,
|
||||
|
||||
// Network payload capture (useful for debugging API issues)
|
||||
// 🌐 网络请求数据捕获(用于调试 API 问题)
|
||||
networkPayloadCapture: {
|
||||
recordHeaders: true,
|
||||
recordBody: true,
|
||||
// Don't record sensitive endpoints
|
||||
recordHeaders: true, // 捕获请求头
|
||||
recordBody: true, // 捕获请求体
|
||||
// 🚫 敏感接口黑名单(不记录以下接口的数据)
|
||||
urlBlocklist: [
|
||||
'/api/auth/session',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/payment',
|
||||
'/api/auth/session', // 会话接口
|
||||
'/api/auth/login', // 登录接口
|
||||
'/api/auth/register', // 注册接口
|
||||
'/api/payment', // 支付接口
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Performance optimization
|
||||
batch_size: 10, // Send events in batches of 10
|
||||
batch_interval_ms: 3000, // Or every 3 seconds
|
||||
// ⚡ 性能优化:批量发送事件
|
||||
batch_size: 10, // 每 10 个事件发送一次
|
||||
batch_interval_ms: 3000, // 或每 3 秒发送一次(两个条件满足其一即发送)
|
||||
|
||||
// Privacy settings
|
||||
respect_dnt: true, // Respect Do Not Track browser setting
|
||||
persistence: 'localStorage+cookie', // Use both for reliability
|
||||
// 🔐 隐私设置
|
||||
respect_dnt: true, // 尊重浏览器的"禁止追踪"(Do Not Track)设置
|
||||
persistence: 'localStorage+cookie', // 同时使用 localStorage 和 Cookie 存储(提高可靠性)
|
||||
|
||||
// Feature flags (for A/B testing)
|
||||
// 🚩 功能开关(Feature Flags)- 用于 A/B 测试和灰度发布
|
||||
bootstrap: {
|
||||
featureFlags: {},
|
||||
featureFlags: {}, // 初始功能开关配置(可从服务端动态加载)
|
||||
},
|
||||
|
||||
// Autocapture settings
|
||||
// 🖱️ 自动捕获设置(Autocapture)
|
||||
autocapture: {
|
||||
// Automatically capture clicks on buttons, links, etc.
|
||||
// 自动捕获用户交互事件(点击、提交、修改等)
|
||||
dom_event_allowlist: ['click', 'submit', 'change'],
|
||||
|
||||
// Capture additional element properties
|
||||
capture_copied_text: false, // Don't capture copied text (privacy)
|
||||
},
|
||||
|
||||
// Development debugging
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
|
||||
}
|
||||
// 捕获额外的元素属性
|
||||
capture_copied_text: false, // 不捕获用户复制的文本(隐私保护)
|
||||
},
|
||||
});
|
||||
|
||||
isInitialized = true;
|
||||
console.log('📊 PostHog Analytics initialized');
|
||||
} catch (error) {
|
||||
// 忽略 AbortError(通常由热重载或快速导航引起)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('⚠️ PostHog 初始化请求被中断(可能是热重载),这是正常的');
|
||||
return;
|
||||
}
|
||||
console.error('❌ PostHog initialization failed:', error);
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
@@ -142,8 +136,6 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
last_login: new Date().toISOString(),
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
// console.log('👤 User identified:', userId); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
@@ -158,7 +150,6 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
// console.log('📝 User properties updated'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
@@ -176,15 +167,35 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📍 Event tracked:', eventName, properties);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 异步追踪事件(不阻塞主线程)
|
||||
* 使用 requestIdleCallback 在浏览器空闲时发送事件
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
export const trackEventAsync = (eventName, properties = {}) => {
|
||||
// 浏览器支持 requestIdleCallback 时使用(推荐)
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
trackEvent(eventName, properties);
|
||||
},
|
||||
{ timeout: 2000 } // 最多延迟 2 秒(防止永远不执行)
|
||||
);
|
||||
} else {
|
||||
// 降级方案:使用 setTimeout(兼容性更好)
|
||||
setTimeout(() => {
|
||||
trackEvent(eventName, properties);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
@@ -201,9 +212,6 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
...properties,
|
||||
});
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📄 Page view tracked:', pagePath);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
@@ -216,7 +224,6 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
// console.log('🔄 User session reset'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
@@ -228,7 +235,6 @@ export const resetUser = () => {
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
@@ -240,7 +246,6 @@ export const optOut = () => {
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
@@ -288,4 +293,105 @@ export const isFeatureEnabled = (flagKey) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Report performance metrics to PostHog
|
||||
* @param {object} metrics - Performance metrics object
|
||||
*/
|
||||
export const reportPerformanceMetrics = (metrics) => {
|
||||
// 仅在生产环境上报
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('📊 [开发环境] 性能指标(未上报到 PostHog):', metrics);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取浏览器和设备信息
|
||||
const browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
connection: navigator.connection?.effectiveType || 'unknown',
|
||||
deviceMemory: navigator.deviceMemory || 'unknown',
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 'unknown',
|
||||
};
|
||||
|
||||
// 上报性能指标
|
||||
posthog.capture('Performance Metrics', {
|
||||
// 网络指标
|
||||
dns_ms: metrics.dns,
|
||||
tcp_ms: metrics.tcp,
|
||||
ttfb_ms: metrics.ttfb,
|
||||
dom_load_ms: metrics.domLoad,
|
||||
resource_load_ms: metrics.resourceLoad,
|
||||
|
||||
// 渲染指标
|
||||
fp_ms: metrics.fp,
|
||||
fcp_ms: metrics.fcp,
|
||||
lcp_ms: metrics.lcp,
|
||||
|
||||
// React 指标
|
||||
react_init_ms: metrics.reactInit,
|
||||
auth_check_ms: metrics.authCheck,
|
||||
homepage_render_ms: metrics.homepageRender,
|
||||
|
||||
// 总计
|
||||
total_white_screen_ms: metrics.totalWhiteScreen,
|
||||
|
||||
// 性能评分
|
||||
performance_score: calculatePerformanceScore(metrics),
|
||||
|
||||
// 浏览器和设备信息
|
||||
...browserInfo,
|
||||
|
||||
// 时间戳
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log('✅ 性能指标已上报到 PostHog');
|
||||
} catch (error) {
|
||||
console.error('❌ PostHog 性能指标上报失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate overall performance score (0-100)
|
||||
* @param {object} metrics - Performance metrics
|
||||
* @returns {number} Score from 0 to 100
|
||||
*/
|
||||
const calculatePerformanceScore = (metrics) => {
|
||||
let score = 100;
|
||||
|
||||
// 白屏时间评分(权重 40%)
|
||||
if (metrics.totalWhiteScreen) {
|
||||
if (metrics.totalWhiteScreen > 3000) score -= 40;
|
||||
else if (metrics.totalWhiteScreen > 2000) score -= 20;
|
||||
else if (metrics.totalWhiteScreen > 1500) score -= 10;
|
||||
}
|
||||
|
||||
// TTFB 评分(权重 20%)
|
||||
if (metrics.ttfb) {
|
||||
if (metrics.ttfb > 1000) score -= 20;
|
||||
else if (metrics.ttfb > 500) score -= 10;
|
||||
}
|
||||
|
||||
// LCP 评分(权重 20%)
|
||||
if (metrics.lcp) {
|
||||
if (metrics.lcp > 4000) score -= 20;
|
||||
else if (metrics.lcp > 2500) score -= 10;
|
||||
}
|
||||
|
||||
// FCP 评分(权重 10%)
|
||||
if (metrics.fcp) {
|
||||
if (metrics.fcp > 3000) score -= 10;
|
||||
else if (metrics.fcp > 1800) score -= 5;
|
||||
}
|
||||
|
||||
// 认证检查评分(权重 10%)
|
||||
if (metrics.authCheck) {
|
||||
if (metrics.authCheck > 500) score -= 10;
|
||||
else if (metrics.authCheck > 300) score -= 5;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, score));
|
||||
};
|
||||
|
||||
export default posthog;
|
||||
|
||||
@@ -78,10 +78,16 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
|
||||
|
||||
const volume = Math.floor(Math.random() * 500000000 + 100000000);
|
||||
|
||||
// ✅ 修复:为分时图添加完整的 OHLC 字段
|
||||
const closePrice = parseFloat(price.toFixed(2));
|
||||
data.push({
|
||||
time: formatTime(current),
|
||||
price: parseFloat(price.toFixed(2)),
|
||||
close: parseFloat(price.toFixed(2)),
|
||||
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
|
||||
open: parseFloat((price * 0.9999).toFixed(2)), // ✅ 新增:开盘价(略低于收盘)
|
||||
high: parseFloat((price * 1.0002).toFixed(2)), // ✅ 新增:最高价(略高于收盘)
|
||||
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
|
||||
close: closePrice, // ✅ 保留:收盘价
|
||||
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
|
||||
volume: volume,
|
||||
prev_close: basePrice
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user