Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI
* feature_2025/251117_pref: (159 commits) feat: UI调整 feat: 将滚动事件移东到组件内部 feat: 去掉背景组件 feat: 拆分左侧栏、中间聊天区、右侧栏组件, Hooks 提取 feat: 简化主组件 index.js - 使用组件组合方式重构 feat: 创建 ChatArea 组件(含 MessageRenderer、ExecutionStepsDisplay 子组件) feat:拆分工具函数 feat: 拆分BackgroundEffects 背景渐变装饰层 feat: RightSidebar (~420 行) - 模型/工具/统计 Tab 面板(单文件) feat: LeftSidebar (~280 行) - 对话历史列表 + 用户信息卡片 feat: 修复bug pref:移除黑夜模式 feat: 修复警告 feat: 提取常量配置 feat: 修复ts报错 feat: StockChartModal.tsx 替换 KLine 实现 update pay function update pay function update pay function update pay function ...
This commit is contained in:
@@ -44,7 +44,10 @@
|
||||
**前端**
|
||||
- **核心框架**: React 18.3.1
|
||||
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
|
||||
- **UI 组件库**: Chakra UI 2.10.9(主要) + 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.
@@ -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处理
|
||||
|
||||
# 计算分页
|
||||
|
||||
@@ -76,7 +76,7 @@ module.exports = {
|
||||
},
|
||||
// 日期/日历库
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar)[\\/]/,
|
||||
name: 'calendar-lib',
|
||||
priority: 18,
|
||||
reuseExistingChunk: true,
|
||||
|
||||
44
package.json
44
package.json
@@ -5,7 +5,6 @@
|
||||
"homepage": "/",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@asseinfo/react-kanban": "^2.2.0",
|
||||
"@chakra-ui/icons": "^2.2.6",
|
||||
"@chakra-ui/react": "^2.10.9",
|
||||
"@chakra-ui/theme-tools": "^2.2.6",
|
||||
@@ -15,9 +14,10 @@
|
||||
"@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",
|
||||
@@ -25,6 +25,8 @@
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"@visx/scale": "^3.12.0",
|
||||
"@visx/text": "^3.12.0",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
"@tsparticles/slim": "^3.0.0",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"@visx/wordcloud": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
@@ -38,59 +40,55 @@
|
||||
"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",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.295.0",
|
||||
"react": "18.3.1",
|
||||
"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-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",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-is": "^19.0.0",
|
||||
"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-tsparticles": "^2.12.2",
|
||||
"react-to-print": "^3.0.3",
|
||||
"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",
|
||||
@@ -103,7 +101,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",
|
||||
@@ -120,12 +118,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",
|
||||
@@ -137,7 +134,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 |
@@ -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()
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
INFO Accepting connections at http://localhost:58321
|
||||
|
||||
INFO Gracefully shutting down. Please wait...
|
||||
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)"
|
||||
@@ -199,9 +199,6 @@ export default function HomeNavbar() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
|
||||
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
|
||||
|
||||
{/* 投资日历 Modal - 已移至 CalendarButton 组件内部 */}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -41,9 +41,6 @@ const NavbarActions = memo(({
|
||||
}) => {
|
||||
return (
|
||||
<HStack spacing={{ base: 2, md: 4 }}>
|
||||
{/* 主题切换按钮 */}
|
||||
<ThemeToggleButton />
|
||||
|
||||
{/* 显示加载状态 */}
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
|
||||
@@ -1,144 +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' }]
|
||||
},
|
||||
{
|
||||
path: '/data-browser',
|
||||
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' }]
|
||||
},
|
||||
{
|
||||
path: '/data-browser',
|
||||
label: '数据浏览器',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
'/data-browser': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{
|
||||
path: '/community',
|
||||
label: '事件中心',
|
||||
badges: [
|
||||
{ text: 'HOT', colorScheme: 'green' },
|
||||
{ text: 'NEW', colorScheme: 'red' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/concepts',
|
||||
label: '概念中心',
|
||||
badges: [{ text: 'NEW', colorScheme: 'red' }]
|
||||
},
|
||||
{
|
||||
path: '/data-browser',
|
||||
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;
|
||||
@@ -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;
|
||||
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,5 +1,5 @@
|
||||
// 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';
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -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>
|
||||
213
src/components/StockChart/StockChartModal.tsx
Normal file
213
src/components/StockChart/StockChartModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// 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 { RelationDescription } from '../StockRelation';
|
||||
import type { RelationDescType } from '../StockRelation';
|
||||
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;
|
||||
relation_desc?: RelationDescType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
{/* 关联描述 */}
|
||||
<RelationDescription relationDesc={stock?.relation_desc} />
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={4} pb={4}>
|
||||
<RiskDisclaimer text="" variant="default" sx={{}} />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChartModal;
|
||||
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,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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -21,11 +21,12 @@ import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
*
|
||||
* Provider 层级顺序 (从外到内):
|
||||
* 1. ReduxProvider - 状态管理层
|
||||
* 2. ChakraProvider - UI 框架层
|
||||
* 2. ChakraProvider - UI 框架层(主要)
|
||||
* 3. NotificationProvider - 通知系统
|
||||
* 4. AuthProvider - 认证系统
|
||||
*
|
||||
* 注意:
|
||||
* - HeroUI v3 不再需要 HeroUIProvider,样式通过 CSS 导入加载 (src/styles/heroui.css)
|
||||
* - AuthModal 已迁移到 Redux (authModalSlice + useAuthModal Hook)
|
||||
* - ErrorBoundary 在各 Layout 层实现,不在全局层,以实现精细化错误隔离
|
||||
* - MainLayout: PageTransitionWrapper 包含 ErrorBoundary (页面错误不影响导航栏)
|
||||
@@ -39,6 +40,13 @@ export function AppProviders({ children }) {
|
||||
<ReduxProvider store={store}>
|
||||
<ChakraProvider
|
||||
theme={theme}
|
||||
// ✅ 强制使用浅色主题(禁用深色模式)
|
||||
colorModeManager={{
|
||||
type: 'cookie',
|
||||
ssr: false,
|
||||
get: () => 'light', // 始终返回 'light'
|
||||
set: () => {}, // 禁止设置(忽略切换操作)
|
||||
}}
|
||||
toastOptions={{
|
||||
defaultOptions: {
|
||||
position: 'top',
|
||||
|
||||
@@ -11,7 +11,7 @@ export const lazyComponents = {
|
||||
// Home 模块
|
||||
HomePage: React.lazy(() => import('../views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
|
||||
ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')),
|
||||
ProfilePage: React.lazy(() => import('../views/Profile')),
|
||||
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
|
||||
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
|
||||
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
|
||||
@@ -42,6 +42,7 @@ export const lazyComponents = {
|
||||
// 价值论坛模块
|
||||
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||
PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')),
|
||||
|
||||
// 数据浏览器模块
|
||||
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
||||
|
||||
@@ -181,16 +181,26 @@ export const routeConfig = [
|
||||
description: '论坛帖子详细内容'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'value-forum/prediction/:topicId',
|
||||
component: lazyComponents.PredictionTopicDetail,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '预测话题详情',
|
||||
description: '预测市场话题详细信息'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Agent模块 ====================
|
||||
{
|
||||
path: 'agent-chat',
|
||||
component: lazyComponents.AgentChat,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
layout: 'main', // 使用主布局(带导航栏)
|
||||
meta: {
|
||||
title: '价小前投研',
|
||||
description: '北京价值前沿科技公司的AI投研聊天助手'
|
||||
title: '价小前投研 AI',
|
||||
description: '超炫酷的 AI 投研聊天助手 - 基于 Hero UI'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
492
src/services/creditSystemService.js
Normal file
492
src/services/creditSystemService.js
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 积分系统服务
|
||||
* 管理用户积分账户、交易、奖励等
|
||||
*/
|
||||
|
||||
// ==================== 常量配置 ====================
|
||||
|
||||
export const CREDIT_CONFIG = {
|
||||
INITIAL_BALANCE: 10000, // 初始积分
|
||||
MIN_BALANCE: 100, // 最低保留余额(破产保护)
|
||||
MAX_SINGLE_BET: 1000, // 单次下注上限
|
||||
DAILY_BONUS: 100, // 每日签到奖励
|
||||
CREATE_TOPIC_COST: 100, // 创建话题费用
|
||||
};
|
||||
|
||||
// 积分账户存储(生产环境应使用数据库)
|
||||
const userAccounts = new Map();
|
||||
|
||||
// 交易记录存储
|
||||
const transactions = [];
|
||||
|
||||
// ==================== 账户管理 ====================
|
||||
|
||||
/**
|
||||
* 获取用户账户
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {Object} 用户账户信息
|
||||
*/
|
||||
export const getUserAccount = (userId) => {
|
||||
if (!userAccounts.has(userId)) {
|
||||
// 首次访问,创建新账户
|
||||
const newAccount = {
|
||||
user_id: userId,
|
||||
balance: CREDIT_CONFIG.INITIAL_BALANCE,
|
||||
frozen: 0,
|
||||
total: CREDIT_CONFIG.INITIAL_BALANCE,
|
||||
total_earned: CREDIT_CONFIG.INITIAL_BALANCE,
|
||||
total_spent: 0,
|
||||
total_profit: 0,
|
||||
active_positions: [],
|
||||
stats: {
|
||||
total_topics: 0,
|
||||
win_count: 0,
|
||||
loss_count: 0,
|
||||
win_rate: 0,
|
||||
best_profit: 0,
|
||||
},
|
||||
last_daily_bonus: null,
|
||||
};
|
||||
userAccounts.set(userId, newAccount);
|
||||
}
|
||||
|
||||
return userAccounts.get(userId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新用户账户
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {Object} updates - 更新内容
|
||||
*/
|
||||
export const updateUserAccount = (userId, updates) => {
|
||||
const account = getUserAccount(userId);
|
||||
const updated = { ...account, ...updates };
|
||||
userAccounts.set(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户积分余额
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {number} 可用余额
|
||||
*/
|
||||
export const getBalance = (userId) => {
|
||||
const account = getUserAccount(userId);
|
||||
return account.balance;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否能支付
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} amount - 金额
|
||||
* @returns {boolean} 是否能支付
|
||||
*/
|
||||
export const canAfford = (userId, amount) => {
|
||||
const account = getUserAccount(userId);
|
||||
const afterBalance = account.balance - amount;
|
||||
|
||||
// 必须保留最低余额
|
||||
return afterBalance >= CREDIT_CONFIG.MIN_BALANCE;
|
||||
};
|
||||
|
||||
// ==================== 积分操作 ====================
|
||||
|
||||
/**
|
||||
* 增加积分
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} amount - 金额
|
||||
* @param {string} reason - 原因
|
||||
*/
|
||||
export const addCredits = (userId, amount, reason = '系统增加') => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const updated = {
|
||||
balance: account.balance + amount,
|
||||
total: account.total + amount,
|
||||
total_earned: account.total_earned + amount,
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
|
||||
// 记录交易
|
||||
logTransaction({
|
||||
user_id: userId,
|
||||
type: 'earn',
|
||||
amount,
|
||||
reason,
|
||||
balance_after: updated.balance,
|
||||
});
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 扣除积分
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} amount - 金额
|
||||
* @param {string} reason - 原因
|
||||
* @throws {Error} 如果余额不足
|
||||
*/
|
||||
export const deductCredits = (userId, amount, reason = '系统扣除') => {
|
||||
if (!canAfford(userId, amount)) {
|
||||
throw new Error(`积分不足,需要${amount}积分,但只有${getBalance(userId)}积分`);
|
||||
}
|
||||
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const updated = {
|
||||
balance: account.balance - amount,
|
||||
total_spent: account.total_spent + amount,
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
|
||||
// 记录交易
|
||||
logTransaction({
|
||||
user_id: userId,
|
||||
type: 'spend',
|
||||
amount: -amount,
|
||||
reason,
|
||||
balance_after: updated.balance,
|
||||
});
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 冻结积分(席位占用)
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} amount - 金额
|
||||
*/
|
||||
export const freezeCredits = (userId, amount) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
if (account.balance < amount) {
|
||||
throw new Error('可用余额不足');
|
||||
}
|
||||
|
||||
const updated = {
|
||||
balance: account.balance - amount,
|
||||
frozen: account.frozen + amount,
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解冻积分
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} amount - 金额
|
||||
*/
|
||||
export const unfreezeCredits = (userId, amount) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const updated = {
|
||||
balance: account.balance + amount,
|
||||
frozen: account.frozen - amount,
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
// ==================== 每日奖励 ====================
|
||||
|
||||
/**
|
||||
* 领取每日签到奖励
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {Object} 奖励信息
|
||||
*/
|
||||
export const claimDailyBonus = (userId) => {
|
||||
const account = getUserAccount(userId);
|
||||
const today = new Date().toDateString();
|
||||
|
||||
// 检查是否已领取
|
||||
if (account.last_daily_bonus === today) {
|
||||
return {
|
||||
success: false,
|
||||
message: '今日已领取',
|
||||
};
|
||||
}
|
||||
|
||||
// 发放奖励
|
||||
addCredits(userId, CREDIT_CONFIG.DAILY_BONUS, '每日签到');
|
||||
|
||||
// 更新领取时间
|
||||
updateUserAccount(userId, { last_daily_bonus: today });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
amount: CREDIT_CONFIG.DAILY_BONUS,
|
||||
message: `获得${CREDIT_CONFIG.DAILY_BONUS}积分`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查今天是否已签到
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasClaimedToday = (userId) => {
|
||||
const account = getUserAccount(userId);
|
||||
const today = new Date().toDateString();
|
||||
return account.last_daily_bonus === today;
|
||||
};
|
||||
|
||||
// ==================== 持仓管理 ====================
|
||||
|
||||
/**
|
||||
* 添加持仓
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {Object} position - 持仓信息
|
||||
*/
|
||||
export const addPosition = (userId, position) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const updated = {
|
||||
active_positions: [...account.active_positions, position],
|
||||
stats: {
|
||||
...account.stats,
|
||||
total_topics: account.stats.total_topics + 1,
|
||||
},
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除持仓
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {string} positionId - 持仓ID
|
||||
*/
|
||||
export const removePosition = (userId, positionId) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const updated = {
|
||||
active_positions: account.active_positions.filter((p) => p.id !== positionId),
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新持仓
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {string} positionId - 持仓ID
|
||||
* @param {Object} updates - 更新内容
|
||||
*/
|
||||
export const updatePosition = (userId, positionId, updates) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const updated = {
|
||||
active_positions: account.active_positions.map((p) =>
|
||||
p.id === positionId ? { ...p, ...updates } : p
|
||||
),
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户持仓
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {string} topicId - 话题ID(可选)
|
||||
* @returns {Array} 持仓列表
|
||||
*/
|
||||
export const getUserPositions = (userId, topicId = null) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
if (topicId) {
|
||||
return account.active_positions.filter((p) => p.topic_id === topicId);
|
||||
}
|
||||
|
||||
return account.active_positions;
|
||||
};
|
||||
|
||||
// ==================== 统计更新 ====================
|
||||
|
||||
/**
|
||||
* 记录胜利
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} profit - 盈利金额
|
||||
*/
|
||||
export const recordWin = (userId, profit) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const newWinCount = account.stats.win_count + 1;
|
||||
const totalGames = newWinCount + account.stats.loss_count;
|
||||
const winRate = (newWinCount / totalGames) * 100;
|
||||
|
||||
const updated = {
|
||||
total_profit: account.total_profit + profit,
|
||||
stats: {
|
||||
...account.stats,
|
||||
win_count: newWinCount,
|
||||
win_rate: winRate,
|
||||
best_profit: Math.max(account.stats.best_profit, profit),
|
||||
},
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录失败
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} loss - 损失金额
|
||||
*/
|
||||
export const recordLoss = (userId, loss) => {
|
||||
const account = getUserAccount(userId);
|
||||
|
||||
const newLossCount = account.stats.loss_count + 1;
|
||||
const totalGames = account.stats.win_count + newLossCount;
|
||||
const winRate = (account.stats.win_count / totalGames) * 100;
|
||||
|
||||
const updated = {
|
||||
total_profit: account.total_profit - loss,
|
||||
stats: {
|
||||
...account.stats,
|
||||
loss_count: newLossCount,
|
||||
win_rate: winRate,
|
||||
},
|
||||
};
|
||||
|
||||
updateUserAccount(userId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
// ==================== 排行榜 ====================
|
||||
|
||||
/**
|
||||
* 获取积分排行榜
|
||||
* @param {number} limit - 返回数量
|
||||
* @returns {Array} 排行榜数据
|
||||
*/
|
||||
export const getLeaderboard = (limit = 100) => {
|
||||
const accounts = Array.from(userAccounts.values());
|
||||
|
||||
return accounts
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, limit)
|
||||
.map((account, index) => ({
|
||||
rank: index + 1,
|
||||
user_id: account.user_id,
|
||||
total: account.total,
|
||||
total_profit: account.total_profit,
|
||||
win_rate: account.stats.win_rate,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户排名
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {number} 排名
|
||||
*/
|
||||
export const getUserRank = (userId) => {
|
||||
const leaderboard = getLeaderboard(1000);
|
||||
const index = leaderboard.findIndex((item) => item.user_id === userId);
|
||||
return index >= 0 ? index + 1 : -1;
|
||||
};
|
||||
|
||||
// ==================== 交易记录 ====================
|
||||
|
||||
/**
|
||||
* 记录交易
|
||||
* @param {Object} transaction - 交易信息
|
||||
*/
|
||||
const logTransaction = (transaction) => {
|
||||
const record = {
|
||||
id: `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
...transaction,
|
||||
};
|
||||
|
||||
transactions.push(record);
|
||||
return record;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户交易记录
|
||||
* @param {string} userId - 用户ID
|
||||
* @param {number} limit - 返回数量
|
||||
* @returns {Array} 交易记录
|
||||
*/
|
||||
export const getUserTransactions = (userId, limit = 50) => {
|
||||
return transactions
|
||||
.filter((tx) => tx.user_id === userId)
|
||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
// ==================== 批量操作 ====================
|
||||
|
||||
/**
|
||||
* 批量发放积分(如活动奖励)
|
||||
* @param {Array} recipients - [{user_id, amount, reason}]
|
||||
*/
|
||||
export const batchAddCredits = (recipients) => {
|
||||
const results = recipients.map(({ user_id, amount, reason }) => {
|
||||
try {
|
||||
return {
|
||||
user_id,
|
||||
success: true,
|
||||
account: addCredits(user_id, amount, reason),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
user_id,
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// ==================== 导出所有功能 ====================
|
||||
|
||||
export default {
|
||||
CREDIT_CONFIG,
|
||||
|
||||
// 账户管理
|
||||
getUserAccount,
|
||||
updateUserAccount,
|
||||
getBalance,
|
||||
canAfford,
|
||||
|
||||
// 积分操作
|
||||
addCredits,
|
||||
deductCredits,
|
||||
freezeCredits,
|
||||
unfreezeCredits,
|
||||
|
||||
// 每日奖励
|
||||
claimDailyBonus,
|
||||
hasClaimedToday,
|
||||
|
||||
// 持仓管理
|
||||
addPosition,
|
||||
removePosition,
|
||||
updatePosition,
|
||||
getUserPositions,
|
||||
|
||||
// 统计更新
|
||||
recordWin,
|
||||
recordLoss,
|
||||
|
||||
// 排行榜
|
||||
getLeaderboard,
|
||||
getUserRank,
|
||||
|
||||
// 交易记录
|
||||
getUserTransactions,
|
||||
|
||||
// 批量操作
|
||||
batchAddCredits,
|
||||
};
|
||||
325
src/services/predictionMarketService.api.js
Normal file
325
src/services/predictionMarketService.api.js
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 预测市场服务 - API 版本
|
||||
* 调用真实的后端 API,数据存储到 MySQL 数据库
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: getApiBase(),
|
||||
timeout: 10000,
|
||||
withCredentials: true, // 携带 Cookie(session)
|
||||
});
|
||||
|
||||
// ==================== 积分系统 API ====================
|
||||
|
||||
/**
|
||||
* 获取用户积分账户
|
||||
*/
|
||||
export const getUserAccount = async () => {
|
||||
try {
|
||||
const response = await api.get('/api/prediction/credit/account');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取积分账户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 领取每日奖励(100积分)
|
||||
*/
|
||||
export const claimDailyBonus = async () => {
|
||||
try {
|
||||
const response = await api.post('/api/prediction/credit/daily-bonus');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('领取每日奖励失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 预测话题 API ====================
|
||||
|
||||
/**
|
||||
* 创建预测话题
|
||||
* @param {Object} topicData - { title, description, category, deadline }
|
||||
*/
|
||||
export const createTopic = async (topicData) => {
|
||||
try {
|
||||
const response = await api.post('/api/prediction/topics', topicData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('创建预测话题失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取预测话题列表
|
||||
* @param {Object} params - { status, category, sort_by, page, per_page }
|
||||
*/
|
||||
export const getTopics = async (params = {}) => {
|
||||
try {
|
||||
const response = await api.get('/api/prediction/topics', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取话题列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取预测话题详情
|
||||
* @param {number} topicId
|
||||
*/
|
||||
export const getTopicDetail = async (topicId) => {
|
||||
try {
|
||||
const response = await api.get(`/api/prediction/topics/${topicId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取话题详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 结算预测话题(仅创建者可操作)
|
||||
* @param {number} topicId
|
||||
* @param {string} result - 'yes' | 'no' | 'draw'
|
||||
*/
|
||||
export const settleTopic = async (topicId, result) => {
|
||||
try {
|
||||
const response = await api.post(`/api/prediction/topics/${topicId}/settle`, { result });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('结算话题失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 交易 API ====================
|
||||
|
||||
/**
|
||||
* 买入预测份额
|
||||
* @param {Object} tradeData - { topic_id, direction, shares }
|
||||
*/
|
||||
export const buyShares = async (tradeData) => {
|
||||
try {
|
||||
const response = await api.post('/api/prediction/trade/buy', tradeData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('买入份额失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户持仓列表
|
||||
*/
|
||||
export const getUserPositions = async () => {
|
||||
try {
|
||||
const response = await api.get('/api/prediction/positions');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取持仓列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 评论 API ====================
|
||||
|
||||
/**
|
||||
* 发表话题评论
|
||||
* @param {number} topicId
|
||||
* @param {Object} commentData - { content, parent_id }
|
||||
*/
|
||||
export const createComment = async (topicId, commentData) => {
|
||||
try {
|
||||
const response = await api.post(`/api/prediction/topics/${topicId}/comments`, commentData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('发表评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取话题评论列表
|
||||
* @param {number} topicId
|
||||
* @param {Object} params - { page, per_page }
|
||||
*/
|
||||
export const getComments = async (topicId, params = {}) => {
|
||||
try {
|
||||
const response = await api.get(`/api/prediction/topics/${topicId}/comments`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取评论列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 点赞/取消点赞评论
|
||||
* @param {number} commentId
|
||||
*/
|
||||
export const likeComment = async (commentId) => {
|
||||
try {
|
||||
const response = await api.post(`/api/prediction/comments/${commentId}/like`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('点赞评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 观点IPO API ====================
|
||||
|
||||
/**
|
||||
* 投资评论(观点IPO)
|
||||
* @param {number} commentId - 评论ID
|
||||
* @param {number} shares - 投资份额
|
||||
*/
|
||||
export const investComment = async (commentId, shares) => {
|
||||
try {
|
||||
const response = await api.post(`/api/prediction/comments/${commentId}/invest`, { shares });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('投资评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取评论的投资列表
|
||||
* @param {number} commentId - 评论ID
|
||||
*/
|
||||
export const getCommentInvestments = async (commentId) => {
|
||||
try {
|
||||
const response = await api.get(`/api/prediction/comments/${commentId}/investments`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取投资列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证评论结果(仅创建者可操作)
|
||||
* @param {number} commentId - 评论ID
|
||||
* @param {string} result - 'correct' | 'incorrect'
|
||||
*/
|
||||
export const verifyComment = async (commentId, result) => {
|
||||
try {
|
||||
const response = await api.post(`/api/prediction/comments/${commentId}/verify`, { result });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('验证评论失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 工具函数(价格计算保留在前端,用于实时预览)====================
|
||||
|
||||
export const MARKET_CONFIG = {
|
||||
MAX_SEATS_PER_SIDE: 5,
|
||||
TAX_RATE: 0.02,
|
||||
MIN_PRICE: 50,
|
||||
MAX_PRICE: 950,
|
||||
BASE_PRICE: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算当前价格(简化版AMM)
|
||||
* @param {number} yesShares - Yes方总份额
|
||||
* @param {number} noShares - No方总份额
|
||||
* @returns {Object} {yes: price, no: price}
|
||||
*/
|
||||
export const calculatePrice = (yesShares, noShares) => {
|
||||
const totalShares = yesShares + noShares;
|
||||
|
||||
if (totalShares === 0) {
|
||||
return {
|
||||
yes: MARKET_CONFIG.BASE_PRICE,
|
||||
no: MARKET_CONFIG.BASE_PRICE,
|
||||
};
|
||||
}
|
||||
|
||||
const yesProb = yesShares / totalShares;
|
||||
const noProb = noShares / totalShares;
|
||||
|
||||
let yesPrice = yesProb * 1000;
|
||||
let noPrice = noProb * 1000;
|
||||
|
||||
yesPrice = Math.max(MARKET_CONFIG.MIN_PRICE, Math.min(MARKET_CONFIG.MAX_PRICE, yesPrice));
|
||||
noPrice = Math.max(MARKET_CONFIG.MIN_PRICE, Math.min(MARKET_CONFIG.MAX_PRICE, noPrice));
|
||||
|
||||
return { yes: Math.round(yesPrice), no: Math.round(noPrice) };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算交易税
|
||||
* @param {number} amount - 交易金额
|
||||
* @returns {number} 税费
|
||||
*/
|
||||
export const calculateTax = (amount) => {
|
||||
return Math.floor(amount * MARKET_CONFIG.TAX_RATE);
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算买入成本(用于前端预览)
|
||||
* @param {number} currentShares - 当前方总份额
|
||||
* @param {number} otherShares - 对手方总份额
|
||||
* @param {number} buyAmount - 买入数量
|
||||
* @returns {Object} { amount, tax, total }
|
||||
*/
|
||||
export const calculateBuyCost = (currentShares, otherShares, buyAmount) => {
|
||||
const currentPrice = calculatePrice(currentShares, otherShares);
|
||||
const afterShares = currentShares + buyAmount;
|
||||
const afterPrice = calculatePrice(afterShares, otherShares);
|
||||
|
||||
const avgPrice = (currentPrice.yes + afterPrice.yes) / 2;
|
||||
const amount = avgPrice * buyAmount;
|
||||
const tax = calculateTax(amount);
|
||||
const total = amount + tax;
|
||||
|
||||
return {
|
||||
amount: Math.round(amount),
|
||||
tax: Math.round(tax),
|
||||
total: Math.round(total),
|
||||
avgPrice: Math.round(avgPrice),
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
// 积分系统
|
||||
getUserAccount,
|
||||
claimDailyBonus,
|
||||
|
||||
// 话题管理
|
||||
createTopic,
|
||||
getTopics,
|
||||
getTopicDetail,
|
||||
settleTopic,
|
||||
|
||||
// 交易
|
||||
buyShares,
|
||||
getUserPositions,
|
||||
|
||||
// 评论
|
||||
createComment,
|
||||
getComments,
|
||||
likeComment,
|
||||
|
||||
// 观点IPO
|
||||
investComment,
|
||||
getCommentInvestments,
|
||||
verifyComment,
|
||||
|
||||
// 工具函数
|
||||
calculatePrice,
|
||||
calculateTax,
|
||||
calculateBuyCost,
|
||||
MARKET_CONFIG,
|
||||
};
|
||||
738
src/services/predictionMarketService.js
Normal file
738
src/services/predictionMarketService.js
Normal file
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* 预测市场服务
|
||||
* 核心功能:话题管理、席位交易、动态定价、领主系统、奖池分配
|
||||
*/
|
||||
|
||||
import {
|
||||
addCredits,
|
||||
deductCredits,
|
||||
canAfford,
|
||||
addPosition,
|
||||
removePosition,
|
||||
updatePosition,
|
||||
getUserPositions,
|
||||
recordWin,
|
||||
recordLoss,
|
||||
CREDIT_CONFIG,
|
||||
} from './creditSystemService';
|
||||
|
||||
// ==================== 常量配置 ====================
|
||||
|
||||
export const MARKET_CONFIG = {
|
||||
MAX_SEATS_PER_SIDE: 5, // 每个方向最多5个席位
|
||||
TAX_RATE: 0.02, // 交易税率 2%
|
||||
MIN_PRICE: 50, // 最低价格
|
||||
MAX_PRICE: 950, // 最高价格
|
||||
BASE_PRICE: 500, // 基础价格
|
||||
};
|
||||
|
||||
// 话题存储(生产环境应使用Elasticsearch)
|
||||
const topics = new Map();
|
||||
|
||||
// 席位存储
|
||||
const positions = new Map();
|
||||
|
||||
// 交易记录
|
||||
const trades = [];
|
||||
|
||||
// ==================== 动态定价算法 ====================
|
||||
|
||||
/**
|
||||
* 计算当前价格(简化版AMM)
|
||||
* @param {number} yesShares - Yes方总份额
|
||||
* @param {number} noShares - No方总份额
|
||||
* @returns {Object} {yes: price, no: price}
|
||||
*/
|
||||
export const calculatePrice = (yesShares, noShares) => {
|
||||
const totalShares = yesShares + noShares;
|
||||
|
||||
if (totalShares === 0) {
|
||||
// 初始状态,双方价格相同
|
||||
return {
|
||||
yes: MARKET_CONFIG.BASE_PRICE,
|
||||
no: MARKET_CONFIG.BASE_PRICE,
|
||||
};
|
||||
}
|
||||
|
||||
// 概率加权定价
|
||||
const yesProb = yesShares / totalShares;
|
||||
const noProb = noShares / totalShares;
|
||||
|
||||
// 价格 = 概率 * 1000,限制在 [MIN_PRICE, MAX_PRICE]
|
||||
const yesPrice = Math.max(
|
||||
MARKET_CONFIG.MIN_PRICE,
|
||||
Math.min(MARKET_CONFIG.MAX_PRICE, yesProb * 1000)
|
||||
);
|
||||
|
||||
const noPrice = Math.max(
|
||||
MARKET_CONFIG.MIN_PRICE,
|
||||
Math.min(MARKET_CONFIG.MAX_PRICE, noProb * 1000)
|
||||
);
|
||||
|
||||
return { yes: yesPrice, no: noPrice };
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算购买成本(含滑点)
|
||||
* @param {number} currentShares - 当前份额
|
||||
* @param {number} otherShares - 对手方份额
|
||||
* @param {number} buyAmount - 购买数量
|
||||
* @returns {number} 总成本
|
||||
*/
|
||||
export const calculateBuyCost = (currentShares, otherShares, buyAmount) => {
|
||||
let totalCost = 0;
|
||||
let tempShares = currentShares;
|
||||
|
||||
// 模拟逐步购买,累计成本
|
||||
for (let i = 0; i < buyAmount; i++) {
|
||||
tempShares += 1;
|
||||
const prices = calculatePrice(tempShares, otherShares);
|
||||
// 假设购买的是yes方
|
||||
totalCost += prices.yes;
|
||||
}
|
||||
|
||||
return totalCost;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算卖出收益(含滑点)
|
||||
* @param {number} currentShares - 当前份额
|
||||
* @param {number} otherShares - 对手方份额
|
||||
* @param {number} sellAmount - 卖出数量
|
||||
* @returns {number} 总收益
|
||||
*/
|
||||
export const calculateSellRevenue = (currentShares, otherShares, sellAmount) => {
|
||||
let totalRevenue = 0;
|
||||
let tempShares = currentShares;
|
||||
|
||||
// 模拟逐步卖出,累计收益
|
||||
for (let i = 0; i < sellAmount; i++) {
|
||||
const prices = calculatePrice(tempShares, otherShares);
|
||||
totalRevenue += prices.yes;
|
||||
tempShares -= 1;
|
||||
}
|
||||
|
||||
return totalRevenue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算交易税
|
||||
* @param {number} amount - 交易金额
|
||||
* @returns {number} 税费
|
||||
*/
|
||||
export const calculateTax = (amount) => {
|
||||
return Math.floor(amount * MARKET_CONFIG.TAX_RATE);
|
||||
};
|
||||
|
||||
// ==================== 话题管理 ====================
|
||||
|
||||
/**
|
||||
* 创建预测话题
|
||||
* @param {Object} topicData - 话题数据
|
||||
* @returns {Object} 创建的话题
|
||||
*/
|
||||
export const createTopic = (topicData) => {
|
||||
const { author_id, title, description, category, tags, deadline, settlement_date } = topicData;
|
||||
|
||||
// 扣除创建费用
|
||||
deductCredits(author_id, CREDIT_CONFIG.CREATE_TOPIC_COST, '创建预测话题');
|
||||
|
||||
const topic = {
|
||||
id: `topic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'prediction',
|
||||
|
||||
// 基础信息
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
tags: tags || [],
|
||||
|
||||
// 作者信息
|
||||
author_id,
|
||||
author_name: topicData.author_name,
|
||||
author_avatar: topicData.author_avatar,
|
||||
|
||||
// 时间管理
|
||||
created_at: new Date().toISOString(),
|
||||
deadline: deadline || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 默认7天
|
||||
settlement_date: settlement_date || new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'active',
|
||||
|
||||
// 预测选项
|
||||
options: [
|
||||
{ id: 'yes', label: '看涨 / Yes', color: '#48BB78' },
|
||||
{ id: 'no', label: '看跌 / No', color: '#F56565' },
|
||||
],
|
||||
|
||||
// 市场数据
|
||||
total_pool: CREDIT_CONFIG.CREATE_TOPIC_COST, // 创建费用进入奖池
|
||||
tax_rate: MARKET_CONFIG.TAX_RATE,
|
||||
|
||||
// 席位数据
|
||||
positions: {
|
||||
yes: {
|
||||
seats: [],
|
||||
total_shares: 0,
|
||||
current_price: MARKET_CONFIG.BASE_PRICE,
|
||||
lord_id: null,
|
||||
},
|
||||
no: {
|
||||
seats: [],
|
||||
total_shares: 0,
|
||||
current_price: MARKET_CONFIG.BASE_PRICE,
|
||||
lord_id: null,
|
||||
},
|
||||
},
|
||||
|
||||
// 交易统计
|
||||
stats: {
|
||||
total_volume: 0,
|
||||
total_transactions: 0,
|
||||
unique_traders: new Set(),
|
||||
},
|
||||
|
||||
// 结果
|
||||
settlement: {
|
||||
result: null,
|
||||
evidence: null,
|
||||
settled_by: null,
|
||||
settled_at: null,
|
||||
},
|
||||
};
|
||||
|
||||
topics.set(topic.id, topic);
|
||||
return topic;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取话题详情
|
||||
* @param {string} topicId - 话题ID
|
||||
* @returns {Object} 话题详情
|
||||
*/
|
||||
export const getTopic = (topicId) => {
|
||||
return topics.get(topicId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新话题
|
||||
* @param {string} topicId - 话题ID
|
||||
* @param {Object} updates - 更新内容
|
||||
*/
|
||||
export const updateTopic = (topicId, updates) => {
|
||||
const topic = getTopic(topicId);
|
||||
const updated = { ...topic, ...updates };
|
||||
topics.set(topicId, updated);
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有话题列表
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @returns {Array} 话题列表
|
||||
*/
|
||||
export const getTopics = (filters = {}) => {
|
||||
let topicList = Array.from(topics.values());
|
||||
|
||||
// 按状态筛选
|
||||
if (filters.status) {
|
||||
topicList = topicList.filter((t) => t.status === filters.status);
|
||||
}
|
||||
|
||||
// 按分类筛选
|
||||
if (filters.category) {
|
||||
topicList = topicList.filter((t) => t.category === filters.category);
|
||||
}
|
||||
|
||||
// 排序
|
||||
const sortBy = filters.sortBy || 'created_at';
|
||||
topicList.sort((a, b) => {
|
||||
if (sortBy === 'created_at') {
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
}
|
||||
if (sortBy === 'total_pool') {
|
||||
return b.total_pool - a.total_pool;
|
||||
}
|
||||
if (sortBy === 'total_volume') {
|
||||
return b.stats.total_volume - a.stats.total_volume;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return topicList;
|
||||
};
|
||||
|
||||
// ==================== 席位管理 ====================
|
||||
|
||||
/**
|
||||
* 创建席位
|
||||
* @param {Object} positionData - 席位数据
|
||||
* @returns {Object} 创建的席位
|
||||
*/
|
||||
const createPosition = (positionData) => {
|
||||
const position = {
|
||||
id: `pos_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
...positionData,
|
||||
acquired_at: new Date().toISOString(),
|
||||
last_traded_at: new Date().toISOString(),
|
||||
is_lord: false,
|
||||
};
|
||||
|
||||
positions.set(position.id, position);
|
||||
return position;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取席位
|
||||
* @param {string} positionId - 席位ID
|
||||
* @returns {Object} 席位信息
|
||||
*/
|
||||
export const getPosition = (positionId) => {
|
||||
return positions.get(positionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 分配席位(取份额最高的前5名)
|
||||
* @param {Array} allPositions - 所有持仓
|
||||
* @returns {Array} 席位列表
|
||||
*/
|
||||
const allocateSeats = (allPositions) => {
|
||||
// 按份额排序
|
||||
const sorted = [...allPositions].sort((a, b) => b.shares - a.shares);
|
||||
|
||||
// 取前5名
|
||||
return sorted.slice(0, MARKET_CONFIG.MAX_SEATS_PER_SIDE);
|
||||
};
|
||||
|
||||
/**
|
||||
* 确定领主(份额最多的人)
|
||||
* @param {Array} seats - 席位列表
|
||||
* @returns {string|null} 领主用户ID
|
||||
*/
|
||||
const determineLord = (seats) => {
|
||||
if (seats.length === 0) return null;
|
||||
|
||||
const lord = seats.reduce((max, seat) => (seat.shares > max.shares ? seat : max));
|
||||
|
||||
return lord.holder_id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新领主标识
|
||||
* @param {string} topicId - 话题ID
|
||||
* @param {string} optionId - 选项ID
|
||||
*/
|
||||
const updateLordStatus = (topicId, optionId) => {
|
||||
const topic = getTopic(topicId);
|
||||
const sideData = topic.positions[optionId];
|
||||
|
||||
// 重新分配席位
|
||||
const allPositions = Array.from(positions.values()).filter(
|
||||
(p) => p.topic_id === topicId && p.option_id === optionId
|
||||
);
|
||||
|
||||
const seats = allocateSeats(allPositions);
|
||||
const lordId = determineLord(seats);
|
||||
|
||||
// 更新所有席位的领主标识
|
||||
allPositions.forEach((position) => {
|
||||
const isLord = position.holder_id === lordId;
|
||||
positions.set(position.id, { ...position, is_lord: isLord });
|
||||
});
|
||||
|
||||
// 更新话题数据
|
||||
updateTopic(topicId, {
|
||||
positions: {
|
||||
...topic.positions,
|
||||
[optionId]: {
|
||||
...sideData,
|
||||
seats,
|
||||
lord_id: lordId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return lordId;
|
||||
};
|
||||
|
||||
// ==================== 交易执行 ====================
|
||||
|
||||
/**
|
||||
* 购买席位
|
||||
* @param {Object} tradeData - 交易数据
|
||||
* @returns {Object} 交易结果
|
||||
*/
|
||||
export const buyPosition = (tradeData) => {
|
||||
const { user_id, user_name, user_avatar, topic_id, option_id, shares } = tradeData;
|
||||
|
||||
// 验证
|
||||
const topic = getTopic(topic_id);
|
||||
if (!topic) throw new Error('话题不存在');
|
||||
if (topic.status !== 'active') throw new Error('话题已关闭交易');
|
||||
if (topic.author_id === user_id) throw new Error('不能参与自己发起的话题');
|
||||
|
||||
// 检查购买上限
|
||||
if (shares * MARKET_CONFIG.BASE_PRICE > CREDIT_CONFIG.MAX_SINGLE_BET) {
|
||||
throw new Error(`单次购买上限为${CREDIT_CONFIG.MAX_SINGLE_BET}积分`);
|
||||
}
|
||||
|
||||
// 获取当前市场数据
|
||||
const sideData = topic.positions[option_id];
|
||||
const otherOptionId = option_id === 'yes' ? 'no' : 'yes';
|
||||
const otherSideData = topic.positions[otherOptionId];
|
||||
|
||||
// 计算成本
|
||||
const cost = calculateBuyCost(sideData.total_shares, otherSideData.total_shares, shares);
|
||||
const tax = calculateTax(cost);
|
||||
const totalCost = cost + tax;
|
||||
|
||||
// 检查余额
|
||||
if (!canAfford(user_id, totalCost)) {
|
||||
throw new Error(`积分不足,需要${totalCost}积分`);
|
||||
}
|
||||
|
||||
// 扣除积分
|
||||
deductCredits(user_id, totalCost, `购买预测席位 - ${topic.title}`);
|
||||
|
||||
// 税费进入奖池
|
||||
updateTopic(topic_id, {
|
||||
total_pool: topic.total_pool + tax,
|
||||
stats: {
|
||||
...topic.stats,
|
||||
total_volume: topic.stats.total_volume + totalCost,
|
||||
total_transactions: topic.stats.total_transactions + 1,
|
||||
unique_traders: topic.stats.unique_traders.add(user_id),
|
||||
},
|
||||
});
|
||||
|
||||
// 查找用户是否已有该选项的席位
|
||||
let userPosition = Array.from(positions.values()).find(
|
||||
(p) => p.topic_id === topic_id && p.option_id === option_id && p.holder_id === user_id
|
||||
);
|
||||
|
||||
if (userPosition) {
|
||||
// 更新现有席位
|
||||
const newShares = userPosition.shares + shares;
|
||||
const newAvgCost = (userPosition.avg_cost * userPosition.shares + cost) / newShares;
|
||||
|
||||
positions.set(userPosition.id, {
|
||||
...userPosition,
|
||||
shares: newShares,
|
||||
avg_cost: newAvgCost,
|
||||
last_traded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 更新用户账户持仓
|
||||
updatePosition(user_id, userPosition.id, {
|
||||
shares: newShares,
|
||||
avg_cost: newAvgCost,
|
||||
});
|
||||
} else {
|
||||
// 创建新席位
|
||||
const newPosition = createPosition({
|
||||
topic_id,
|
||||
option_id,
|
||||
holder_id: user_id,
|
||||
holder_name: user_name,
|
||||
holder_avatar: user_avatar,
|
||||
shares,
|
||||
avg_cost: cost / shares,
|
||||
current_value: cost,
|
||||
unrealized_pnl: 0,
|
||||
});
|
||||
|
||||
// 添加到用户账户
|
||||
addPosition(user_id, {
|
||||
id: newPosition.id,
|
||||
topic_id,
|
||||
option_id,
|
||||
shares,
|
||||
avg_cost: cost / shares,
|
||||
});
|
||||
|
||||
userPosition = newPosition;
|
||||
}
|
||||
|
||||
// 更新话题席位数据
|
||||
updateTopic(topic_id, {
|
||||
positions: {
|
||||
...topic.positions,
|
||||
[option_id]: {
|
||||
...sideData,
|
||||
total_shares: sideData.total_shares + shares,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新价格
|
||||
const newPrices = calculatePrice(
|
||||
topic.positions[option_id].total_shares + shares,
|
||||
topic.positions[otherOptionId].total_shares
|
||||
);
|
||||
|
||||
updateTopic(topic_id, {
|
||||
positions: {
|
||||
...topic.positions,
|
||||
yes: { ...topic.positions.yes, current_price: newPrices.yes },
|
||||
no: { ...topic.positions.no, current_price: newPrices.no },
|
||||
},
|
||||
});
|
||||
|
||||
// 更新领主状态
|
||||
const newLordId = updateLordStatus(topic_id, option_id);
|
||||
|
||||
// 记录交易
|
||||
const trade = {
|
||||
id: `trade_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
topic_id,
|
||||
option_id,
|
||||
buyer_id: user_id,
|
||||
seller_id: null,
|
||||
type: 'buy',
|
||||
shares,
|
||||
price: cost / shares,
|
||||
total_cost: totalCost,
|
||||
tax,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
trades.push(trade);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
position: userPosition,
|
||||
trade,
|
||||
new_lord_id: newLordId,
|
||||
current_price: newPrices[option_id],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 卖出席位
|
||||
* @param {Object} tradeData - 交易数据
|
||||
* @returns {Object} 交易结果
|
||||
*/
|
||||
export const sellPosition = (tradeData) => {
|
||||
const { user_id, topic_id, option_id, shares } = tradeData;
|
||||
|
||||
// 验证
|
||||
const topic = getTopic(topic_id);
|
||||
if (!topic) throw new Error('话题不存在');
|
||||
if (topic.status !== 'active') throw new Error('话题已关闭交易');
|
||||
|
||||
// 查找用户席位
|
||||
const userPosition = Array.from(positions.values()).find(
|
||||
(p) => p.topic_id === topic_id && p.option_id === option_id && p.holder_id === user_id
|
||||
);
|
||||
|
||||
if (!userPosition) throw new Error('未持有该席位');
|
||||
if (userPosition.shares < shares) throw new Error('持有份额不足');
|
||||
|
||||
// 获取当前市场数据
|
||||
const sideData = topic.positions[option_id];
|
||||
const otherOptionId = option_id === 'yes' ? 'no' : 'yes';
|
||||
const otherSideData = topic.positions[otherOptionId];
|
||||
|
||||
// 计算收益
|
||||
const revenue = calculateSellRevenue(sideData.total_shares, otherSideData.total_shares, shares);
|
||||
const tax = calculateTax(revenue);
|
||||
const netRevenue = revenue - tax;
|
||||
|
||||
// 返还积分
|
||||
addCredits(user_id, netRevenue, `卖出预测席位 - ${topic.title}`);
|
||||
|
||||
// 税费进入奖池
|
||||
updateTopic(topic_id, {
|
||||
total_pool: topic.total_pool + tax,
|
||||
stats: {
|
||||
...topic.stats,
|
||||
total_volume: topic.stats.total_volume + revenue,
|
||||
total_transactions: topic.stats.total_transactions + 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 更新席位
|
||||
const newShares = userPosition.shares - shares;
|
||||
|
||||
if (newShares === 0) {
|
||||
// 完全卖出,删除席位
|
||||
positions.delete(userPosition.id);
|
||||
removePosition(user_id, userPosition.id);
|
||||
} else {
|
||||
// 部分卖出,更新份额
|
||||
positions.set(userPosition.id, {
|
||||
...userPosition,
|
||||
shares: newShares,
|
||||
last_traded_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
updatePosition(user_id, userPosition.id, { shares: newShares });
|
||||
}
|
||||
|
||||
// 更新话题席位数据
|
||||
updateTopic(topic_id, {
|
||||
positions: {
|
||||
...topic.positions,
|
||||
[option_id]: {
|
||||
...sideData,
|
||||
total_shares: sideData.total_shares - shares,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新价格
|
||||
const newPrices = calculatePrice(
|
||||
topic.positions[option_id].total_shares - shares,
|
||||
topic.positions[otherOptionId].total_shares
|
||||
);
|
||||
|
||||
updateTopic(topic_id, {
|
||||
positions: {
|
||||
...topic.positions,
|
||||
yes: { ...topic.positions.yes, current_price: newPrices.yes },
|
||||
no: { ...topic.positions.no, current_price: newPrices.no },
|
||||
},
|
||||
});
|
||||
|
||||
// 更新领主状态
|
||||
const newLordId = updateLordStatus(topic_id, option_id);
|
||||
|
||||
// 记录交易
|
||||
const trade = {
|
||||
id: `trade_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
topic_id,
|
||||
option_id,
|
||||
buyer_id: null,
|
||||
seller_id: user_id,
|
||||
type: 'sell',
|
||||
shares,
|
||||
price: revenue / shares,
|
||||
total_cost: netRevenue,
|
||||
tax,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
trades.push(trade);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
trade,
|
||||
new_lord_id: newLordId,
|
||||
current_price: newPrices[option_id],
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 结算 ====================
|
||||
|
||||
/**
|
||||
* 结算话题
|
||||
* @param {string} topicId - 话题ID
|
||||
* @param {string} result - 结果 'yes' | 'no'
|
||||
* @param {string} evidence - 证据说明
|
||||
* @param {string} settledBy - 裁决者ID
|
||||
* @returns {Object} 结算结果
|
||||
*/
|
||||
export const settleTopic = (topicId, result, evidence, settledBy) => {
|
||||
const topic = getTopic(topicId);
|
||||
|
||||
if (!topic) throw new Error('话题不存在');
|
||||
if (topic.status === 'settled') throw new Error('话题已结算');
|
||||
|
||||
// 只有作者可以结算
|
||||
if (topic.author_id !== settledBy) throw new Error('无权结算');
|
||||
|
||||
// 获取获胜方和失败方
|
||||
const winningOption = result;
|
||||
const losingOption = result === 'yes' ? 'no' : 'yes';
|
||||
|
||||
const winners = Array.from(positions.values()).filter(
|
||||
(p) => p.topic_id === topicId && p.option_id === winningOption
|
||||
);
|
||||
|
||||
const losers = Array.from(positions.values()).filter(
|
||||
(p) => p.topic_id === topicId && p.option_id === losingOption
|
||||
);
|
||||
|
||||
// 分配奖池
|
||||
if (winners.length === 0) {
|
||||
// 无人获胜,奖池返还给作者
|
||||
addCredits(topic.author_id, topic.total_pool, '话题奖池返还');
|
||||
} else {
|
||||
// 计算获胜方总份额
|
||||
const totalWinningShares = winners.reduce((sum, p) => sum + p.shares, 0);
|
||||
|
||||
// 按份额分配
|
||||
winners.forEach((position) => {
|
||||
const share = position.shares / totalWinningShares;
|
||||
const reward = Math.floor(topic.total_pool * share);
|
||||
|
||||
// 返还本金 + 奖池分成
|
||||
const refund = Math.floor(position.avg_cost * position.shares);
|
||||
const total = refund + reward;
|
||||
|
||||
addCredits(position.holder_id, total, `预测获胜 - ${topic.title}`);
|
||||
|
||||
// 记录胜利
|
||||
recordWin(position.holder_id, reward);
|
||||
|
||||
// 删除席位
|
||||
positions.delete(position.id);
|
||||
removePosition(position.holder_id, position.id);
|
||||
});
|
||||
}
|
||||
|
||||
// 失败方损失本金
|
||||
losers.forEach((position) => {
|
||||
const loss = Math.floor(position.avg_cost * position.shares);
|
||||
|
||||
// 记录失败
|
||||
recordLoss(position.holder_id, loss);
|
||||
|
||||
// 删除席位
|
||||
positions.delete(position.id);
|
||||
removePosition(position.holder_id, position.id);
|
||||
});
|
||||
|
||||
// 更新话题状态
|
||||
updateTopic(topicId, {
|
||||
status: 'settled',
|
||||
settlement: {
|
||||
result,
|
||||
evidence,
|
||||
settled_by: settledBy,
|
||||
settled_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
winners_count: winners.length,
|
||||
losers_count: losers.length,
|
||||
total_distributed: topic.total_pool,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 数据导出 ====================
|
||||
|
||||
export default {
|
||||
MARKET_CONFIG,
|
||||
|
||||
// 定价算法
|
||||
calculatePrice,
|
||||
calculateBuyCost,
|
||||
calculateSellRevenue,
|
||||
calculateTax,
|
||||
|
||||
// 话题管理
|
||||
createTopic,
|
||||
getTopic,
|
||||
updateTopic,
|
||||
getTopics,
|
||||
|
||||
// 席位管理
|
||||
getPosition,
|
||||
|
||||
// 交易
|
||||
buyPosition,
|
||||
sellPosition,
|
||||
|
||||
// 结算
|
||||
settleTopic,
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
/* Brainwave 色彩变量定义 */
|
||||
:root {
|
||||
/* Brainwave 中性色系 */
|
||||
--color-n-1: #FFFFFF;
|
||||
--color-n-2: #CAC6DD;
|
||||
--color-n-3: #ADA8C3;
|
||||
--color-n-4: #757185;
|
||||
--color-n-5: #3F3A52;
|
||||
--color-n-6: #252134;
|
||||
--color-n-7: #15131D;
|
||||
--color-n-8: #0E0C15;
|
||||
|
||||
/* Brainwave 主题色 */
|
||||
--color-1: #AC6AFF;
|
||||
--color-2: #FFC876;
|
||||
--color-3: #FF776F;
|
||||
--color-4: #7ADB78;
|
||||
--color-5: #858DFF;
|
||||
--color-6: #FF98E2;
|
||||
|
||||
/* 描边色 */
|
||||
--stroke-1: #26242C;
|
||||
}
|
||||
|
||||
/* CSS类名映射到变量 */
|
||||
.bg-n-8 { background-color: var(--color-n-8) !important; }
|
||||
.bg-n-7 { background-color: var(--color-n-7) !important; }
|
||||
.bg-n-6 { background-color: var(--color-n-6) !important; }
|
||||
|
||||
.text-n-1 { color: var(--color-n-1) !important; }
|
||||
.text-n-2 { color: var(--color-n-2) !important; }
|
||||
.text-n-3 { color: var(--color-n-3) !important; }
|
||||
.text-n-4 { color: var(--color-n-4) !important; }
|
||||
|
||||
.border-n-6 { border-color: var(--color-n-6) !important; }
|
||||
.border-n-1\/10 { border-color: rgba(255, 255, 255, 0.1) !important; }
|
||||
.border-n-2\/5 { border-color: rgba(202, 198, 221, 0.05) !important; }
|
||||
.border-n-2\/10 { border-color: rgba(202, 198, 221, 0.1) !important; }
|
||||
|
||||
.bg-stroke-1 { background-color: var(--stroke-1) !important; }
|
||||
|
||||
/* 渐变背景 */
|
||||
.bg-conic-gradient {
|
||||
background: conic-gradient(from 225deg, #FFC876, #79FFF7, #9F53FF, #FF98E2, #FFC876) !important;
|
||||
}
|
||||
|
||||
.bg-gradient-to-br {
|
||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important;
|
||||
}
|
||||
@@ -1,35 +1,12 @@
|
||||
/* Tailwind CSS 入口文件 */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
}
|
||||
|
||||
/* styles for splide carousel */
|
||||
@layer components {
|
||||
.splide-custom .splide__arrow {
|
||||
@apply relative top-0 left-0 right-0 flex items-center justify-center w-12 h-12 bg-transparent border border-solid border-n-4/50 rounded-full transform-none transition-colors hover:border-n-3;
|
||||
}
|
||||
|
||||
.splide-custom .splide__arrow:hover svg {
|
||||
@apply fill-n-1;
|
||||
}
|
||||
|
||||
.splide-custom .splide__arrow svg {
|
||||
@apply w-4 h-4 fill-n-4 transform-none transition-colors;
|
||||
}
|
||||
|
||||
.splide-visible .splide__track {
|
||||
@apply overflow-visible;
|
||||
}
|
||||
|
||||
.splide-pricing .splide__list {
|
||||
@apply lg:grid !important;
|
||||
@apply lg:grid-cols-3 lg:gap-4;
|
||||
}
|
||||
|
||||
.splide-benefits .splide__list {
|
||||
@apply md:grid !important;
|
||||
@apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem];
|
||||
}
|
||||
/* 自定义工具类 */
|
||||
@layer utilities {
|
||||
/* 毛玻璃效果 */
|
||||
.backdrop-blur-xl {
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,16 @@
|
||||
[class*="bytedesk"],
|
||||
[id*="bytedesk"],
|
||||
[class*="BytedeskWeb"] {
|
||||
position: fixed !important;
|
||||
z-index: 999999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Bytedesk iframe - 聊天窗口 */
|
||||
iframe[src*="bytedesk"],
|
||||
iframe[src*="/chat/"],
|
||||
iframe[src*="/visitor/"] {
|
||||
position: fixed !important;
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
|
||||
|
||||
89
src/styles/select-fix.css
Normal file
89
src/styles/select-fix.css
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 修复 Chakra UI Select 组件的下拉选项颜色问题
|
||||
* 黑金主题下,下拉选项需要深色背景和白色文字
|
||||
*/
|
||||
|
||||
/* 所有 select 元素的 option 样式 */
|
||||
select option {
|
||||
background-color: #1A1A1A !important; /* 深色背景 */
|
||||
color: #FFFFFF !important; /* 白色文字 */
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
/* 选中的 option */
|
||||
select option:checked {
|
||||
background-color: #2A2A2A !important;
|
||||
color: #FFC107 !important; /* 金色高亮 */
|
||||
}
|
||||
|
||||
/* hover 状态的 option (某些浏览器支持) */
|
||||
select option:hover {
|
||||
background-color: #222222 !important;
|
||||
color: #FFD700 !important;
|
||||
}
|
||||
|
||||
/* 禁用的 option */
|
||||
select option:disabled {
|
||||
color: #808080 !important;
|
||||
background-color: #151515 !important;
|
||||
}
|
||||
|
||||
/* Firefox 特殊处理 */
|
||||
@-moz-document url-prefix() {
|
||||
select option {
|
||||
background-color: #1A1A1A !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Webkit/Chrome 特殊处理 */
|
||||
select {
|
||||
/* 自定义下拉箭头颜色 */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* 修复 Chakra UI Select 组件的特定样式 */
|
||||
.chakra-select {
|
||||
background-color: #1A1A1A !important;
|
||||
color: #FFFFFF !important;
|
||||
border-color: #333333 !important;
|
||||
}
|
||||
|
||||
.chakra-select:hover {
|
||||
border-color: #404040 !important;
|
||||
}
|
||||
|
||||
.chakra-select:focus {
|
||||
border-color: #FFC107 !important;
|
||||
box-shadow: 0 0 0 1px rgba(255, 193, 7, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 下拉箭头图标 */
|
||||
.chakra-select__icon-wrapper {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* 修复所有表单 select 元素 */
|
||||
select[class*="chakra-select"],
|
||||
select[class*="select"] {
|
||||
background-color: #1A1A1A !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 (适用于下拉列表) */
|
||||
select::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
select::-webkit-scrollbar-track {
|
||||
background: #0A0A0A;
|
||||
}
|
||||
|
||||
select::-webkit-scrollbar-thumb {
|
||||
background: #333333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
select::-webkit-scrollbar-thumb:hover {
|
||||
background: #FFC107;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { benefits } from "@/mocks/benefits";
|
||||
|
||||
type BenefitsProps = {};
|
||||
|
||||
const Benefits = ({}: BenefitsProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container relative z-2">
|
||||
<Splide
|
||||
className="splide-benefits splide-visible max-w-[16rem] md:max-w-none"
|
||||
options={{
|
||||
mediaQuery: "min",
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
gap: "1.5rem",
|
||||
breakpoints: {
|
||||
768: {
|
||||
destroy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{benefits.map((item) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="flex items-center mb-6">
|
||||
<Image
|
||||
src={item.iconUrl}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
<h5 className="h6 mb-4">{item.title}</h5>
|
||||
<p className="body-2 text-n-3">{item.text}</p>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
</Splide>
|
||||
<div className="flex mt-12 -mx-2 md:hidden">
|
||||
{benefits.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>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Benefits;
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { benefits } from "@/mocks/benefits";
|
||||
|
||||
type BenefitsProps = {};
|
||||
|
||||
const Benefits = ({}: BenefitsProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container relative z-2">
|
||||
<Splide
|
||||
className="splide-benefits splide-visible max-w-[16rem] md:max-w-none"
|
||||
options={{
|
||||
mediaQuery: "min",
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
gap: "1.5rem",
|
||||
breakpoints: {
|
||||
768: {
|
||||
destroy: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{benefits.map((item) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="flex items-center mb-6">
|
||||
<Image
|
||||
src={item.iconUrl}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
<h5 className="h6 mb-4">{item.title}</h5>
|
||||
<p className="body-2 text-n-3">{item.text}</p>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
</Splide>
|
||||
<div className="flex mt-12 -mx-2 md:hidden">
|
||||
{benefits.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>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Benefits;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { community } from "@/mocks/community";
|
||||
|
||||
type CommunityProps = {};
|
||||
|
||||
const Community = ({}: CommunityProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="">
|
||||
<div className="container">
|
||||
<div className="relative p-0.5 bg-gradient-to-b from-color-2/80 from-[4.5rem] via-color-1/40 via-[9rem] to-n-1/15 rounded-3xl">
|
||||
<div className="pt-20 px-5 py-10 bg-n-8 rounded-[1.375rem] md:pt-20 md:px-20 mb:pb-16 lg:py-28 lg:pr-48">
|
||||
<Splide
|
||||
options={{
|
||||
type: "fade",
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
ref={ref}
|
||||
>
|
||||
{community.map((comment) => (
|
||||
<SplideSlide className="flex" key={comment.id}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start">
|
||||
<div className="quote mb-6 md:mb-12 lg:mb-0 lg:text-[1.75rem] lg:leading-[2.25rem]">
|
||||
{comment.text}
|
||||
</div>
|
||||
<div className="flex items-center mt-auto lg:block lg:mt-0 lg:ml-20">
|
||||
<div className="w-20 mr-6 lg:w-40 lg:mr-0 lg:mb-11">
|
||||
<Image
|
||||
className="w-full rounded-2xl"
|
||||
src={comment.avatarUrl}
|
||||
width={160}
|
||||
height={160}
|
||||
alt={comment.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="h6">
|
||||
{comment.name}
|
||||
</h6>
|
||||
<div className="caption text-n-1/25">
|
||||
{comment.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</Splide>
|
||||
<div
|
||||
className="flex justify-center mt-10 -mx-2 md:mt-12 md:justify-start lg:absolute lg:top-0
|
||||
lg:right-20 lg:h-full lg:flex-col lg:justify-center lg:m-0"
|
||||
>
|
||||
{community.map((item: any, index: number) => (
|
||||
<button
|
||||
className="relative w-6 h-6 mx-2 lg:my-2 lg:mx-0"
|
||||
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>
|
||||
</div>
|
||||
<div className="absolute -top-14 left-0 z-2 font-code text-[11.25rem] text-color-1 leading-none md:left-12">
|
||||
“
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 bg-n-8">
|
||||
<svg width="72" height="72" viewBox="0 0 72 72">
|
||||
<path
|
||||
fill="#0E0C15"
|
||||
stroke="#FFC876"
|
||||
strokeWidth="2"
|
||||
strokeOpacity=".8"
|
||||
d="M-1176,1 L6.15,1 C13.89,1 21.35,3.89547 27.06,9.11714 L60.91,40.0541 C67.34,45.9271 71,54.2315 71,62.937 L71,444 C71,461.121 57.12,475 40,475 L-1176,475 C-1193.1209,475 -1207,461.121 -1207,444 L-1207,32 C-1207,14.8792 -1193.1208,1 -1176,1 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { community } from "@/mocks/community";
|
||||
|
||||
type CommunityProps = {};
|
||||
|
||||
const Community = ({}: CommunityProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="">
|
||||
<div className="container">
|
||||
<div className="relative p-0.5 bg-gradient-to-b from-color-2/80 from-[4.5rem] via-color-1/40 via-[9rem] to-n-1/15 rounded-3xl">
|
||||
<div className="pt-20 px-5 py-10 bg-n-8 rounded-[1.375rem] md:pt-20 md:px-20 mb:pb-16 lg:py-28 lg:pr-48">
|
||||
<Splide
|
||||
options={{
|
||||
type: "fade",
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
ref={ref}
|
||||
>
|
||||
{community.map((comment) => (
|
||||
<SplideSlide className="flex" key={comment.id}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start">
|
||||
<div className="quote mb-6 md:mb-12 lg:mb-0 lg:text-[1.75rem] lg:leading-[2.25rem]">
|
||||
{comment.text}
|
||||
</div>
|
||||
<div className="flex items-center mt-auto lg:block lg:mt-0 lg:ml-20">
|
||||
<div className="w-20 mr-6 lg:w-40 lg:mr-0 lg:mb-11">
|
||||
<Image
|
||||
className="w-full rounded-2xl"
|
||||
src={comment.avatarUrl}
|
||||
width={160}
|
||||
height={160}
|
||||
alt={comment.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="h6">
|
||||
{comment.name}
|
||||
</h6>
|
||||
<div className="caption text-n-1/25">
|
||||
{comment.role}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</Splide>
|
||||
<div
|
||||
className="flex justify-center mt-10 -mx-2 md:mt-12 md:justify-start lg:absolute lg:top-0
|
||||
lg:right-20 lg:h-full lg:flex-col lg:justify-center lg:m-0"
|
||||
>
|
||||
{community.map((item: any, index: number) => (
|
||||
<button
|
||||
className="relative w-6 h-6 mx-2 lg:my-2 lg:mx-0"
|
||||
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>
|
||||
</div>
|
||||
<div className="absolute -top-14 left-0 z-2 font-code text-[11.25rem] text-color-1 leading-none md:left-12">
|
||||
“
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 bg-n-8">
|
||||
<svg width="72" height="72" viewBox="0 0 72 72">
|
||||
<path
|
||||
fill="#0E0C15"
|
||||
stroke="#FFC876"
|
||||
strokeWidth="2"
|
||||
strokeOpacity=".8"
|
||||
d="M-1176,1 L6.15,1 C13.89,1 21.35,3.89547 27.06,9.11714 L60.91,40.0541 C67.34,45.9271 71,54.2315 71,62.937 L71,444 C71,461.121 57.12,475 40,475 L-1176,475 C-1193.1209,475 -1207,461.121 -1207,444 L-1207,32 C-1207,14.8792 -1193.1208,1 -1176,1 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
@@ -1,101 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
type FeaturesProps = {};
|
||||
|
||||
const Features = ({}: FeaturesProps) => {
|
||||
const content = [
|
||||
{
|
||||
id: "0",
|
||||
title: "Seamless Integration",
|
||||
text: "With smart automation and top-notch security, it's the perfect solution for teams looking to work smarter.",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
title: "Smart Automation",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Top-notch Security",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container">
|
||||
<div className="-mb-16">
|
||||
{[
|
||||
{ id: "0", imageUrl: "/images/features/image-1.jpg" },
|
||||
{ id: "1", imageUrl: "/images/features/image-1.jpg" },
|
||||
{ id: "2", imageUrl: "/images/features/image-1.jpg" },
|
||||
].map((item, index) => (
|
||||
<div
|
||||
className="mb-16 md:grid md:grid-cols-2 md:items-center lg:gap-20 xl:gap-40"
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={`mb-8 bg-n-6 rounded-3xl md:relative md:mb-0 ${
|
||||
index % 2 === 0 ? "" : "md:order-1"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
className="w-full rounded-3xl"
|
||||
src={item.imageUrl}
|
||||
width={550}
|
||||
height={600}
|
||||
alt="Image"
|
||||
/>
|
||||
<div
|
||||
className={`hidden absolute top-5 -right-8 bottom-5 grid-cols-2 w-8 md:grid ${
|
||||
index % 2 === 0
|
||||
? "-right-8"
|
||||
: "-left-8 rotate-180"
|
||||
}`}
|
||||
>
|
||||
<div className="rounded-r-[1.25rem] bg-[#1B1B2E]"></div>
|
||||
<div className="my-5 rounded-r-[1.25rem] bg-[#1B1B2E]/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
index % 2 === 0 ? "md:pl-16" : "md:pr-16"
|
||||
}
|
||||
>
|
||||
<h2 className="h2 mb-4 md:mb-8">
|
||||
Customization Options
|
||||
</h2>
|
||||
<ul className="">
|
||||
{content.map((item) => (
|
||||
<li
|
||||
className="py-4 border-b border-n-1/5 md:py-6"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<h6 className="body-2 ml-5">
|
||||
{item.title}
|
||||
</h6>
|
||||
</div>
|
||||
{item.text && (
|
||||
<p className="body-2 mt-3 text-n-4">
|
||||
{item.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
@@ -1,101 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
type FeaturesProps = {};
|
||||
|
||||
const Features = ({}: FeaturesProps) => {
|
||||
const content = [
|
||||
{
|
||||
id: "0",
|
||||
title: "Seamless Integration",
|
||||
text: "With smart automation and top-notch security, it's the perfect solution for teams looking to work smarter.",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
title: "Smart Automation",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Top-notch Security",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container">
|
||||
<div className="-mb-16">
|
||||
{[
|
||||
{ id: "0", imageUrl: "/images/features/image-1.jpg" },
|
||||
{ id: "1", imageUrl: "/images/features/image-1.jpg" },
|
||||
{ id: "2", imageUrl: "/images/features/image-1.jpg" },
|
||||
].map((item, index) => (
|
||||
<div
|
||||
className="mb-16 md:grid md:grid-cols-2 md:items-center lg:gap-20 xl:gap-40"
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={`mb-8 bg-n-6 rounded-3xl md:relative md:mb-0 ${
|
||||
index % 2 === 0 ? "" : "md:order-1"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
className="w-full rounded-3xl"
|
||||
src={item.imageUrl}
|
||||
width={550}
|
||||
height={600}
|
||||
alt="Image"
|
||||
/>
|
||||
<div
|
||||
className={`hidden absolute top-5 -right-8 bottom-5 grid-cols-2 w-8 md:grid ${
|
||||
index % 2 === 0
|
||||
? "-right-8"
|
||||
: "-left-8 rotate-180"
|
||||
}`}
|
||||
>
|
||||
<div className="rounded-r-[1.25rem] bg-[#1B1B2E]"></div>
|
||||
<div className="my-5 rounded-r-[1.25rem] bg-[#1B1B2E]/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
index % 2 === 0 ? "md:pl-16" : "md:pr-16"
|
||||
}
|
||||
>
|
||||
<h2 className="h2 mb-4 md:mb-8">
|
||||
Customization Options
|
||||
</h2>
|
||||
<ul className="">
|
||||
{content.map((item) => (
|
||||
<li
|
||||
className="py-4 border-b border-n-1/5 md:py-6"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<h6 className="body-2 ml-5">
|
||||
{item.title}
|
||||
</h6>
|
||||
</div>
|
||||
{item.text && (
|
||||
<p className="body-2 mt-3 text-n-4">
|
||||
{item.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
@@ -1,38 +0,0 @@
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
import Section from "@/components/Section";
|
||||
|
||||
type HeroProps = {};
|
||||
|
||||
const Hero = ({}: HeroProps) => (
|
||||
<Section className="overflow-hidden md:-mb-10 xl:-mb-20">
|
||||
<div className="container relative z-2 md:grid md:grid-cols-2 md:items-center md:gap-10 lg:gap-48">
|
||||
<Heading
|
||||
className="md:mt-12 lg:max-w-[30rem] lg:mt-20"
|
||||
textAlignClassName="md:text-left"
|
||||
titleLarge="Main features of Brainwave"
|
||||
textLarge="Here are some of the core features of Brainwavethat make it stand out from other chat applications"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Image
|
||||
className="w-full md:min-w-[125%] xl:min-w-full"
|
||||
src="/images/features/features.png"
|
||||
width={547}
|
||||
height={588}
|
||||
alt="Features"
|
||||
/>
|
||||
<div className="absolute top-0 left-1/2 w-full">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/grid.png"
|
||||
width={550}
|
||||
height={550}
|
||||
alt="Grid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Hero;
|
||||
@@ -1,38 +0,0 @@
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
import Section from "@/components/Section";
|
||||
|
||||
type HeroProps = {};
|
||||
|
||||
const Hero = ({}: HeroProps) => (
|
||||
<Section className="overflow-hidden md:-mb-10 xl:-mb-20">
|
||||
<div className="container relative z-2 md:grid md:grid-cols-2 md:items-center md:gap-10 lg:gap-48">
|
||||
<Heading
|
||||
className="md:mt-12 lg:max-w-[30rem] lg:mt-20"
|
||||
textAlignClassName="md:text-left"
|
||||
titleLarge="Main features of Brainwave"
|
||||
textLarge="Here are some of the core features of Brainwavethat make it stand out from other chat applications"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Image
|
||||
className="w-full md:min-w-[125%] xl:min-w-full"
|
||||
src="/images/features/features.png"
|
||||
width={547}
|
||||
height={588}
|
||||
alt="Features"
|
||||
/>
|
||||
<div className="absolute top-0 left-1/2 w-full">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/grid.png"
|
||||
width={550}
|
||||
height={550}
|
||||
alt="Grid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Hero;
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import Services from "@/components/Services";
|
||||
import Join from "@/components/Join";
|
||||
import Hero from "./Hero";
|
||||
import Benefits from "./Benefits";
|
||||
import Features from "./Features";
|
||||
import Community from "./Community";
|
||||
|
||||
const FeaturesPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Hero />
|
||||
<Benefits />
|
||||
<Features />
|
||||
<Community />
|
||||
<Services containerClassName="md:pb-10" />
|
||||
<Join />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesPage;
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import Services from "@/components/Services";
|
||||
import Join from "@/components/Join";
|
||||
import Hero from "./Hero";
|
||||
import Benefits from "./Benefits";
|
||||
import Features from "./Features";
|
||||
import Community from "./Community";
|
||||
|
||||
const FeaturesPage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Hero />
|
||||
<Benefits />
|
||||
<Features />
|
||||
<Community />
|
||||
<Services containerClassName="md:pb-10" />
|
||||
<Join />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesPage;
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
// import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "../../../components/Section";
|
||||
import Heading from "../../../components/Heading/index.js";
|
||||
import Image from "../../../components/Image";
|
||||
|
||||
// 简化版数据,避免依赖外部mock文件
|
||||
const benefits = [
|
||||
{
|
||||
id: "0",
|
||||
title: "智能问答",
|
||||
text: "让用户能够快速找到问题答案,无需在多个信息源中搜索,提升投研效率。",
|
||||
backgroundUrl: "/images/benefits/card-1.svg",
|
||||
iconUrl: "/images/benefits/icon-1.svg",
|
||||
imageUrl: "/images/benefits/image-2.png",
|
||||
light: true,
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
title: "持续学习",
|
||||
text: "系统采用自然语言处理技术理解用户查询,提供准确相关的投研分析结果。",
|
||||
backgroundUrl: "/images/benefits/card-2.svg",
|
||||
iconUrl: "/images/benefits/icon-2.svg",
|
||||
imageUrl: "/images/benefits/image-2.png",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "全域连接",
|
||||
text: "随时随地连接AI投研助手,支持多设备访问,让专业分析更便捷。",
|
||||
backgroundUrl: "/images/benefits/card-3.svg",
|
||||
iconUrl: "/images/benefits/icon-3.svg",
|
||||
imageUrl: "/images/benefits/image-2.png",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "快速响应",
|
||||
text: "毫秒级响应速度,让用户快速获得投研洞察,把握市场先机。",
|
||||
backgroundUrl: "/images/benefits/card-4.svg",
|
||||
iconUrl: "/images/benefits/icon-4.svg",
|
||||
imageUrl: "/images/benefits/image-2.png",
|
||||
light: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "深度分析",
|
||||
text: "基于海量数据训练的专业投研模型,提供超越传统分析工具的深度洞察。",
|
||||
backgroundUrl: "/images/benefits/card-5.svg",
|
||||
iconUrl: "/images/benefits/icon-1.svg",
|
||||
imageUrl: "/images/benefits/image-2.png",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "智能预测",
|
||||
text: "结合机器学习算法,为投资决策提供智能预测和风险评估建议。",
|
||||
backgroundUrl: "/images/benefits/card-6.svg",
|
||||
iconUrl: "/images/benefits/icon-2.svg",
|
||||
imageUrl: "/images/benefits/image-2.png",
|
||||
},
|
||||
];
|
||||
|
||||
const Benefits = () => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const handleClick = (index) => {
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="overflow-hidden bg-n-8">
|
||||
<div className="container relative z-2">
|
||||
<Heading
|
||||
className="md:max-w-md lg:max-w-2xl"
|
||||
title="智能投研,让分析更简单"
|
||||
text="利用先进的人工智能技术,为您提供专业的投资研究分析服务"
|
||||
/>
|
||||
|
||||
{/* 简化版网格布局,暂时不使用Splide */}
|
||||
<div className="max-w-[24rem] md:max-w-none grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{benefits.map((item, index) => (
|
||||
<div key={item.id}>
|
||||
<Link
|
||||
className="block relative p-0.5 bg-no-repeat bg-[length:100%_100%] md:max-w-[24rem] rounded-xl"
|
||||
to="/features"
|
||||
style={{
|
||||
backgroundImage: `url(${item.backgroundUrl})`,
|
||||
}}
|
||||
>
|
||||
<div className="relative z-2 flex flex-col h-[22.625rem] p-[2.375rem] pointer-events-none">
|
||||
<h5 className="h5 mb-5">
|
||||
{item.title}
|
||||
</h5>
|
||||
<p className="body-2 mb-6 text-n-3">
|
||||
{item.text}
|
||||
</p>
|
||||
<div className="flex items-center mt-auto">
|
||||
<Image
|
||||
className=""
|
||||
src={item.iconUrl}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={item.title}
|
||||
/>
|
||||
<div className="ml-auto font-code text-xs font-bold text-n-1 uppercase tracking-wider">
|
||||
了解更多
|
||||
</div>
|
||||
<svg
|
||||
className="ml-5 fill-n-1"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="M8.293 5.293a1 1 0 0 1 1.414 0l6 6a1 1 0 0 1 0 1.414l-6 6a1 1 0 0 1-1.414-1.414L13.586 12 8.293 6.707a1 1 0 0 1 0-1.414z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{item.light && (
|
||||
<div className="absolute top-0 left-1/4 w-full aspect-square bg-radial-gradient from-[#28206C] to-[#28206C]/0 to-70% pointer-events-none"></div>
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-0.5 bg-n-8 rounded-xl"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity hover:opacity-10">
|
||||
{item.imageUrl && (
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-xl"
|
||||
src={item.imageUrl}
|
||||
width={380}
|
||||
height={362}
|
||||
alt={item.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 指示器 */}
|
||||
<div className="flex mt-12 -mx-2 md:mt-15 lg:justify-center xl:mt-20">
|
||||
{benefits.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>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Benefits;
|
||||
@@ -1,130 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { text, content, apps } from "@/mocks/collaboration";
|
||||
|
||||
type CollaborationProps = {};
|
||||
|
||||
const Collaboration = ({}: CollaborationProps) => {
|
||||
return (
|
||||
<Section crosses>
|
||||
<div className="container lg:flex">
|
||||
<div className="max-w-[25rem]">
|
||||
<h2 className="h2 mb-4 md:mb-8">
|
||||
AI chat app for seamless collaboration
|
||||
</h2>
|
||||
<ul className="max-w-[22.5rem] mb-10 md:mb-14">
|
||||
{content.map((item) => (
|
||||
<li className="mb-3 py-3" key={item.id}>
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<h6 className="body-2 ml-5">
|
||||
{item.title}
|
||||
</h6>
|
||||
</div>
|
||||
{item.text && (
|
||||
<p className="body-2 mt-3 text-n-4">
|
||||
{item.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button>Try it now</Button>
|
||||
</div>
|
||||
<div className="mt-15 lg:mt-0 lg:ml-auto xl:w-[37.5rem]">
|
||||
<div className="relative lg:w-[22.5rem] lg:mx-auto">
|
||||
<p className="body-2 mb-4 text-n-4 md:mb-16 lg:mb-32">
|
||||
{text}
|
||||
</p>
|
||||
<div className="relative left-1/2 flex w-[22.5rem] aspect-square border border-n-6 rounded-full -translate-x-1/2 scale-75 md:scale-100">
|
||||
<div className="flex w-60 aspect-square m-auto border border-n-6 rounded-full">
|
||||
<div className="w-[5.75rem] aspect-square m-auto p-[0.1875rem] bg-conic-gradient rounded-full">
|
||||
<div className="flex items-center justify-center w-full h-full bg-n-8 rounded-full">
|
||||
<Image
|
||||
src="/images/brainwave-symbol.svg"
|
||||
width={48}
|
||||
height={48}
|
||||
alt="Brainwave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{apps.map((app, index) => (
|
||||
<li
|
||||
className={`absolute top-0 left-1/2 h-1/2 -ml-[1.625rem] ${
|
||||
index === 1 && "rotate-[45deg]"
|
||||
} ${index === 2 && "rotate-[90deg]"} ${
|
||||
index === 3 && "rotate-[135deg]"
|
||||
} ${index === 4 && "rotate-[180deg]"} ${
|
||||
index === 5 && "rotate-[225deg]"
|
||||
} ${index === 6 && "rotate-[270deg]"} ${
|
||||
index === 7 && "rotate-[315deg]"
|
||||
} origin-bottom`}
|
||||
key={app.id}
|
||||
>
|
||||
<div
|
||||
className={`relative -top-[1.625rem] flex w-[3.25rem] h-[3.25rem] bg-n-7 border border-n-1/15 rounded-xl ${
|
||||
index === 1 && "-rotate-[45deg]"
|
||||
} ${
|
||||
index === 2 && "-rotate-[90deg]"
|
||||
} ${
|
||||
index === 3 &&
|
||||
"-rotate-[135deg]"
|
||||
} ${
|
||||
index === 4 &&
|
||||
"-rotate-[180deg]"
|
||||
} ${
|
||||
index === 5 &&
|
||||
"-rotate-[225deg]"
|
||||
} ${
|
||||
index === 6 &&
|
||||
"-rotate-[270deg]"
|
||||
} ${
|
||||
index === 7 &&
|
||||
"-rotate-[315deg]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
className="m-auto"
|
||||
src={app.icon}
|
||||
width={app.width}
|
||||
height={app.height}
|
||||
alt={app.title}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="hidden absolute top-1/2 right-full w-[32.625rem] -mt-1 mr-10 pointer-events-none xl:block">
|
||||
<Image
|
||||
src="/images/collaboration/curve-1.svg"
|
||||
width={522}
|
||||
height={182}
|
||||
alt="Curve 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden absolute top-1/2 left-full w-[10.125rem] -mt-1 ml-10 pointer-events-none xl:block">
|
||||
<Image
|
||||
src="/images/collaboration/curve-2.svg"
|
||||
width={162}
|
||||
height={76}
|
||||
alt="Curve 2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collaboration;
|
||||
@@ -1,130 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { text, content, apps } from "@/mocks/collaboration";
|
||||
|
||||
type CollaborationProps = {};
|
||||
|
||||
const Collaboration = ({}: CollaborationProps) => {
|
||||
return (
|
||||
<Section crosses>
|
||||
<div className="container lg:flex">
|
||||
<div className="max-w-[25rem]">
|
||||
<h2 className="h2 mb-4 md:mb-8">
|
||||
AI chat app for seamless collaboration
|
||||
</h2>
|
||||
<ul className="max-w-[22.5rem] mb-10 md:mb-14">
|
||||
{content.map((item) => (
|
||||
<li className="mb-3 py-3" key={item.id}>
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/images/check.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
<h6 className="body-2 ml-5">
|
||||
{item.title}
|
||||
</h6>
|
||||
</div>
|
||||
{item.text && (
|
||||
<p className="body-2 mt-3 text-n-4">
|
||||
{item.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button>Try it now</Button>
|
||||
</div>
|
||||
<div className="mt-15 lg:mt-0 lg:ml-auto xl:w-[37.5rem]">
|
||||
<div className="relative lg:w-[22.5rem] lg:mx-auto">
|
||||
<p className="body-2 mb-4 text-n-4 md:mb-16 lg:mb-32">
|
||||
{text}
|
||||
</p>
|
||||
<div className="relative left-1/2 flex w-[22.5rem] aspect-square border border-n-6 rounded-full -translate-x-1/2 scale-75 md:scale-100">
|
||||
<div className="flex w-60 aspect-square m-auto border border-n-6 rounded-full">
|
||||
<div className="w-[5.75rem] aspect-square m-auto p-[0.1875rem] bg-conic-gradient rounded-full">
|
||||
<div className="flex items-center justify-center w-full h-full bg-n-8 rounded-full">
|
||||
<Image
|
||||
src="/images/brainwave-symbol.svg"
|
||||
width={48}
|
||||
height={48}
|
||||
alt="Brainwave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{apps.map((app, index) => (
|
||||
<li
|
||||
className={`absolute top-0 left-1/2 h-1/2 -ml-[1.625rem] ${
|
||||
index === 1 && "rotate-[45deg]"
|
||||
} ${index === 2 && "rotate-[90deg]"} ${
|
||||
index === 3 && "rotate-[135deg]"
|
||||
} ${index === 4 && "rotate-[180deg]"} ${
|
||||
index === 5 && "rotate-[225deg]"
|
||||
} ${index === 6 && "rotate-[270deg]"} ${
|
||||
index === 7 && "rotate-[315deg]"
|
||||
} origin-bottom`}
|
||||
key={app.id}
|
||||
>
|
||||
<div
|
||||
className={`relative -top-[1.625rem] flex w-[3.25rem] h-[3.25rem] bg-n-7 border border-n-1/15 rounded-xl ${
|
||||
index === 1 && "-rotate-[45deg]"
|
||||
} ${
|
||||
index === 2 && "-rotate-[90deg]"
|
||||
} ${
|
||||
index === 3 &&
|
||||
"-rotate-[135deg]"
|
||||
} ${
|
||||
index === 4 &&
|
||||
"-rotate-[180deg]"
|
||||
} ${
|
||||
index === 5 &&
|
||||
"-rotate-[225deg]"
|
||||
} ${
|
||||
index === 6 &&
|
||||
"-rotate-[270deg]"
|
||||
} ${
|
||||
index === 7 &&
|
||||
"-rotate-[315deg]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
className="m-auto"
|
||||
src={app.icon}
|
||||
width={app.width}
|
||||
height={app.height}
|
||||
alt={app.title}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="hidden absolute top-1/2 right-full w-[32.625rem] -mt-1 mr-10 pointer-events-none xl:block">
|
||||
<Image
|
||||
src="/images/collaboration/curve-1.svg"
|
||||
width={522}
|
||||
height={182}
|
||||
alt="Curve 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden absolute top-1/2 left-full w-[10.125rem] -mt-1 ml-10 pointer-events-none xl:block">
|
||||
<Image
|
||||
src="/images/collaboration/curve-2.svg"
|
||||
width={162}
|
||||
height={76}
|
||||
alt="Curve 2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collaboration;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import Section from "../../../components/Section";
|
||||
import Button from "../../../components/Button";
|
||||
import Image from "../../../components/Image";
|
||||
import Notification from "../../../components/Notification";
|
||||
|
||||
// 简化版特性数据
|
||||
const features = [
|
||||
{
|
||||
id: "0",
|
||||
title: "智能投研分析",
|
||||
text: "利用先进的AI技术,为您提供全面的投资研究分析,包括市场趋势、公司基本面、技术指标等多维度分析,帮助您做出更明智的投资决策。",
|
||||
imageUrl: "/images/features/features.png",
|
||||
iconUrl: "/images/icons/recording-01.svg",
|
||||
notification: "AI分析完成 - 发现3个潜在投资机会",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
title: "实时市场监控",
|
||||
text: "24/7全天候监控全球金融市场动态,实时捕捉市场变化和投资机会。智能预警系统会在关键时刻及时提醒您,确保不错过任何重要的投资时机。",
|
||||
imageUrl: "/images/features/image-1.jpg",
|
||||
iconUrl: "/images/icons/chrome-cast.svg",
|
||||
notification: "市场异动提醒 - 科技股出现上涨信号",
|
||||
},
|
||||
];
|
||||
|
||||
const Features = () => {
|
||||
const [currentFeature, setCurrentFeature] = useState(0);
|
||||
|
||||
return (
|
||||
<Section
|
||||
className="py-10 md:pb-20 lg:pt-16 lg:pb-32 xl:pb-40 overflow-hidden bg-n-7"
|
||||
customPaddings
|
||||
>
|
||||
<div className="container relative z-2">
|
||||
{features.map((item, index) => (
|
||||
<div key={item.id} className={index === currentFeature ? "block" : "hidden"}>
|
||||
<div className="lg:flex">
|
||||
<div className="lg:flex lg:flex-col lg:items-start lg:max-w-[18.75rem] lg:mr-auto">
|
||||
<h2 className="h2 mb-6">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="body-2 mb-10 text-n-3">
|
||||
{item.text}
|
||||
</p>
|
||||
<Button className="" onClick={null} px="px-7">
|
||||
了解工作原理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative h-[27.5rem] border border-n-1/20 rounded-3xl md:rounded-[2.5rem] lg:flex-1 lg:max-w-[34.625rem] lg:h-[34.5rem] lg:ml-24 xl:h-[36rem] mt-10 lg:mt-0">
|
||||
<div className="absolute top-[8.5rem] -left-[2rem] w-[21rem] md:w-[25.25rem] md:top-[6.4rem] md:-left-[4.5rem] lg:top-[12rem] lg:-left-[3rem] xl:top-[7.625rem] xl:-left-[4.5rem] xl:w-[32.75rem]">
|
||||
<Image
|
||||
className="w-full rounded-xl"
|
||||
src={item.imageUrl}
|
||||
width={512}
|
||||
height={512}
|
||||
alt="Feature"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-4 right-4 bottom-4 bg-n-8/95 md:left-8 md:right-8 md:bottom-8 rounded-xl">
|
||||
<Notification
|
||||
className=""
|
||||
title={item.notification}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-6 right-6 flex items-center justify-center w-15 h-15 bg-n-1 rounded-full xl:top-8 xl:right-8">
|
||||
<Image
|
||||
className=""
|
||||
src={item.iconUrl}
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Icon"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden absolute top-0 left-full ml-5 w-full h-full bg-n-8/50 border border-n-1/10 rounded-[2.5rem] md:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 简化版导航 */}
|
||||
<div className="flex justify-center mt-12 gap-4">
|
||||
{features.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`w-3 h-3 rounded-full transition-colors ${
|
||||
index === currentFeature ? 'bg-color-1' : 'bg-n-4'
|
||||
}`}
|
||||
onClick={() => setCurrentFeature(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
import Notification from "@/components/Notification";
|
||||
|
||||
import { features } from "@/mocks/features";
|
||||
import Arrows from "@/components/Arrows";
|
||||
|
||||
type FeaturesProps = {};
|
||||
|
||||
const Features = ({}: FeaturesProps) => {
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
return (
|
||||
<Section
|
||||
className="py-10 md:pb-20 lg:pt-16 lg:pb-32 xl:pb-40 overflow-hidden"
|
||||
customPaddings
|
||||
>
|
||||
<div className="container relative z-2">
|
||||
<Splide
|
||||
className="splide-custom splide-visible"
|
||||
options={{
|
||||
type: "fade",
|
||||
rewind: true,
|
||||
pagination: false,
|
||||
}}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{features.map((item) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="lg:flex" key={item.id}>
|
||||
<div className="lg:flex lg:flex-col lg:items-start lg:max-w-[18.75rem] lg:mr-auto">
|
||||
<h2 className="h2 mb-6">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="body-2 mb-10 text-n-3">
|
||||
{item.text}
|
||||
</p>
|
||||
<Button>See how it work</Button>
|
||||
</div>
|
||||
<Arrows
|
||||
className="my-10 lg:hidden"
|
||||
prevClassName="mr-3"
|
||||
onPrev={() => ref.current?.go("<")}
|
||||
onNext={() => ref.current?.go(">")}
|
||||
/>
|
||||
<div className="relative h-[27.5rem] border border-n-1/20 rounded-3xl md:rounded-[2.5rem] lg:flex-1 lg:max-w-[34.625rem] lg:h-[34.5rem] lg:ml-24 xl:h-[36rem]">
|
||||
<div className="absolute top-[8.5rem] -left-[2rem] w-[21rem] md:w-[25.25rem] md:top-[6.4rem] md:-left-[4.5rem] lg:top-[12rem] lg:-left-[3rem] xl:top-[7.625rem] xl:-left-[4.5rem] xl:w-[32.75rem]">
|
||||
<Image
|
||||
className="w-full"
|
||||
src={item.imageUrl}
|
||||
width={512}
|
||||
height={512}
|
||||
alt="Figure"
|
||||
/>
|
||||
</div>
|
||||
<Notification
|
||||
className="absolute left-4 right-4 bottom-4 bg-n-8/95 md:left-8 md:right-8 md:bottom-8"
|
||||
title={item.notification}
|
||||
/>
|
||||
<div className="absolute top-6 right-6 flex items-center justify-center w-15 h-15 bg-n-1 rounded-full xl:top-8 xl:right-8">
|
||||
<Image
|
||||
src={item.iconUrl}
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Icon"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden absolute top-0 left-full ml-5 w-full h-full bg-n-8/50 border border-n-1/10 rounded-[2.5rem] md:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<Arrows
|
||||
className="hidden -mt-12 lg:flex"
|
||||
prevClassName="mr-3"
|
||||
onPrev={() => ref.current?.go("<")}
|
||||
onNext={() => ref.current?.go(">")}
|
||||
/>
|
||||
</Splide>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// import { MouseParallax, ScrollParallax } from "react-just-parallax";
|
||||
import Section from "../../../components/Section";
|
||||
import Button from "../../../components/Button";
|
||||
import Image from "../../../components/Image";
|
||||
import Generating from "../../../components/Generating";
|
||||
import Notification from "../../../components/Notification";
|
||||
import Logos from "../../../components/Logos";
|
||||
|
||||
const Hero = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const parallaxRef = useRef(null);
|
||||
|
||||
return (
|
||||
<Section
|
||||
className="-mt-[4.75rem] pt-[8.25rem] pb-4 overflow-hidden md:pt-[9.75rem] md:pb-[4.8rem] lg:-mt-[5.25rem] lg:-mb-40 lg:pt-[12.25rem] lg:pb-[13.8rem]"
|
||||
crosses
|
||||
crossesOffset="lg:translate-y-[5.25rem]"
|
||||
customPaddings
|
||||
>
|
||||
{/* 添加深色渐变背景 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-n-8 via-n-7 to-n-6"></div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(172,106,255,0.1)_0%,transparent_70%)]"></div>
|
||||
<div className="container relative" ref={parallaxRef}>
|
||||
<div
|
||||
className="relative z-1 max-w-[62rem] mx-auto mb-[3.875rem] text-center md:mb-20 lg:mb-[6.25rem]"
|
||||
style={{ position: 'relative', zIndex: 10 }}
|
||||
>
|
||||
<h1
|
||||
className="h1 mb-6"
|
||||
style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: '3.75rem',
|
||||
lineHeight: '4.5rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '24px'
|
||||
}}
|
||||
>
|
||||
探索
|
||||
<span style={{
|
||||
background: 'linear-gradient(to right, #AC6AFF, #FFC876)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
AI投研
|
||||
</span>
|
||||
的无限可能性 {" "}
|
||||
<span className="inline-block relative">
|
||||
<span style={{
|
||||
background: 'linear-gradient(to right, #FFC876, #AC6AFF)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
价值前沿
|
||||
</span>
|
||||
<Image
|
||||
className="absolute top-full left-0 w-full xl:-mt-2"
|
||||
src="/images/curve.png"
|
||||
width={624}
|
||||
height={28}
|
||||
alt="Curve"
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
className="body-1 max-w-3xl mx-auto mb-6 lg:mb-8"
|
||||
style={{
|
||||
color: '#CAC6DD',
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: '2rem',
|
||||
marginBottom: '32px',
|
||||
maxWidth: '48rem',
|
||||
margin: '0 auto 32px'
|
||||
}}
|
||||
>
|
||||
释放AI的力量,升级您的投研效率。
|
||||
体验专业的开放式AI投研平台,超越传统分析工具。
|
||||
</p>
|
||||
<Button href="/community" white className="" onClick={null} px="px-7">
|
||||
开始使用
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative max-w-[23.25rem] mx-auto md:max-w-5xl xl:mb-24">
|
||||
<div className="relative z-1 p-0.5 rounded-2xl bg-conic-gradient">
|
||||
<div className="relative bg-n-8 rounded-[0.875rem]">
|
||||
<div className="h-[1.375rem] bg-[#43435C] rounded-t-[0.875rem]"></div>
|
||||
<div className="aspect-[33/40] rounded-b-[0.875rem] overflow-hidden md:aspect-[688/490] lg:aspect-[1024/490]">
|
||||
<Image
|
||||
className="w-full scale-[1.7] translate-y-[8%] md:scale-[1] md:-translate-y-[10.5%] lg:-translate-y-[23.5%]"
|
||||
src="/images/hero/robot.jpg"
|
||||
width={1024}
|
||||
height={490}
|
||||
alt="AI"
|
||||
/>
|
||||
</div>
|
||||
<Generating className="absolute left-4 right-4 bottom-5 md:left-1/2 md:right-auto md:bottom-8 md:w-[30.5rem] md:-translate-x-1/2" />
|
||||
|
||||
{/* 简化版本,暂时不使用ScrollParallax */}
|
||||
<div className="hidden absolute -left-[5.5rem] bottom-[7.625rem] px-1 py-1 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl xl:flex">
|
||||
{[
|
||||
"/images/icons/home-smile.svg",
|
||||
"/images/icons/file-02.svg",
|
||||
"/images/icons/search-md.svg",
|
||||
"/images/icons/plus-square.svg",
|
||||
].map((icon, index) => (
|
||||
<div className="p-5" key={index}>
|
||||
<Image
|
||||
className=""
|
||||
src={icon}
|
||||
width={24}
|
||||
height={25}
|
||||
alt={`Icon ${index}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden absolute -right-[5.5rem] bottom-[11.25rem] w-[18.375rem] xl:flex">
|
||||
<Notification
|
||||
className=""
|
||||
title="AI投研分析完成"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-1 h-6 mx-2.5 bg-[#1B1B2E] shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-8"></div>
|
||||
<div className="relative z-1 h-6 mx-6 bg-[#1B1B2E]/70 shadow-xl rounded-b-[1.25rem] lg:h-6 lg:mx-20"></div>
|
||||
<div className="absolute -top-[54%] left-1/2 w-[234%] -translate-x-1/2 md:-top-[46%] md:w-[138%] lg:-top-[104%]">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/hero/background.jpg"
|
||||
width={1440}
|
||||
height={1800}
|
||||
alt="Hero"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -top-[42.375rem] left-1/2 w-[78rem] aspect-square border border-n-2/5 rounded-full -translate-x-1/2 md:-top-[38.5rem] xl:-top-[32rem]">
|
||||
<div className="absolute top-1/2 left-1/2 w-[65.875rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[51.375rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[36.125rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-[23.125rem] aspect-square border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
||||
|
||||
{/* 浮动装饰点 */}
|
||||
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom rotate-[46deg]">
|
||||
<div
|
||||
className={`w-2 h-2 -ml-1 -mt-36 bg-gradient-to-b from-[#DD734F] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
|
||||
mounted
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-10 opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom -rotate-[56deg]">
|
||||
<div
|
||||
className={`w-4 h-4 -ml-1 -mt-32 bg-gradient-to-b from-[#DD734F] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
|
||||
mounted
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-10 opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom rotate-[54deg]">
|
||||
<div
|
||||
className={`hidden w-4 h-4 -ml-1 mt-[12.9rem] bg-gradient-to-b from-[#B9AEDF] to-[#1A1A32] rounded-full xl:block transition-transform duration-500 ease-out ${
|
||||
mounted
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-10 opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom -rotate-[65deg]">
|
||||
<div
|
||||
className={`w-3 h-3 -ml-1.5 mt-52 bg-gradient-to-b from-[#B9AEDF] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
|
||||
mounted
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-10 opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom -rotate-[85deg]">
|
||||
<div
|
||||
className={`w-6 h-6 -ml-3 -mt-3 bg-gradient-to-b from-[#88E5BE] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
|
||||
mounted
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-10 opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="absolute bottom-1/2 left-1/2 w-0.25 h-1/2 origin-bottom rotate-[70deg]">
|
||||
<div
|
||||
className={`w-6 h-6 -ml-3 -mt-3 bg-gradient-to-b from-[#88E5BE] to-[#1A1A32] rounded-full transition-transform duration-500 ease-out ${
|
||||
mounted
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-10 opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Logos className="hidden relative z-10 mt-20 lg:block" />
|
||||
</div>
|
||||
<div className="hidden absolute top-[55.25rem] left-10 right-10 h-0.25 bg-n-6 pointer-events-none xl:block"></div>
|
||||
<svg
|
||||
className="hidden absolute top-[54.9375rem] left-[2.1875rem] z-2 pointer-events-none xl:block"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className="hidden absolute top-[54.9375rem] right-[2.1875rem] z-2 pointer-events-none xl:block"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Button from "@/components/Button";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Arrows from "@/components/Arrows";
|
||||
|
||||
import { howItWorks } from "@/mocks/how-it-works";
|
||||
|
||||
type HowItWorksProps = {};
|
||||
|
||||
const HowItWorks = ({}: HowItWorksProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="lg:-mb-16" crosses>
|
||||
<div className="container">
|
||||
<Splide
|
||||
className="splide-custom"
|
||||
options={{
|
||||
type: "fade",
|
||||
rewind: true,
|
||||
pagination: false,
|
||||
}}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{howItWorks.map((item, index) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="lg:flex lg:flex-row-reverse lg:items-center">
|
||||
<div className="">
|
||||
<Tagline className="mb-4 lg:mb-6">
|
||||
How it work: 0{index + 1}.
|
||||
</Tagline>
|
||||
<h2 className="h2 mb-4 lg:mb-6">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="body-2 mb-10 text-n-3">
|
||||
{item.text}
|
||||
</p>
|
||||
<Button href="/login">
|
||||
Connect now
|
||||
</Button>
|
||||
<Arrows
|
||||
className="my-10 lg:hidden"
|
||||
prevClassName="mr-3"
|
||||
onPrev={() => ref.current?.go("<")}
|
||||
onNext={() => ref.current?.go(">")}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative lg:w-[29.375rem] lg:flex-shrink-0 lg:mr-[7.125rem] xl:w-[34.375rem] xl:mr-40">
|
||||
<div className="pt-0.25 pl-0.25 overflow-hidden bg-gradient-to-tl from-n-1/0 via-n-1/0 to-n-1/15 rounded-3xl">
|
||||
<div className="h-[30.5rem] bg-n-7 rounded-[1.4375rem] xl:h-[35.625rem]">
|
||||
<Image
|
||||
className="w-full h-full object-contain"
|
||||
src={item.image}
|
||||
width={550}
|
||||
height={570}
|
||||
alt={item.title}
|
||||
/>
|
||||
<div className="absolute left-4 right-4 bottom-4 flex items-center h-16 px-5 bg-n-8 border border-n-1/10 rounded-xl lg:left-6 lg:right-6 lg:bottom-6">
|
||||
<div className="flex items-center justify-center w-6 h-6 mr-5 bg-color-1 rounded-full">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 fill-n-1"
|
||||
viewBox="0 0 10 10"
|
||||
>
|
||||
<path d="M5 0a1 1 0 0 1 .993.883L6 1v3h3a1 1 0 0 1 .117 1.993L9 6H6v3a1 1 0 0 1-1.993.117L4 9V6H1a1 1 0 0 1-.117-1.993L1 4h3V1a1 1 0 0 1 1-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-base text-n-3/75">
|
||||
Ask anything
|
||||
</div>
|
||||
<div className="w-6 h-6 ml-auto opacity-50">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/icons/recording-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Recording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-6 top-8 bottom-8 w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
|
||||
<div className="absolute -right-12 top-16 bottom-16 w-6 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<Arrows
|
||||
className="hidden justify-center mt-12 lg:flex lg:mt-15 xl:hidden"
|
||||
prevClassName="mr-3"
|
||||
onPrev={() => ref.current?.go("<")}
|
||||
onNext={() => ref.current?.go(">")}
|
||||
/>
|
||||
<div className="absolute top-0 -left-[10rem] w-[29.5rem] h-[29.5rem] mix-blend-color-dodge opacity-20 pointer-events-none">
|
||||
<Image
|
||||
className="absolute top-1/2 left-1/2 w-[55.5rem] max-w-[55.5rem] h-[61.5rem] -translate-x-1/2 -translate-y-1/2"
|
||||
src="/images/how-it-works/gradient.png"
|
||||
width={984}
|
||||
height={984}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden grid-cols-4 gap-6 mt-20 xl:grid">
|
||||
{howItWorks.map((item, index) => (
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
onClick={() => handleClick(index)}
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={`h-[0.125rem] mb-10 transition-colors ${
|
||||
index === activeIndex
|
||||
? "bg-color-1"
|
||||
: "bg-[#D9D9D9]/10 group-hover:bg-[#D9D9D9]/50"
|
||||
}`}
|
||||
></div>
|
||||
<div className="tagline mb-1 text-n-3">
|
||||
0{index + 1}.
|
||||
</div>
|
||||
<h2 className="mb-[0.625rem] text-2xl leading-8">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p
|
||||
className={`body-2 text-n-3 line-clamp-3 transition-opacity ${
|
||||
index !== activeIndex && "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Splide>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowItWorks;
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Button from "@/components/Button";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Arrows from "@/components/Arrows";
|
||||
|
||||
import { howItWorks } from "@/mocks/how-it-works";
|
||||
|
||||
type HowItWorksProps = {};
|
||||
|
||||
const HowItWorks = ({}: HowItWorksProps) => {
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
ref.current?.go(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="lg:-mb-16" crosses>
|
||||
<div className="container">
|
||||
<Splide
|
||||
className="splide-custom"
|
||||
options={{
|
||||
type: "fade",
|
||||
rewind: true,
|
||||
pagination: false,
|
||||
}}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{howItWorks.map((item, index) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="lg:flex lg:flex-row-reverse lg:items-center">
|
||||
<div className="">
|
||||
<Tagline className="mb-4 lg:mb-6">
|
||||
How it work: 0{index + 1}.
|
||||
</Tagline>
|
||||
<h2 className="h2 mb-4 lg:mb-6">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="body-2 mb-10 text-n-3">
|
||||
{item.text}
|
||||
</p>
|
||||
<Button href="/login">
|
||||
Connect now
|
||||
</Button>
|
||||
<Arrows
|
||||
className="my-10 lg:hidden"
|
||||
prevClassName="mr-3"
|
||||
onPrev={() => ref.current?.go("<")}
|
||||
onNext={() => ref.current?.go(">")}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative lg:w-[29.375rem] lg:flex-shrink-0 lg:mr-[7.125rem] xl:w-[34.375rem] xl:mr-40">
|
||||
<div className="pt-0.25 pl-0.25 overflow-hidden bg-gradient-to-tl from-n-1/0 via-n-1/0 to-n-1/15 rounded-3xl">
|
||||
<div className="h-[30.5rem] bg-n-7 rounded-[1.4375rem] xl:h-[35.625rem]">
|
||||
<Image
|
||||
className="w-full h-full object-contain"
|
||||
src={item.image}
|
||||
width={550}
|
||||
height={570}
|
||||
alt={item.title}
|
||||
/>
|
||||
<div className="absolute left-4 right-4 bottom-4 flex items-center h-16 px-5 bg-n-8 border border-n-1/10 rounded-xl lg:left-6 lg:right-6 lg:bottom-6">
|
||||
<div className="flex items-center justify-center w-6 h-6 mr-5 bg-color-1 rounded-full">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 fill-n-1"
|
||||
viewBox="0 0 10 10"
|
||||
>
|
||||
<path d="M5 0a1 1 0 0 1 .993.883L6 1v3h3a1 1 0 0 1 .117 1.993L9 6H6v3a1 1 0 0 1-1.993.117L4 9V6H1a1 1 0 0 1-.117-1.993L1 4h3V1a1 1 0 0 1 1-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-base text-n-3/75">
|
||||
Ask anything
|
||||
</div>
|
||||
<div className="w-6 h-6 ml-auto opacity-50">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/icons/recording-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Recording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-6 top-8 bottom-8 w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
|
||||
<div className="absolute -right-12 top-16 bottom-16 w-6 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<Arrows
|
||||
className="hidden justify-center mt-12 lg:flex lg:mt-15 xl:hidden"
|
||||
prevClassName="mr-3"
|
||||
onPrev={() => ref.current?.go("<")}
|
||||
onNext={() => ref.current?.go(">")}
|
||||
/>
|
||||
<div className="absolute top-0 -left-[10rem] w-[29.5rem] h-[29.5rem] mix-blend-color-dodge opacity-20 pointer-events-none">
|
||||
<Image
|
||||
className="absolute top-1/2 left-1/2 w-[55.5rem] max-w-[55.5rem] h-[61.5rem] -translate-x-1/2 -translate-y-1/2"
|
||||
src="/images/how-it-works/gradient.png"
|
||||
width={984}
|
||||
height={984}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden grid-cols-4 gap-6 mt-20 xl:grid">
|
||||
{howItWorks.map((item, index) => (
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
onClick={() => handleClick(index)}
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className={`h-[0.125rem] mb-10 transition-colors ${
|
||||
index === activeIndex
|
||||
? "bg-color-1"
|
||||
: "bg-[#D9D9D9]/10 group-hover:bg-[#D9D9D9]/50"
|
||||
}`}
|
||||
></div>
|
||||
<div className="tagline mb-1 text-n-3">
|
||||
0{index + 1}.
|
||||
</div>
|
||||
<h2 className="mb-[0.625rem] text-2xl leading-8">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p
|
||||
className={`body-2 text-n-3 line-clamp-3 transition-opacity ${
|
||||
index !== activeIndex && "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Splide>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowItWorks;
|
||||
@@ -1,69 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Heading from "@/components/Heading";
|
||||
import PricingList from "@/components/PricingList";
|
||||
|
||||
type PricingProps = {};
|
||||
|
||||
const Pricing = ({}: PricingProps) => {
|
||||
return (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container relative z-2">
|
||||
<div className="hidden relative justify-center mb-[6.5rem] lg:flex">
|
||||
<Image
|
||||
className="relative z-1"
|
||||
src="/images/figures/4-small.png"
|
||||
width={255}
|
||||
height={255}
|
||||
alt="Sphere"
|
||||
/>
|
||||
<div className="absolute top-1/2 left-1/2 w-[59.5rem] -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/pricing/stars.svg"
|
||||
width={952}
|
||||
height={396}
|
||||
alt="Stars"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Heading
|
||||
tag="Get started with Brainwave"
|
||||
title="Pay once, use forever"
|
||||
/>
|
||||
<div className="relative">
|
||||
<PricingList />
|
||||
<div className="hidden lg:block absolute top-1/2 right-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 pointer-events-none">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/pricing/lines.svg"
|
||||
width={1480}
|
||||
height={177}
|
||||
alt="Lines"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden lg:block absolute top-1/2 left-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 -scale-x-100 pointer-events-none">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/pricing/lines.svg"
|
||||
width={1480}
|
||||
height={177}
|
||||
alt="Lines"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-8 md:mt-15 xl:mt-20">
|
||||
<Link
|
||||
className="text-xs font-code font-bold tracking-wider uppercase border-b border-n-1 transition-colors hover:border-n-1/0"
|
||||
href="/pricing"
|
||||
>
|
||||
See the full details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pricing;
|
||||
@@ -1,69 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Heading from "@/components/Heading";
|
||||
import PricingList from "@/components/PricingList";
|
||||
|
||||
type PricingProps = {};
|
||||
|
||||
const Pricing = ({}: PricingProps) => {
|
||||
return (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container relative z-2">
|
||||
<div className="hidden relative justify-center mb-[6.5rem] lg:flex">
|
||||
<Image
|
||||
className="relative z-1"
|
||||
src="/images/figures/4-small.png"
|
||||
width={255}
|
||||
height={255}
|
||||
alt="Sphere"
|
||||
/>
|
||||
<div className="absolute top-1/2 left-1/2 w-[59.5rem] -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/pricing/stars.svg"
|
||||
width={952}
|
||||
height={396}
|
||||
alt="Stars"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Heading
|
||||
tag="Get started with Brainwave"
|
||||
title="Pay once, use forever"
|
||||
/>
|
||||
<div className="relative">
|
||||
<PricingList />
|
||||
<div className="hidden lg:block absolute top-1/2 right-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 pointer-events-none">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/pricing/lines.svg"
|
||||
width={1480}
|
||||
height={177}
|
||||
alt="Lines"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden lg:block absolute top-1/2 left-full w-[92.5rem] h-[11.0625rem] -translate-y-1/2 -scale-x-100 pointer-events-none">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/pricing/lines.svg"
|
||||
width={1480}
|
||||
height={177}
|
||||
alt="Lines"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-8 md:mt-15 xl:mt-20">
|
||||
<Link
|
||||
className="text-xs font-code font-bold tracking-wider uppercase border-b border-n-1 transition-colors hover:border-n-1/0"
|
||||
href="/pricing"
|
||||
>
|
||||
See the full details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pricing;
|
||||
@@ -1,97 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { roadmap } from "@/mocks/roadmap";
|
||||
import Button from "@/components/Button";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
type RoadmapProps = {};
|
||||
|
||||
const Roadmap = ({}: RoadmapProps) => (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container md:pb-10">
|
||||
<Heading tag="Ready to get started" title="What we’re working on" />
|
||||
<div className="relative grid gap-6 md:grid-cols-2 md:gap-4 md:pb-[7rem]">
|
||||
{roadmap.map((item, index) => (
|
||||
<div
|
||||
className={`md:flex ${
|
||||
index % 2 !== 0 ? "md:translate-y-[7rem]" : ""
|
||||
} p-0.25 rounded-[2.5rem] ${
|
||||
item.colorful ? "bg-conic-gradient" : "bg-n-6"
|
||||
}`}
|
||||
key={item.id}
|
||||
>
|
||||
<div className="relative p-8 bg-n-8 rounded-[2.4375rem] overflow-hidden xl:p-15">
|
||||
<div className="absolute top-0 left-0 max-w-full">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/grid.png"
|
||||
width={550}
|
||||
height={550}
|
||||
alt="Grid"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-1">
|
||||
<div className="flex items-center justify-between max-w-[27rem] mb-8 md:mb-20">
|
||||
<Tagline>{item.date}</Tagline>
|
||||
<div className="flex items-center px-4 py-1 bg-n-1 rounded text-n-8">
|
||||
<Image
|
||||
className="mr-2.5"
|
||||
src={
|
||||
item.status === "done"
|
||||
? "/images/icons/check.svg"
|
||||
: "/images/icons/loading-01.svg"
|
||||
}
|
||||
width={16}
|
||||
height={16}
|
||||
alt={
|
||||
item.status === "done"
|
||||
? "Done"
|
||||
: "In progress"
|
||||
}
|
||||
/>
|
||||
<div className="tagline">
|
||||
{item.status === "done"
|
||||
? "Done"
|
||||
: "In progress"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 md:mb-20">
|
||||
<div className="-my-10 -mx-15">
|
||||
<Image
|
||||
className="w-full"
|
||||
src={item.imageUrl}
|
||||
width={628}
|
||||
height={426}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="h4 mb-4">{item.title}</h4>
|
||||
<p className="body-2 text-n-4">{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/gradient.png"
|
||||
width={942}
|
||||
height={942}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-12 md:mt-15 xl:mt-20">
|
||||
<Button href="/roadmap">Our roadmap</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Roadmap;
|
||||
@@ -1,97 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { roadmap } from "@/mocks/roadmap";
|
||||
import Button from "@/components/Button";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
type RoadmapProps = {};
|
||||
|
||||
const Roadmap = ({}: RoadmapProps) => (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container md:pb-10">
|
||||
<Heading tag="Ready to get started" title="What we’re working on" />
|
||||
<div className="relative grid gap-6 md:grid-cols-2 md:gap-4 md:pb-[7rem]">
|
||||
{roadmap.map((item, index) => (
|
||||
<div
|
||||
className={`md:flex ${
|
||||
index % 2 !== 0 ? "md:translate-y-[7rem]" : ""
|
||||
} p-0.25 rounded-[2.5rem] ${
|
||||
item.colorful ? "bg-conic-gradient" : "bg-n-6"
|
||||
}`}
|
||||
key={item.id}
|
||||
>
|
||||
<div className="relative p-8 bg-n-8 rounded-[2.4375rem] overflow-hidden xl:p-15">
|
||||
<div className="absolute top-0 left-0 max-w-full">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/grid.png"
|
||||
width={550}
|
||||
height={550}
|
||||
alt="Grid"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-1">
|
||||
<div className="flex items-center justify-between max-w-[27rem] mb-8 md:mb-20">
|
||||
<Tagline>{item.date}</Tagline>
|
||||
<div className="flex items-center px-4 py-1 bg-n-1 rounded text-n-8">
|
||||
<Image
|
||||
className="mr-2.5"
|
||||
src={
|
||||
item.status === "done"
|
||||
? "/images/icons/check.svg"
|
||||
: "/images/icons/loading-01.svg"
|
||||
}
|
||||
width={16}
|
||||
height={16}
|
||||
alt={
|
||||
item.status === "done"
|
||||
? "Done"
|
||||
: "In progress"
|
||||
}
|
||||
/>
|
||||
<div className="tagline">
|
||||
{item.status === "done"
|
||||
? "Done"
|
||||
: "In progress"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 md:mb-20">
|
||||
<div className="-my-10 -mx-15">
|
||||
<Image
|
||||
className="w-full"
|
||||
src={item.imageUrl}
|
||||
width={628}
|
||||
height={426}
|
||||
alt={item.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="h4 mb-4">{item.title}</h4>
|
||||
<p className="body-2 text-n-4">{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/gradient.png"
|
||||
width={942}
|
||||
height={942}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-12 md:mt-15 xl:mt-20">
|
||||
<Button href="/roadmap">Our roadmap</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Roadmap;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { testimonials } from "@/mocks/testimonials";
|
||||
import Arrows from "@/components/Arrows";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
type TestimonialsProps = {};
|
||||
|
||||
const Testimonials = ({}: TestimonialsProps) => (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container relative z-2">
|
||||
<Heading
|
||||
tag="Ready to get started"
|
||||
title="What the community is saying"
|
||||
/>
|
||||
<Splide
|
||||
className="splide-custom splide-visible"
|
||||
options={{
|
||||
mediaQuery: "min",
|
||||
gap: "1.5rem",
|
||||
breakpoints: {
|
||||
1024: {
|
||||
autoWidth: true,
|
||||
},
|
||||
},
|
||||
rewind: true,
|
||||
pagination: false,
|
||||
}}
|
||||
hasTrack={false}
|
||||
>
|
||||
<SplideTrack>
|
||||
{testimonials.map((item) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="relative flex h-full p-4 rounded-t-xl overflow-hidden lg:w-[46.125rem]">
|
||||
<div className="absolute top-0 left-0 right-0 bottom-[3.25rem] border border-n-4/50 rounded-3xl"></div>
|
||||
<div className="absolute inset-px rounded-t-[1.4375rem] overflow-hidden">
|
||||
<div className="absolute -inset-0.25">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src={item.imageUrl}
|
||||
width={739}
|
||||
height={472}
|
||||
alt={item.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-n-8/50 to-n-8/0"></div>
|
||||
<div className="hidden relative z-1 md:flex flex-col flex-1 pt-12 px-4 pb-16">
|
||||
<div className="w-[12.75rem] h-10 mb-auto">
|
||||
<Image
|
||||
className="w-full h-full object-contain"
|
||||
src={item.logoUrl}
|
||||
width={204}
|
||||
height={40}
|
||||
alt={item.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5">{item.name}</div>
|
||||
<div className="h5 text-n-4">
|
||||
{item.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex z-1 bg-conic-gradient p-0.25 rounded-2xl md:ml-auto">
|
||||
<div className="flex flex-col items-start p-8 bg-n-8 rounded-[0.9375rem] md:w-[21.75rem]">
|
||||
<p className="quote mb-8">
|
||||
{item.text}
|
||||
</p>
|
||||
<Button className="mt-auto">
|
||||
Visit link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<Arrows
|
||||
className="justify-center mt-12 md:mt-15 xl:mt-20"
|
||||
prevClassName="mr-8"
|
||||
/>
|
||||
</Splide>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Testimonials;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Section from "@/components/Section";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
|
||||
import { testimonials } from "@/mocks/testimonials";
|
||||
import Arrows from "@/components/Arrows";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
type TestimonialsProps = {};
|
||||
|
||||
const Testimonials = ({}: TestimonialsProps) => (
|
||||
<Section className="overflow-hidden">
|
||||
<div className="container relative z-2">
|
||||
<Heading
|
||||
tag="Ready to get started"
|
||||
title="What the community is saying"
|
||||
/>
|
||||
<Splide
|
||||
className="splide-custom splide-visible"
|
||||
options={{
|
||||
mediaQuery: "min",
|
||||
gap: "1.5rem",
|
||||
breakpoints: {
|
||||
1024: {
|
||||
autoWidth: true,
|
||||
},
|
||||
},
|
||||
rewind: true,
|
||||
pagination: false,
|
||||
}}
|
||||
hasTrack={false}
|
||||
>
|
||||
<SplideTrack>
|
||||
{testimonials.map((item) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="relative flex h-full p-4 rounded-t-xl overflow-hidden lg:w-[46.125rem]">
|
||||
<div className="absolute top-0 left-0 right-0 bottom-[3.25rem] border border-n-4/50 rounded-3xl"></div>
|
||||
<div className="absolute inset-px rounded-t-[1.4375rem] overflow-hidden">
|
||||
<div className="absolute -inset-0.25">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src={item.imageUrl}
|
||||
width={739}
|
||||
height={472}
|
||||
alt={item.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-n-8/50 to-n-8/0"></div>
|
||||
<div className="hidden relative z-1 md:flex flex-col flex-1 pt-12 px-4 pb-16">
|
||||
<div className="w-[12.75rem] h-10 mb-auto">
|
||||
<Image
|
||||
className="w-full h-full object-contain"
|
||||
src={item.logoUrl}
|
||||
width={204}
|
||||
height={40}
|
||||
alt={item.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5">{item.name}</div>
|
||||
<div className="h5 text-n-4">
|
||||
{item.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex z-1 bg-conic-gradient p-0.25 rounded-2xl md:ml-auto">
|
||||
<div className="flex flex-col items-start p-8 bg-n-8 rounded-[0.9375rem] md:w-[21.75rem]">
|
||||
<p className="quote mb-8">
|
||||
{item.text}
|
||||
</p>
|
||||
<Button className="mt-auto">
|
||||
Visit link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<Arrows
|
||||
className="justify-center mt-12 md:mt-15 xl:mt-20"
|
||||
prevClassName="mr-8"
|
||||
/>
|
||||
</Splide>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Testimonials;
|
||||
@@ -1,30 +0,0 @@
|
||||
import Layout from "../../components/Layout";
|
||||
import Hero from "./Hero/index.js";
|
||||
import Benefits from "./Benefits/index.js";
|
||||
import Features from "./Features/index.js";
|
||||
import Collaboration from "./Collaboration";
|
||||
import HowItWorks from "./HowItWorks";
|
||||
import Pricing from "./Pricing";
|
||||
import Testimonials from "./Testimonials";
|
||||
import Roadmap from "./Roadmap";
|
||||
import Services from "../../components/Services";
|
||||
import Join from "../../components/Join";
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Hero />
|
||||
<Benefits />
|
||||
<Features />
|
||||
<Collaboration />
|
||||
<HowItWorks />
|
||||
<Services />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
<Roadmap />
|
||||
<Join />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,31 +0,0 @@
|
||||
import Layout from "../../components/Layout/index.js";
|
||||
import Hero from "./Hero/index.js";
|
||||
import Benefits from "./Benefits/index.js";
|
||||
import Features from "./Features/index.js";
|
||||
// import Collaboration from "./Collaboration";
|
||||
// import HowItWorks from "./HowItWorks";
|
||||
// import Pricing from "./Pricing";
|
||||
// import Testimonials from "./Testimonials";
|
||||
// import Roadmap from "./Roadmap";
|
||||
// import Services from "../../components/Services";
|
||||
// import Join from "../../components/Join";
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Hero />
|
||||
<Benefits />
|
||||
<Features />
|
||||
{/* 其他组件将在后续逐步修复 */}
|
||||
{/* <Collaboration />
|
||||
<HowItWorks />
|
||||
<Services />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
<Roadmap />
|
||||
<Join /> */}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,62 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
type HelpProps = {};
|
||||
|
||||
const Help = ({}: HelpProps) => (
|
||||
<Section crosses>
|
||||
<div className="container pt-10 pb-10 lg:grid lg:grid-cols-2 lg:gap-20 lg:items-center lg:p-0">
|
||||
<div className="hidden lg:block">
|
||||
<div>
|
||||
<Image
|
||||
src="/images/help/help.png"
|
||||
width={756}
|
||||
height={756}
|
||||
alt="Help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="h2 mb-4 md:mb-6">Need help?</h2>
|
||||
<p className="body-2 mb-5 text-n-3">
|
||||
Can’t find your answer, contact us
|
||||
</p>
|
||||
<ul>
|
||||
{[
|
||||
{
|
||||
id: "0",
|
||||
title: "Join our community",
|
||||
text: "Discuss anything with other users",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
title: "Email us",
|
||||
text: "hello@brainwave.com",
|
||||
},
|
||||
].map((item) => (
|
||||
<li
|
||||
className="flex items-center py-10 border-b border-n-1/15"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-center justify-center w-15 h-15 mr-10 bg-n-7 border border-n-1/15 rounded-xl">
|
||||
<Image
|
||||
src="/images/icons/building-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Contact"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="h6 text-n-3">{item.title}</h6>
|
||||
<p className="body-2 text-n-2">{item.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Help;
|
||||
@@ -1,62 +0,0 @@
|
||||
import Section from "@/components/Section";
|
||||
import Image from "@/components/Image";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
type HelpProps = {};
|
||||
|
||||
const Help = ({}: HelpProps) => (
|
||||
<Section crosses>
|
||||
<div className="container pt-10 pb-10 lg:grid lg:grid-cols-2 lg:gap-20 lg:items-center lg:p-0">
|
||||
<div className="hidden lg:block">
|
||||
<div>
|
||||
<Image
|
||||
src="/images/help/help.png"
|
||||
width={756}
|
||||
height={756}
|
||||
alt="Help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="h2 mb-4 md:mb-6">Need help?</h2>
|
||||
<p className="body-2 mb-5 text-n-3">
|
||||
Can’t find your answer, contact us
|
||||
</p>
|
||||
<ul>
|
||||
{[
|
||||
{
|
||||
id: "0",
|
||||
title: "Join our community",
|
||||
text: "Discuss anything with other users",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
title: "Email us",
|
||||
text: "hello@brainwave.com",
|
||||
},
|
||||
].map((item) => (
|
||||
<li
|
||||
className="flex items-center py-10 border-b border-n-1/15"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex items-center justify-center w-15 h-15 mr-10 bg-n-7 border border-n-1/15 rounded-xl">
|
||||
<Image
|
||||
src="/images/icons/building-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Contact"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="h6 text-n-3">{item.title}</h6>
|
||||
<p className="body-2 text-n-2">{item.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Help;
|
||||
@@ -1,261 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import ScrollIntoView from "react-scroll-into-view";
|
||||
import Section from "@/components/Section";
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
import { navigation } from "@/mocks/how-to-use";
|
||||
|
||||
type HowToUseProps = {};
|
||||
|
||||
const HowToUse = ({}: HowToUseProps) => {
|
||||
const [openNavigation, setOpenNavigation] = useState<boolean>(false);
|
||||
const [openGroupId, setOpenGroudId] = useState<string | null>("g0");
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container md:py-10 lg:pt-16 xl:pt-20">
|
||||
<Heading
|
||||
textAlignClassName="text-center"
|
||||
titleLarge="How to use"
|
||||
textLarge="Get started with Brainwave - AI chat app today and experience the power of AI in your conversations!"
|
||||
/>
|
||||
<div className="relative max-w-[75rem] mb-15 mx-auto">
|
||||
<Image
|
||||
className="absolute top-6 left-6 w-6 opacity-30 pointer-events-none"
|
||||
src="/images/icons/search-md.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Search"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-[4.5rem] pl-[3.5rem] pr-10 bg-transparent border border-n-6 rounded-[2.25rem] outline-none transition-colors focus:border-n-5"
|
||||
type="text"
|
||||
placeholder="Search topic"
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:flex">
|
||||
<div className="mb-16 lg:flex-shrink-0 lg:w-[19rem] lg:mr-10 xl:mr-20">
|
||||
<div
|
||||
className="flex items-center justify-between w-full h-16 px-6 bg-n-7 rounded-xl cursor-pointer lg:hidden"
|
||||
onClick={() => setOpenNavigation(!openNavigation)}
|
||||
>
|
||||
<div className="h6 text-n-1/50">
|
||||
Getting started
|
||||
</div>
|
||||
<Image
|
||||
src="/images/icons/chevron-down.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Arrow"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`pt-4 ${
|
||||
openNavigation ? "block" : "hidden"
|
||||
} lg:block lg:pt-0`}
|
||||
>
|
||||
{navigation.map((group) => (
|
||||
<div className="mb-5" key={group.id}>
|
||||
<button
|
||||
className="flex items-start py-3.5"
|
||||
onClick={() => setOpenGroudId(group.id)}
|
||||
>
|
||||
<div
|
||||
className={`relative w-6 h-6 my-0.5 mr-5 p-0.5 ${
|
||||
group.id === openGroupId
|
||||
? "bg-n-7 border-[0.125rem] border-n-1/15 rounded-md"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
className={`relative z-1 w-full transition-transform ${
|
||||
group.id === openGroupId
|
||||
? "rotate-90"
|
||||
: ""
|
||||
}`}
|
||||
src="/images/icons/chevron-right.svg"
|
||||
width={16}
|
||||
height={16}
|
||||
alt="Arrow"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xl">
|
||||
{group.title}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={`grid grid-rows-[0fr] transition-all ${
|
||||
group.id === openGroupId
|
||||
? "grid-rows-[1fr]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ul className="overflow-hidden">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<ScrollIntoView
|
||||
className="body-2 block py-3 pl-11 text-n-3 transition-colors hover:text-color-1 cursor-pointer"
|
||||
selector={`#anchor-${group.id}-${item.id}`}
|
||||
>
|
||||
<span>
|
||||
{item.title}
|
||||
</span>
|
||||
</ScrollIntoView>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="h3 mb-16 pb-8 border-b border-n-1/15">
|
||||
Getting started
|
||||
</h3>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">Sign up</h4>
|
||||
<Tagline className="ml-4 mt-4">01</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-1.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
{`To create an account with Brainwave - AI
|
||||
chat app, all you need to do is provide
|
||||
your name, email address, and password.
|
||||
Once you have signed up, you will be
|
||||
able to start exploring the app's
|
||||
various features. Brainwave's AI chat
|
||||
system is designed to provide you with
|
||||
an intuitive, easy-to-use interface that
|
||||
makes it simple to chat with friends and
|
||||
family, or even with new acquaintances.`}
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
In addition, the app is constantly being
|
||||
updated with new features and improvements,
|
||||
so you can expect it to continue to evolve
|
||||
and improve over time. Whether you are
|
||||
looking for a simple chat app, or a more
|
||||
advanced platform that can help you stay
|
||||
connected with people from all over the
|
||||
world, Brainwave is the perfect choice.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-0`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">
|
||||
Connect with AI Chatbot
|
||||
</h4>
|
||||
<Tagline className="ml-4 mt-4">02</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-2.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 2"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
Connect with the AI chatbot to start the
|
||||
conversation. The chatbot uses natural
|
||||
language processing to understand your
|
||||
queries and provide relevant responses.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-1`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">
|
||||
Get Personalized Advices
|
||||
</h4>
|
||||
<Tagline className="ml-4 mt-4">03</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-3.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 3"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
Based on the conversation with the AI
|
||||
chatbot, you will receive personalized
|
||||
recommendations related to your queries. The
|
||||
chatbot is trained to understand your
|
||||
preferences and provide customized
|
||||
suggestions.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-2`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">
|
||||
Explore and Engage
|
||||
</h4>
|
||||
<Tagline className="ml-4 mt-4">04</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-4.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
Explore the recommendations provided by the
|
||||
AI chatbot and engage with the app. You can
|
||||
ask questions, provide feedback, and share
|
||||
your experience with the chatbot.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-3`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button>Read more</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowToUse;
|
||||
@@ -1,261 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import ScrollIntoView from "react-scroll-into-view";
|
||||
import Section from "@/components/Section";
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
import Tagline from "@/components/Tagline";
|
||||
import Button from "@/components/Button";
|
||||
|
||||
import { navigation } from "@/mocks/how-to-use";
|
||||
|
||||
type HowToUseProps = {};
|
||||
|
||||
const HowToUse = ({}: HowToUseProps) => {
|
||||
const [openNavigation, setOpenNavigation] = useState<boolean>(false);
|
||||
const [openGroupId, setOpenGroudId] = useState<string | null>("g0");
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container md:py-10 lg:pt-16 xl:pt-20">
|
||||
<Heading
|
||||
textAlignClassName="text-center"
|
||||
titleLarge="How to use"
|
||||
textLarge="Get started with Brainwave - AI chat app today and experience the power of AI in your conversations!"
|
||||
/>
|
||||
<div className="relative max-w-[75rem] mb-15 mx-auto">
|
||||
<Image
|
||||
className="absolute top-6 left-6 w-6 opacity-30 pointer-events-none"
|
||||
src="/images/icons/search-md.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Search"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-[4.5rem] pl-[3.5rem] pr-10 bg-transparent border border-n-6 rounded-[2.25rem] outline-none transition-colors focus:border-n-5"
|
||||
type="text"
|
||||
placeholder="Search topic"
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:flex">
|
||||
<div className="mb-16 lg:flex-shrink-0 lg:w-[19rem] lg:mr-10 xl:mr-20">
|
||||
<div
|
||||
className="flex items-center justify-between w-full h-16 px-6 bg-n-7 rounded-xl cursor-pointer lg:hidden"
|
||||
onClick={() => setOpenNavigation(!openNavigation)}
|
||||
>
|
||||
<div className="h6 text-n-1/50">
|
||||
Getting started
|
||||
</div>
|
||||
<Image
|
||||
src="/images/icons/chevron-down.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Arrow"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`pt-4 ${
|
||||
openNavigation ? "block" : "hidden"
|
||||
} lg:block lg:pt-0`}
|
||||
>
|
||||
{navigation.map((group) => (
|
||||
<div className="mb-5" key={group.id}>
|
||||
<button
|
||||
className="flex items-start py-3.5"
|
||||
onClick={() => setOpenGroudId(group.id)}
|
||||
>
|
||||
<div
|
||||
className={`relative w-6 h-6 my-0.5 mr-5 p-0.5 ${
|
||||
group.id === openGroupId
|
||||
? "bg-n-7 border-[0.125rem] border-n-1/15 rounded-md"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
className={`relative z-1 w-full transition-transform ${
|
||||
group.id === openGroupId
|
||||
? "rotate-90"
|
||||
: ""
|
||||
}`}
|
||||
src="/images/icons/chevron-right.svg"
|
||||
width={16}
|
||||
height={16}
|
||||
alt="Arrow"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xl">
|
||||
{group.title}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={`grid grid-rows-[0fr] transition-all ${
|
||||
group.id === openGroupId
|
||||
? "grid-rows-[1fr]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ul className="overflow-hidden">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<ScrollIntoView
|
||||
className="body-2 block py-3 pl-11 text-n-3 transition-colors hover:text-color-1 cursor-pointer"
|
||||
selector={`#anchor-${group.id}-${item.id}`}
|
||||
>
|
||||
<span>
|
||||
{item.title}
|
||||
</span>
|
||||
</ScrollIntoView>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="h3 mb-16 pb-8 border-b border-n-1/15">
|
||||
Getting started
|
||||
</h3>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">Sign up</h4>
|
||||
<Tagline className="ml-4 mt-4">01</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-1.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
{`To create an account with Brainwave - AI
|
||||
chat app, all you need to do is provide
|
||||
your name, email address, and password.
|
||||
Once you have signed up, you will be
|
||||
able to start exploring the app's
|
||||
various features. Brainwave's AI chat
|
||||
system is designed to provide you with
|
||||
an intuitive, easy-to-use interface that
|
||||
makes it simple to chat with friends and
|
||||
family, or even with new acquaintances.`}
|
||||
</p>
|
||||
<p className="mb-6">
|
||||
In addition, the app is constantly being
|
||||
updated with new features and improvements,
|
||||
so you can expect it to continue to evolve
|
||||
and improve over time. Whether you are
|
||||
looking for a simple chat app, or a more
|
||||
advanced platform that can help you stay
|
||||
connected with people from all over the
|
||||
world, Brainwave is the perfect choice.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-0`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">
|
||||
Connect with AI Chatbot
|
||||
</h4>
|
||||
<Tagline className="ml-4 mt-4">02</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-2.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 2"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
Connect with the AI chatbot to start the
|
||||
conversation. The chatbot uses natural
|
||||
language processing to understand your
|
||||
queries and provide relevant responses.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-1`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">
|
||||
Get Personalized Advices
|
||||
</h4>
|
||||
<Tagline className="ml-4 mt-4">03</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-3.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 3"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
Based on the conversation with the AI
|
||||
chatbot, you will receive personalized
|
||||
recommendations related to your queries. The
|
||||
chatbot is trained to understand your
|
||||
preferences and provide customized
|
||||
suggestions.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-2`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative mb-16">
|
||||
<div className="flex items-start mb-10">
|
||||
<h4 className="h4 mr-auto">
|
||||
Explore and Engage
|
||||
</h4>
|
||||
<Tagline className="ml-4 mt-4">04</Tagline>
|
||||
</div>
|
||||
<div className="mb-10">
|
||||
<Image
|
||||
className="w-full h-full object-cover rounded-3xl"
|
||||
src="/images/how-to-use/image-4.jpg"
|
||||
width={896}
|
||||
height={600}
|
||||
alt="Image 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="body-2 text-n-2">
|
||||
<p className="mb-6">
|
||||
Explore the recommendations provided by the
|
||||
AI chatbot and engage with the app. You can
|
||||
ask questions, provide feedback, and share
|
||||
your experience with the chatbot.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id={`anchor-${openGroupId}-3`}
|
||||
className="absolute -top-32 left-0 w-full h-0.25"
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button>Read more</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowToUse;
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import HowToUse from "./HowToUse";
|
||||
import Help from "./Help";
|
||||
|
||||
const HowToUsePage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<HowToUse />
|
||||
<Help />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowToUsePage;
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import HowToUse from "./HowToUse";
|
||||
import Help from "./Help";
|
||||
|
||||
const HowToUsePage = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<HowToUse />
|
||||
<Help />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowToUsePage;
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
import Layout from "@/components/Layout";
|
||||
import Section from "@/components/Section";
|
||||
|
||||
const LoginPage = ({}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const signUp = searchParams.has("new");
|
||||
|
||||
return (
|
||||
<Layout hideFooter>
|
||||
<Section className="flex min-h-[calc(100vh-4.8125rem)] overflow-hidden lg:min-h-[calc(100vh-5.3125rem)]">
|
||||
<div className="container relative z-2 max-w-[68rem] m-auto lg:flex lg:justify-between">
|
||||
<div className="max-w-[32.875rem] mx-auto mb-12 text-center md:mb-16 lg:flex lg:flex-col lg:justify-around lg:max-w-[23.75rem] lg:m-0 lg:text-left">
|
||||
<h2 className="h2">
|
||||
Join the AI revolution with Brainwave
|
||||
</h2>
|
||||
<p className="hidden body-2 mt-4 text-n-4 md:block">
|
||||
Get started with Brainwave - AI chat app today and
|
||||
experience the power of AI in your conversations!
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="relative max-w-[23.5rem] mx-auto p-0.25 bg-conic-gradient rounded-3xl lg:flex-1 lg:max-w-[27.5rem] lg:m-0 xl:mr-12"
|
||||
action=""
|
||||
>
|
||||
<div className="px-9 py-10 bg-n-8 rounded-[1.4375rem] lg:px-16 lg:py-[3.25rem]">
|
||||
{signUp && (
|
||||
<div className="relative mb-4 lg:mb-5">
|
||||
<Image
|
||||
className="absolute top-4 left-0 w-6 pointer-events-none"
|
||||
src="/images/icons/mail-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Mail"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-4 lg:mb-5">
|
||||
<Image
|
||||
className="absolute top-4 left-0 w-6 pointer-events-none"
|
||||
src="/images/icons/mail-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Mail"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
|
||||
type="text"
|
||||
placeholder="Email"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative mb-4 lg:mb-5">
|
||||
<Image
|
||||
className="absolute top-4 left-0 w-6 pointer-events-none"
|
||||
src="/images/icons/lock-03.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Lock"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
|
||||
type="text"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" white>
|
||||
{signUp ? "Sign up now" : "Sign in"}
|
||||
</Button>
|
||||
<div className="mt-10">
|
||||
<div className="caption mb-6 text-n-4 text-center">
|
||||
Or start your Brainwave with
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
|
||||
href="#"
|
||||
>
|
||||
<svg width="24" height="24">
|
||||
<path
|
||||
fill="#757185"
|
||||
d="M23.049 10h-10.5v4.5h5.951c-.951 3-3.3 4-5.999 4a6.5 6.5 0 0 1-5.33-2.768 6.5 6.5 0 0 1-.787-5.954 6.5 6.5 0 0 1 4.428-4.057 6.5 6.5 0 0 1 5.863 1.302l3.27-3.117a11 11 0 0 0-9.931-2.623 11 11 0 0 0-7.768 6.721A11 11 0 0 0 3.414 18.21 11 11 0 0 0 12.501 23c6.066 0 11.55-4 10.548-13z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
|
||||
href="#"
|
||||
>
|
||||
<svg width="24" height="24">
|
||||
<path
|
||||
fill="#757185"
|
||||
d="M21.356 16.252c-1.338-.506-2.233-1.721-2.334-3.17-.099-1.412.593-2.666 1.851-3.355l1.046-.573-.747-.93c-1.255-1.563-3.051-2.497-4.804-2.497-1.215 0-2.058.318-2.735.574-.478.181-.855.323-1.269.323-.472 0-.938-.166-1.478-.358-.708-.252-1.51-.538-2.54-.538-1.99 0-3.997 1.188-5.237 3.098-1.851 2.849-1.343 7.734 1.208 11.616 1.011 1.538 2.428 3.305 4.435 3.323h.039c1.643 0 2.003-.876 3.598-.886 1.742.082 1.962.893 3.589.882 1.961-.018 3.375-1.771 4.499-3.484.664-1.007.921-1.534 1.438-2.678l.438-.97-.997-.377zM15.103 3.214c.65-.834 1.143-2.011.964-3.214-1.062.073-2.302.748-3.027 1.628-.658.799-1.201 1.983-.99 3.135 1.158.036 2.357-.656 3.053-1.549z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden absolute top-6 -right-12 bottom-6 xl:flex">
|
||||
<div className="w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
|
||||
<div className="w-6 my-12 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="hidden absolute left-5 right-5 bottom-5 z-4 h-0.25 bg-n-6 pointer-events-none md:block lg:left-7.5 lg:right-7.5 lg:bottom-7.5 xl:left-10 xl:right-10 xl:bottom-10"></div>
|
||||
<svg
|
||||
className="hidden absolute left-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:left-[1.5625rem] lg:bottom-[1.5625rem] xl:left-[2.1875rem] xl:bottom-[2.1875rem]"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className="hidden absolute right-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:right-[1.5625rem] lg:bottom-[1.5625rem] xl:right-[2.1875rem] xl:bottom-[2.1875rem]"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src="/images/login/background.jpg"
|
||||
width={1920}
|
||||
height={1080}
|
||||
quality={100}
|
||||
alt="Background"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Button from "@/components/Button";
|
||||
import Image from "@/components/Image";
|
||||
import Layout from "@/components/Layout";
|
||||
import Section from "@/components/Section";
|
||||
|
||||
const LoginPage = ({}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const signUp = searchParams.has("new");
|
||||
|
||||
return (
|
||||
<Layout hideFooter>
|
||||
<Section className="flex min-h-[calc(100vh-4.8125rem)] overflow-hidden lg:min-h-[calc(100vh-5.3125rem)]">
|
||||
<div className="container relative z-2 max-w-[68rem] m-auto lg:flex lg:justify-between">
|
||||
<div className="max-w-[32.875rem] mx-auto mb-12 text-center md:mb-16 lg:flex lg:flex-col lg:justify-around lg:max-w-[23.75rem] lg:m-0 lg:text-left">
|
||||
<h2 className="h2">
|
||||
Join the AI revolution with Brainwave
|
||||
</h2>
|
||||
<p className="hidden body-2 mt-4 text-n-4 md:block">
|
||||
Get started with Brainwave - AI chat app today and
|
||||
experience the power of AI in your conversations!
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="relative max-w-[23.5rem] mx-auto p-0.25 bg-conic-gradient rounded-3xl lg:flex-1 lg:max-w-[27.5rem] lg:m-0 xl:mr-12"
|
||||
action=""
|
||||
>
|
||||
<div className="px-9 py-10 bg-n-8 rounded-[1.4375rem] lg:px-16 lg:py-[3.25rem]">
|
||||
{signUp && (
|
||||
<div className="relative mb-4 lg:mb-5">
|
||||
<Image
|
||||
className="absolute top-4 left-0 w-6 pointer-events-none"
|
||||
src="/images/icons/mail-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Mail"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-4 lg:mb-5">
|
||||
<Image
|
||||
className="absolute top-4 left-0 w-6 pointer-events-none"
|
||||
src="/images/icons/mail-01.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Mail"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
|
||||
type="text"
|
||||
placeholder="Email"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative mb-4 lg:mb-5">
|
||||
<Image
|
||||
className="absolute top-4 left-0 w-6 pointer-events-none"
|
||||
src="/images/icons/lock-03.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Lock"
|
||||
/>
|
||||
<input
|
||||
className="w-full h-14 pl-12 bg-transparent border-b border-n-1/15 font-light placeholder:text-n-4 outline-none transition-colors focus:border-n-1/30"
|
||||
type="text"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" white>
|
||||
{signUp ? "Sign up now" : "Sign in"}
|
||||
</Button>
|
||||
<div className="mt-10">
|
||||
<div className="caption mb-6 text-n-4 text-center">
|
||||
Or start your Brainwave with
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
|
||||
href="#"
|
||||
>
|
||||
<svg width="24" height="24">
|
||||
<path
|
||||
fill="#757185"
|
||||
d="M23.049 10h-10.5v4.5h5.951c-.951 3-3.3 4-5.999 4a6.5 6.5 0 0 1-5.33-2.768 6.5 6.5 0 0 1-.787-5.954 6.5 6.5 0 0 1 4.428-4.057 6.5 6.5 0 0 1 5.863 1.302l3.27-3.117a11 11 0 0 0-9.931-2.623 11 11 0 0 0-7.768 6.721A11 11 0 0 0 3.414 18.21 11 11 0 0 0 12.501 23c6.066 0 11.55-4 10.548-13z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center justify-center w-12 h-12 mx-3 border border-n-1/5 rounded-full transition-colors hover:border-n-1/15"
|
||||
href="#"
|
||||
>
|
||||
<svg width="24" height="24">
|
||||
<path
|
||||
fill="#757185"
|
||||
d="M21.356 16.252c-1.338-.506-2.233-1.721-2.334-3.17-.099-1.412.593-2.666 1.851-3.355l1.046-.573-.747-.93c-1.255-1.563-3.051-2.497-4.804-2.497-1.215 0-2.058.318-2.735.574-.478.181-.855.323-1.269.323-.472 0-.938-.166-1.478-.358-.708-.252-1.51-.538-2.54-.538-1.99 0-3.997 1.188-5.237 3.098-1.851 2.849-1.343 7.734 1.208 11.616 1.011 1.538 2.428 3.305 4.435 3.323h.039c1.643 0 2.003-.876 3.598-.886 1.742.082 1.962.893 3.589.882 1.961-.018 3.375-1.771 4.499-3.484.664-1.007.921-1.534 1.438-2.678l.438-.97-.997-.377zM15.103 3.214c.65-.834 1.143-2.011.964-3.214-1.062.073-2.302.748-3.027 1.628-.658.799-1.201 1.983-.99 3.135 1.158.036 2.357-.656 3.053-1.549z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden absolute top-6 -right-12 bottom-6 xl:flex">
|
||||
<div className="w-6 bg-[#1B1B2E] rounded-r-3xl"></div>
|
||||
<div className="w-6 my-12 bg-[#1B1B2E]/50 rounded-r-3xl"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="hidden absolute left-5 right-5 bottom-5 z-4 h-0.25 bg-n-6 pointer-events-none md:block lg:left-7.5 lg:right-7.5 lg:bottom-7.5 xl:left-10 xl:right-10 xl:bottom-10"></div>
|
||||
<svg
|
||||
className="hidden absolute left-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:left-[1.5625rem] lg:bottom-[1.5625rem] xl:left-[2.1875rem] xl:bottom-[2.1875rem]"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className="hidden absolute right-[0.9375rem] bottom-[0.9375rem] z-4 pointer-events-none md:block lg:right-[1.5625rem] lg:bottom-[1.5625rem] xl:right-[2.1875rem] xl:bottom-[2.1875rem]"
|
||||
width="11"
|
||||
height="11"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||
fill="#ada8c4"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
className="w-full h-full object-cover"
|
||||
src="/images/login/background.jpg"
|
||||
width={1920}
|
||||
height={1080}
|
||||
quality={100}
|
||||
alt="Background"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type CarouselProps = {
|
||||
items: any;
|
||||
};
|
||||
|
||||
const Carousel = ({ items }: CarouselProps) => {
|
||||
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-visible relative z-2"
|
||||
options={{
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
gap: "1.5rem",
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{items.map((item: any) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="flex h-full">
|
||||
<Comment comment={item} />
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
|
||||
{items.map((item: any, index: number) => (
|
||||
<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 Carousel;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Splide, SplideTrack, SplideSlide } from "@splidejs/react-splide";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type CarouselProps = {
|
||||
items: any;
|
||||
};
|
||||
|
||||
const Carousel = ({ items }: CarouselProps) => {
|
||||
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-visible relative z-2"
|
||||
options={{
|
||||
pagination: false,
|
||||
arrows: false,
|
||||
gap: "1.5rem",
|
||||
}}
|
||||
onMoved={(e, newIndex) => setActiveIndex(newIndex)}
|
||||
hasTrack={false}
|
||||
ref={ref}
|
||||
>
|
||||
<SplideTrack>
|
||||
{items.map((item: any) => (
|
||||
<SplideSlide key={item.id}>
|
||||
<div className="flex h-full">
|
||||
<Comment comment={item} />
|
||||
</div>
|
||||
</SplideSlide>
|
||||
))}
|
||||
</SplideTrack>
|
||||
<div className="flex justify-center mt-8 -mx-2 md:mt-15 lg:hidden">
|
||||
{items.map((item: any, index: number) => (
|
||||
<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 Carousel;
|
||||
@@ -1,28 +0,0 @@
|
||||
import Image from "@/components/Image";
|
||||
|
||||
type CommentProps = {
|
||||
comment: any;
|
||||
};
|
||||
|
||||
const Comment = ({ comment }: CommentProps) => (
|
||||
<div className="flex flex-col bg-n-8 border border-n-1/5 rounded-2xl">
|
||||
<div className="quote flex-1 px-5 py-10 md:px-10">{comment.text}</div>
|
||||
<div className="flex items-center px-5 py-6 bg-n-7 rounded-b-[0.9375rem] md:px-10">
|
||||
<div className="mr-5">
|
||||
<h6 className="h6">{comment.name}</h6>
|
||||
<div className="caption text-n-1/25">{comment.role}</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Image
|
||||
className="w-full rounded-full"
|
||||
src={comment.avatarUrl}
|
||||
width={60}
|
||||
height={60}
|
||||
alt={comment.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Comment;
|
||||
@@ -1,28 +0,0 @@
|
||||
import Image from "@/components/Image";
|
||||
|
||||
type CommentProps = {
|
||||
comment: any;
|
||||
};
|
||||
|
||||
const Comment = ({ comment }: CommentProps) => (
|
||||
<div className="flex flex-col bg-n-8 border border-n-1/5 rounded-2xl">
|
||||
<div className="quote flex-1 px-5 py-10 md:px-10">{comment.text}</div>
|
||||
<div className="flex items-center px-5 py-6 bg-n-7 rounded-b-[0.9375rem] md:px-10">
|
||||
<div className="mr-5">
|
||||
<h6 className="h6">{comment.name}</h6>
|
||||
<div className="caption text-n-1/25">{comment.role}</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Image
|
||||
className="w-full rounded-full"
|
||||
src={comment.avatarUrl}
|
||||
width={60}
|
||||
height={60}
|
||||
alt={comment.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Comment;
|
||||
@@ -1,25 +0,0 @@
|
||||
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type GridProps = {
|
||||
items: any;
|
||||
};
|
||||
|
||||
const Grid = ({ items }: GridProps) => {
|
||||
return (
|
||||
<ResponsiveMasonry
|
||||
className="relative z-2"
|
||||
columnsCountBreakPoints={{ 768: 2, 1280: 3 }}
|
||||
>
|
||||
<Masonry gutter="1.5rem">
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id}>
|
||||
<Comment comment={item} />
|
||||
</div>
|
||||
))}
|
||||
</Masonry>
|
||||
</ResponsiveMasonry>
|
||||
);
|
||||
};
|
||||
|
||||
export default Grid;
|
||||
@@ -1,25 +0,0 @@
|
||||
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type GridProps = {
|
||||
items: any;
|
||||
};
|
||||
|
||||
const Grid = ({ items }: GridProps) => {
|
||||
return (
|
||||
<ResponsiveMasonry
|
||||
className="relative z-2"
|
||||
columnsCountBreakPoints={{ 768: 2, 1280: 3 }}
|
||||
>
|
||||
<Masonry gutter="1.5rem">
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id}>
|
||||
<Comment comment={item} />
|
||||
</div>
|
||||
))}
|
||||
</Masonry>
|
||||
</ResponsiveMasonry>
|
||||
);
|
||||
};
|
||||
|
||||
export default Grid;
|
||||
@@ -1,50 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import Section from "@/components/Section";
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
const Grid = dynamic(() => import("./Grid"), { ssr: false });
|
||||
const Carousel = dynamic(() => import("./Carousel"), { ssr: false });
|
||||
|
||||
import { community } from "@/mocks/community";
|
||||
|
||||
type CommunityProps = {};
|
||||
|
||||
const Community = ({}: CommunityProps) => {
|
||||
const isTablet = useMediaQuery({
|
||||
query: "(min-width: 768px)",
|
||||
});
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container">
|
||||
<Heading
|
||||
className="md:text-center"
|
||||
tagClassName="md:justify-center"
|
||||
tag="ready to get started"
|
||||
title="What the community is saying"
|
||||
/>
|
||||
<div className="relative">
|
||||
{isTablet ? (
|
||||
<Grid items={community} />
|
||||
) : (
|
||||
<Carousel items={community} />
|
||||
)}
|
||||
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/gradient.png"
|
||||
width={942}
|
||||
height={942}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
@@ -1,50 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
import Section from "@/components/Section";
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
const Grid = dynamic(() => import("./Grid"), { ssr: false });
|
||||
const Carousel = dynamic(() => import("./Carousel"), { ssr: false });
|
||||
|
||||
import { community } from "@/mocks/community";
|
||||
|
||||
type CommunityProps = {};
|
||||
|
||||
const Community = ({}: CommunityProps) => {
|
||||
const isTablet = useMediaQuery({
|
||||
query: "(min-width: 768px)",
|
||||
});
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container">
|
||||
<Heading
|
||||
className="md:text-center"
|
||||
tagClassName="md:justify-center"
|
||||
tag="ready to get started"
|
||||
title="What the community is saying"
|
||||
/>
|
||||
<div className="relative">
|
||||
{isTablet ? (
|
||||
<Grid items={community} />
|
||||
) : (
|
||||
<Carousel items={community} />
|
||||
)}
|
||||
<div className="absolute top-[18.25rem] -left-[30.375rem] w-[56.625rem] opacity-60 mix-blend-color-dodge pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
||||
<Image
|
||||
className="w-full"
|
||||
src="/images/gradient.png"
|
||||
width={942}
|
||||
height={942}
|
||||
alt="Gradient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
@@ -1,101 +0,0 @@
|
||||
import Tippy from "@tippyjs/react";
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
import Section from "@/components/Section";
|
||||
|
||||
import { comparison } from "@/mocks/comparison";
|
||||
|
||||
type ComparisonProps = {};
|
||||
|
||||
const Comparison = ({}: ComparisonProps) => {
|
||||
const check = (value: any, enterprise?: boolean) =>
|
||||
typeof value === "boolean" ? (
|
||||
value === true ? (
|
||||
<Image
|
||||
src={
|
||||
enterprise
|
||||
? "/images/check-yellow.svg"
|
||||
: "/images/check.svg"
|
||||
}
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
value
|
||||
);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container">
|
||||
<Heading
|
||||
className="md:text-center"
|
||||
title="Compare plans & features"
|
||||
/>
|
||||
<div className="-mx-5 px-5 overflow-auto">
|
||||
<table className="table-fixed w-full min-w-[32rem]">
|
||||
<tbody>
|
||||
<tr className="h6">
|
||||
<td className="w-[35%] py-4 pr-10">Features</td>
|
||||
<td className="p-4 text-center text-color-2">
|
||||
Basic
|
||||
</td>
|
||||
<td className="p-4 text-center text-color-1">
|
||||
Premium
|
||||
</td>
|
||||
<td className="p-4 text-center text-color-3">
|
||||
Enterprise
|
||||
</td>
|
||||
</tr>
|
||||
{comparison.map((item) => (
|
||||
<tr className="body-2" key={item.id}>
|
||||
<td className="w-[35%] h-[4.75rem] py-2.5 pr-2.5 border-t border-n-1/5">
|
||||
<div className="flex items-center">
|
||||
{item.title}
|
||||
<Tippy
|
||||
className="p-2.5 bg-n-1 text-n-8 rounded-xl"
|
||||
content="Provide dedicated servers for enterprises to ensure maximum security, performance, and uptime."
|
||||
placement="right"
|
||||
animation="shift-toward"
|
||||
>
|
||||
<div className="flex-shrink-0 ml-3 opacity-30 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/images/icons/help-circle.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Help"
|
||||
/>
|
||||
</div>
|
||||
</Tippy>
|
||||
</div>
|
||||
</td>
|
||||
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
|
||||
{check(
|
||||
item.pricing[0],
|
||||
item.enterprise
|
||||
)}
|
||||
</td>
|
||||
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
|
||||
{check(
|
||||
item.pricing[1],
|
||||
item.enterprise
|
||||
)}
|
||||
</td>
|
||||
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
|
||||
{check(
|
||||
item.pricing[2],
|
||||
item.enterprise
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comparison;
|
||||
@@ -1,101 +0,0 @@
|
||||
import Tippy from "@tippyjs/react";
|
||||
import Heading from "@/components/Heading";
|
||||
import Image from "@/components/Image";
|
||||
import Section from "@/components/Section";
|
||||
|
||||
import { comparison } from "@/mocks/comparison";
|
||||
|
||||
type ComparisonProps = {};
|
||||
|
||||
const Comparison = ({}: ComparisonProps) => {
|
||||
const check = (value: any, enterprise?: boolean) =>
|
||||
typeof value === "boolean" ? (
|
||||
value === true ? (
|
||||
<Image
|
||||
src={
|
||||
enterprise
|
||||
? "/images/check-yellow.svg"
|
||||
: "/images/check.svg"
|
||||
}
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Check"
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
value
|
||||
);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container">
|
||||
<Heading
|
||||
className="md:text-center"
|
||||
title="Compare plans & features"
|
||||
/>
|
||||
<div className="-mx-5 px-5 overflow-auto">
|
||||
<table className="table-fixed w-full min-w-[32rem]">
|
||||
<tbody>
|
||||
<tr className="h6">
|
||||
<td className="w-[35%] py-4 pr-10">Features</td>
|
||||
<td className="p-4 text-center text-color-2">
|
||||
Basic
|
||||
</td>
|
||||
<td className="p-4 text-center text-color-1">
|
||||
Premium
|
||||
</td>
|
||||
<td className="p-4 text-center text-color-3">
|
||||
Enterprise
|
||||
</td>
|
||||
</tr>
|
||||
{comparison.map((item) => (
|
||||
<tr className="body-2" key={item.id}>
|
||||
<td className="w-[35%] h-[4.75rem] py-2.5 pr-2.5 border-t border-n-1/5">
|
||||
<div className="flex items-center">
|
||||
{item.title}
|
||||
<Tippy
|
||||
className="p-2.5 bg-n-1 text-n-8 rounded-xl"
|
||||
content="Provide dedicated servers for enterprises to ensure maximum security, performance, and uptime."
|
||||
placement="right"
|
||||
animation="shift-toward"
|
||||
>
|
||||
<div className="flex-shrink-0 ml-3 opacity-30 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/images/icons/help-circle.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Help"
|
||||
/>
|
||||
</div>
|
||||
</Tippy>
|
||||
</div>
|
||||
</td>
|
||||
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
|
||||
{check(
|
||||
item.pricing[0],
|
||||
item.enterprise
|
||||
)}
|
||||
</td>
|
||||
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
|
||||
{check(
|
||||
item.pricing[1],
|
||||
item.enterprise
|
||||
)}
|
||||
</td>
|
||||
<td className="h-[4.75rem] p-2.5 border-t border-n-1/5 text-center">
|
||||
{check(
|
||||
item.pricing[2],
|
||||
item.enterprise
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comparison;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Section from "@/components/Section";
|
||||
import Heading from "@/components/Heading";
|
||||
|
||||
import { faq } from "@/mocks/faq";
|
||||
|
||||
type FaqProps = {};
|
||||
|
||||
const Faq = ({}: FaqProps) => {
|
||||
const [activeId, setActiveId] = useState<string | null>(faq[0].id);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="container lg:flex">
|
||||
<Heading
|
||||
className="lg:min-w-[22.75rem] lg:mr-12 lg:pt-8 xl:min-w-[32.75rem]"
|
||||
textAlignClassName="md:text-center lg:text-left"
|
||||
title="Frequently asked questions"
|
||||
text={
|
||||
<>
|
||||
Haven’t found what you’re looking for?{" "}
|
||||
<a
|
||||
className="text-n-1 hover:text-color-2"
|
||||
href="mailto:info@ui8.net"
|
||||
>
|
||||
Contact us
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="-mt-8 lg:mt-0">
|
||||
{faq.map((item) => (
|
||||
<div
|
||||
className="py-8 border-b border-n-1/5"
|
||||
key={item.id}
|
||||
>
|
||||
<div
|
||||
className="flex items-start justify-between cursor-pointer"
|
||||
onClick={() =>
|
||||
setActiveId(
|
||||
activeId === item.id ? null : item.id
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="text-[1.25rem] leading-8">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="relative w-6 h-6 mt-1 ml-10">
|
||||
<div className="absolute top-[0.6875rem] left-1 w-4 h-0.5 bg-n-1 rounded-sm"></div>
|
||||
<div
|
||||
className={`absolute top-[0.6875rem] left-1 w-4 h-0.5 bg-n-1 rounded-sm transition-transform ${
|
||||
item.id === activeId
|
||||
? ""
|
||||
: "rotate-90"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`grid grid-rows-[0fr] transition-all ${
|
||||
item.id === activeId
|
||||
? "grid-rows-[1fr]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="body-2 text-n-3 overflow-hidden">
|
||||
<div className="pt-6">{item.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Faq;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user