mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
Merge branch 'dev' into fix/bug-fixes-collection-v2
This commit is contained in:
@@ -1,33 +1,44 @@
|
||||
# Pull Request | PR 提交
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 Recommended PR title format: `type(scope): description`
|
||||
> 例如 Examples: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
> 详情 Details: [PR Title Guide](./PR_TITLE_GUIDE.md)
|
||||
> **📋 选择专用模板 | Choose Specialized Template**
|
||||
>
|
||||
> 我们现在提供了针对不同类型PR的专用模板,帮助你更快速地填写PR信息:
|
||||
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
||||
>
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** | 后端PR模板 - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes
|
||||
>
|
||||
> **如何使用?| How to use?**
|
||||
> - 创建PR时,在URL中添加 `?template=backend.md` 或其他模板名称
|
||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||
> - 或者直接复制粘贴对应模板的内容
|
||||
> - Or simply copy and paste the content from the corresponding template
|
||||
|
||||
---
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
<!-- Provide a brief summary of your changes -->
|
||||
<!-- 简要描述你的变更 -->
|
||||
**English:** | **中文:**
|
||||
|
||||
**English:**
|
||||
|
||||
**中文:**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
|
||||
<!-- Mark the relevant option with an "x" -->
|
||||
<!-- 在相关选项上打"x" -->
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug(不影响现有功能的修复)
|
||||
- [ ] ✨ New feature | 新功能(不影响现有功能的新增)
|
||||
- [ ] 💥 Breaking change | 破坏性变更(会导致现有功能无法正常工作的修复或功能)
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新(格式化、重命名等)
|
||||
- [ ] ♻️ Refactoring | 重构(无功能变更)
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
@@ -37,9 +48,6 @@
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
|
||||
<!-- Link related issues below. Use "Closes #123" to auto-close issues when PR is merged -->
|
||||
<!-- 在下方关联相关 issue。使用 "Closes #123" 可以在 PR 合并时自动关闭 issue -->
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
@@ -47,242 +55,50 @@
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
<!-- List the specific changes you made -->
|
||||
<!-- 列出你做的具体变更 -->
|
||||
|
||||
**English:**
|
||||
- Change 1
|
||||
- Change 2
|
||||
- Change 3
|
||||
|
||||
**中文:**
|
||||
- 变更 1
|
||||
- 变更 2
|
||||
- 变更 3
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
|
||||
<!-- Describe how you tested your changes -->
|
||||
<!-- 描述你如何测试你的变更 -->
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
|
||||
- [ ] Tested with different configurations | 测试了不同配置
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
|
||||
### Test Environment | 测试环境
|
||||
|
||||
- **OS | 操作系统:** [e.g. macOS, Ubuntu, Windows]
|
||||
- **Go Version | Go 版本:** [e.g. 1.21.5]
|
||||
- **Node Version | Node 版本:** [e.g. 18.x] (if applicable | 如适用)
|
||||
- **Exchange | 交易所:** [if applicable | 如适用]
|
||||
|
||||
### Test Results | 测试结果
|
||||
|
||||
<!-- Paste relevant test output or describe results -->
|
||||
<!-- 粘贴相关测试输出或描述结果 -->
|
||||
|
||||
```
|
||||
Test output here | 测试输出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots / Demo | 截图/演示
|
||||
|
||||
<!-- If applicable, add screenshots or video demo -->
|
||||
<!-- 如适用,添加截图或视频演示 -->
|
||||
|
||||
<!-- For UI changes, include before/after screenshots -->
|
||||
<!-- 对于 UI 变更,包含变更前后的截图 -->
|
||||
|
||||
**Before | 变更前:**
|
||||
|
||||
|
||||
**After | 变更后:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
<!-- Mark completed items with an "x" -->
|
||||
<!-- 在已完成的项目上打"x" -->
|
||||
|
||||
### Code Quality | 代码质量
|
||||
|
||||
- [ ] My code follows the project's code style | 我的代码遵循项目代码风格 ([Contributing Guide](../CONTRIBUTING.md))
|
||||
- [ ] I have performed a self-review of my code | 我已进行代码自查
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas | 我已添加代码注释,特别是难以理解的部分
|
||||
- [ ] My changes generate no new warnings or errors | 我的变更没有产生新的警告或错误
|
||||
- [ ] Code compiles successfully | 代码编译成功 (`go build` / `npm run build`)
|
||||
- [ ] I have run `go fmt` (for Go code) | 我已运行 `go fmt`(Go 代码)
|
||||
- [ ] I have run `npm run lint` (for frontend code) | 我已运行 `npm run lint`(前端代码)
|
||||
|
||||
### Testing | 测试
|
||||
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works | 我已添加证明修复有效或功能正常的测试
|
||||
- [ ] New and existing unit tests pass locally | 新旧单元测试在本地通过
|
||||
- [ ] I have tested on testnet (for trading/exchange changes) | 我已在测试网测试(交易/交易所变更)
|
||||
- [ ] Integration tests pass | 集成测试通过
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
|
||||
### Documentation | 文档
|
||||
|
||||
- [ ] I have updated the documentation accordingly | 我已相应更新文档
|
||||
- [ ] I have updated the README if needed | 我已更新 README(如需要)
|
||||
- [ ] I have added inline code comments where necessary | 我已在必要处添加代码注释
|
||||
- [ ] I have updated type definitions (for TypeScript changes) | 我已更新类型定义(TypeScript 变更)
|
||||
- [ ] I have updated API documentation (if applicable) | 我已更新 API 文档(如适用)
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
|
||||
### Git
|
||||
|
||||
- [ ] My commits follow the conventional commits format | 我的提交遵循 Conventional Commits 格式 (`feat:`, `fix:`, etc.)
|
||||
- [ ] I have rebased my branch on the latest `dev` branch | 我已将分支 rebase 到最新的 `dev` 分支
|
||||
- [ ] There are no merge conflicts | 没有合并冲突
|
||||
- [ ] Commit messages are clear and descriptive | 提交信息清晰明确
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations | 安全考虑
|
||||
|
||||
<!-- Answer these questions for security-sensitive changes -->
|
||||
<!-- 对于安全敏感的变更,请回答以下问题 -->
|
||||
|
||||
- [ ] No API keys or secrets are hardcoded | 没有硬编码 API 密钥或密钥
|
||||
- [ ] User inputs are properly validated | 用户输入已正确验证
|
||||
- [ ] No SQL injection vulnerabilities introduced | 未引入 SQL 注入漏洞
|
||||
- [ ] No XSS vulnerabilities introduced | 未引入 XSS 漏洞
|
||||
- [ ] Authentication/authorization properly handled | 认证/授权已正确处理
|
||||
- [ ] Sensitive data is encrypted | 敏感数据已加密
|
||||
- [ ] N/A (not security-related) | 不适用(非安全相关)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Impact | 性能影响
|
||||
|
||||
<!-- Describe any performance implications -->
|
||||
<!-- 描述任何性能影响 -->
|
||||
|
||||
- [ ] No significant performance impact | 无显著性能影响
|
||||
- [ ] Performance improved | 性能提升
|
||||
- [ ] Performance may be impacted (explain below) | 性能可能受影响(请在下方说明)
|
||||
|
||||
<!-- If performance impacted, explain: -->
|
||||
<!-- 如果性能受影响,请说明: -->
|
||||
|
||||
**English:**
|
||||
|
||||
**中文:**
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
|
||||
<!-- For UI/documentation changes -->
|
||||
<!-- 对于 UI/文档变更 -->
|
||||
|
||||
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
|
||||
- [ ] Both English and Chinese versions provided | 提供了中英文版本
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
<!-- Any additional information for reviewers -->
|
||||
<!-- 给审查者的任何补充信息 -->
|
||||
**English:** | **中文:**
|
||||
|
||||
**English:**
|
||||
|
||||
**中文:**
|
||||
|
||||
---
|
||||
|
||||
## 💰 For Bounty Claims | 赏金申请
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
|
||||
<!-- Fill this section only if claiming a bounty -->
|
||||
<!-- 仅在申请赏金时填写此部分 -->
|
||||
|
||||
- [ ] This PR is for bounty issue # | 此 PR 用于赏金 issue #
|
||||
- [ ] All acceptance criteria from the bounty issue are met | 满足赏金 issue 的所有验收标准
|
||||
- [ ] I have included a demo video/screenshots | 我已包含演示视频/截图
|
||||
- [ ] I am ready for payment upon merge | 我准备好在合并后接收付款
|
||||
|
||||
**Payment Details | 付款详情:** <!-- Discuss privately with maintainers | 与维护者私下讨论 -->
|
||||
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Reviewer Notes | 审查者注意事项
|
||||
|
||||
<!-- Optional: anything specific you want reviewers to focus on? -->
|
||||
<!-- 可选:你希望审查者关注的特定内容? -->
|
||||
|
||||
**English:**
|
||||
|
||||
**中文:**
|
||||
|
||||
---
|
||||
|
||||
## 📋 PR Size Estimate | PR 大小估计
|
||||
|
||||
<!-- This helps reviewers plan their time -->
|
||||
<!-- 这有助于审查者安排时间 -->
|
||||
|
||||
- [ ] 🟢 Small (< 100 lines) | 小(< 100 行)
|
||||
- [ ] 🟡 Medium (100-500 lines) | 中(100-500 行)
|
||||
- [ ] 🔴 Large (> 500 lines) | 大(> 500 行)
|
||||
|
||||
<!-- For large PRs, consider: -->
|
||||
<!-- 对于大型 PR,考虑: -->
|
||||
<!-- - Breaking into smaller, focused PRs | 拆分为更小、更专注的 PR -->
|
||||
<!-- - Providing a detailed explanation | 提供详细说明 -->
|
||||
<!-- - Highlighting the most important changes | 突出最重要的变更 -->
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Review Focus Areas | 审查重点
|
||||
|
||||
<!-- Help reviewers know where to focus their attention -->
|
||||
<!-- 帮助审查者了解重点关注的地方 -->
|
||||
|
||||
Please pay special attention to:
|
||||
请特别注意:
|
||||
|
||||
- [ ] Logic changes | 逻辑变更
|
||||
- [ ] Security implications | 安全影响
|
||||
- [ ] Performance optimization | 性能优化
|
||||
- [ ] API changes | API 变更
|
||||
- [ ] Database schema changes | 数据库架构变更
|
||||
- [ ] UI/UX changes | UI/UX 变更
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm that:**
|
||||
**提交此 PR,我确认:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 我已阅读[贡献指南](../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 我同意[行为准则](../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under the AGPL-3.0 License | 我的贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I understand this is a voluntary contribution | 我理解这是自愿贡献
|
||||
- [ ] I have the right to submit this code | 我有权提交此代码
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
🌟 感谢你的贡献!Thank you for your contribution!
|
||||
|
||||
贡献者来自世界各地,我们重视每一份贡献。
|
||||
Contributors come from all around the world, and we value every contribution.
|
||||
|
||||
如果你是首次贡献,欢迎加入我们的社区!
|
||||
If this is your first contribution, welcome to our community!
|
||||
|
||||
💬 需要帮助?Feel free to ask questions in:
|
||||
- GitHub Discussions
|
||||
- Discord: [链接 Link]
|
||||
- Telegram: [链接 Link]
|
||||
-->
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# PR Templates | PR 模板
|
||||
|
||||
## 📋 模板概述 | Template Overview
|
||||
|
||||
我们提供了4种针对不同类型PR的专用模板,帮助贡献者快速填写PR信息:
|
||||
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
|
||||
|
||||
### 1. 🔧 Backend Template | 后端模板
|
||||
**文件:** `backend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- Go代码变更 | Go code changes
|
||||
- API端点开发 | API endpoint development
|
||||
- 交易逻辑实现 | Trading logic implementation
|
||||
- 后端性能优化 | Backend performance optimization
|
||||
- 数据库相关改动 | Database-related changes
|
||||
|
||||
**包含 | Includes:**
|
||||
- Go测试环境配置 | Go test environment
|
||||
- 安全考虑检查 | Security considerations
|
||||
- 性能影响评估 | Performance impact assessment
|
||||
- `go fmt` 和 `go build` 检查 | `go fmt` and `go build` checks
|
||||
|
||||
### 2. 🎨 Frontend Template | 前端模板
|
||||
**文件:** `frontend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- UI/UX变更 | UI/UX changes
|
||||
- React/Vue组件开发 | React/Vue component development
|
||||
- 前端样式更新 | Frontend styling updates
|
||||
- 浏览器兼容性修复 | Browser compatibility fixes
|
||||
- 前端性能优化 | Frontend performance optimization
|
||||
|
||||
**包含 | Includes:**
|
||||
- 截图/演示要求 | Screenshots/demo requirements
|
||||
- 浏览器测试清单 | Browser testing checklist
|
||||
- 国际化检查 | Internationalization checks
|
||||
- 响应式设计验证 | Responsive design verification
|
||||
- `npm run lint` 和 `npm run build` 检查 | Linting and build checks
|
||||
|
||||
### 3. 📝 Documentation Template | 文档模板
|
||||
**文件:** `docs.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- README更新 | README updates
|
||||
- API文档编写 | API documentation
|
||||
- 教程和指南 | Tutorials and guides
|
||||
- 代码注释改进 | Code comment improvements
|
||||
- 翻译工作 | Translation work
|
||||
|
||||
**包含 | Includes:**
|
||||
- 文档类型分类 | Documentation type classification
|
||||
- 内容质量检查 | Content quality checks
|
||||
- 双语要求(中英文)| Bilingual requirements (EN/CN)
|
||||
- 链接有效性验证 | Link validity verification
|
||||
|
||||
### 4. 📦 General Template | 通用模板
|
||||
**文件:** `general.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- 混合类型变更 | Mixed-type changes
|
||||
- 跨多个领域的PR | Cross-domain PRs
|
||||
- 构建配置变更 | Build configuration changes
|
||||
- 依赖更新 | Dependency updates
|
||||
- 不确定使用哪个模板时 | When unsure which template to use
|
||||
|
||||
## 🤖 自动模板建议 | Automatic Template Suggestion
|
||||
|
||||
我们的GitHub Action会自动分析你的PR并建议最合适的模板:
|
||||
Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
|
||||
|
||||
### 工作原理 | How it works:
|
||||
|
||||
1. **文件分析 | File Analysis**
|
||||
- 检测PR中所有变更的文件类型
|
||||
- Detects all changed file types in the PR
|
||||
|
||||
2. **智能判断 | Smart Detection**
|
||||
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
|
||||
- If >50% are `.go` files → Suggests **Backend template**
|
||||
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
|
||||
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
|
||||
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
|
||||
- If >70% are `.md` files → Suggests **Documentation template**
|
||||
|
||||
3. **自动评论 | Auto-comment**
|
||||
- 如果检测到你使用了默认模板,但应该用专用模板
|
||||
- If it detects you're using the default template but should use a specialized one
|
||||
- 会自动添加友好的评论建议
|
||||
- It will automatically add a friendly comment suggestion
|
||||
|
||||
4. **自动标签 | Auto-labeling**
|
||||
- 自动添加对应的标签:`backend`、`frontend`、`documentation`
|
||||
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
|
||||
|
||||
## 📖 使用方法 | How to Use
|
||||
|
||||
### 方法1: URL参数(推荐) | Method 1: URL Parameter (Recommended)
|
||||
|
||||
创建PR时,在URL末尾添加模板参数:
|
||||
When creating a PR, add the template parameter to the URL:
|
||||
|
||||
```
|
||||
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
|
||||
```
|
||||
|
||||
替换 `backend.md` 为:
|
||||
Replace `backend.md` with:
|
||||
- `backend.md` - 后端模板 | Backend template
|
||||
- `frontend.md` - 前端模板 | Frontend template
|
||||
- `docs.md` - 文档模板 | Documentation template
|
||||
- `general.md` - 通用模板 | General template
|
||||
|
||||
### 方法2: 手动选择 | Method 2: Manual Selection
|
||||
|
||||
1. 创建PR时,默认模板会显示
|
||||
When creating a PR, the default template will be shown
|
||||
|
||||
2. 根据顶部的指引链接,点击查看对应的模板
|
||||
Follow the guidance links at the top to view the corresponding template
|
||||
|
||||
3. 复制模板内容到PR描述中
|
||||
Copy the template content into the PR description
|
||||
|
||||
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion
|
||||
|
||||
1. 使用任何模板创建PR
|
||||
Create a PR with any template
|
||||
|
||||
2. GitHub Action会自动分析并评论建议
|
||||
GitHub Action will automatically analyze and comment with a suggestion
|
||||
|
||||
3. 根据建议更新PR描述
|
||||
Update the PR description based on the suggestion
|
||||
|
||||
## 🎯 最佳实践 | Best Practices
|
||||
|
||||
1. **提前选择 | Choose in Advance**
|
||||
- 在创建PR前确定变更类型
|
||||
- Determine the change type before creating the PR
|
||||
|
||||
2. **完整填写 | Complete Filling**
|
||||
- 不要跳过必填项(标记为 required)
|
||||
- Don't skip required items
|
||||
|
||||
3. **保持简洁 | Keep it Concise**
|
||||
- 描述清晰但简洁
|
||||
- Keep descriptions clear but concise
|
||||
|
||||
4. **添加截图 | Add Screenshots**
|
||||
- 对于UI变更,务必添加截图
|
||||
- For UI changes, always add screenshots
|
||||
|
||||
5. **测试证明 | Test Evidence**
|
||||
- 提供测试通过的证据
|
||||
- Provide evidence that tests pass
|
||||
|
||||
## 🔧 自定义 | Customization
|
||||
|
||||
如果需要修改模板或自动检测逻辑:
|
||||
If you need to modify templates or auto-detection logic:
|
||||
|
||||
1. **修改模板** | **Modify Templates**
|
||||
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
|
||||
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
|
||||
|
||||
2. **调整检测阈值** | **Adjust Detection Threshold**
|
||||
- 编辑 `.github/workflows/pr-template-suggester.yml`
|
||||
- Edit `.github/workflows/pr-template-suggester.yml`
|
||||
- 修改文件类型占比阈值(当前:50%后端,50%前端,70%文档)
|
||||
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
|
||||
|
||||
3. **添加新模板** | **Add New Template**
|
||||
- 在 `PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
|
||||
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
|
||||
- 更新工作流以支持新的文件类型检测
|
||||
- Update the workflow to support new file type detection
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: 我的PR既有前端又有后端代码,用哪个模板?**
|
||||
**Q: My PR has both frontend and backend code, which template should I use?**
|
||||
|
||||
A: 使用**通用模板**(`general.md`),或选择主要变更类型的模板。
|
||||
A: Use the **General template** (`general.md`), or choose the template for the primary change type.
|
||||
|
||||
---
|
||||
|
||||
**Q: 自动建议的模板不合适怎么办?**
|
||||
**Q: What if the automatically suggested template is not suitable?**
|
||||
|
||||
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
|
||||
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
|
||||
|
||||
---
|
||||
|
||||
**Q: 可以不使用任何模板吗?**
|
||||
**Q: Can I not use any template?**
|
||||
|
||||
A: 不推荐。模板帮助确保PR包含必要信息,加快审查速度。
|
||||
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
|
||||
|
||||
---
|
||||
|
||||
**Q: 如何禁用自动模板建议?**
|
||||
**Q: How to disable automatic template suggestions?**
|
||||
|
||||
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
|
||||
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
|
||||
|
||||
---
|
||||
|
||||
🌟 **感谢使用我们的PR模板系统!| Thank you for using our PR template system!**
|
||||
@@ -0,0 +1,121 @@
|
||||
# Pull Request - Backend | 后端 PR
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Go Version | Go 版本:**
|
||||
- **Exchange | 交易所:** [if applicable | 如适用]
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
|
||||
- [ ] Unit tests pass | 单元测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
|
||||
### Test Results | 测试结果
|
||||
```
|
||||
Test output here | 测试输出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations | 安全考虑
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞
|
||||
- [ ] Authentication/authorization properly handled | 认证/授权正确处理
|
||||
- [ ] Sensitive data is encrypted | 敏感数据已加密
|
||||
- [ ] N/A (not security-related) | 不适用
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Impact | 性能影响
|
||||
|
||||
- [ ] No significant performance impact | 无显著性能影响
|
||||
- [ ] Performance improved | 性能提升
|
||||
- [ ] Performance may be impacted (explain below) | 性能可能受影响
|
||||
|
||||
**If impacted, explain | 如果受影响,请说明:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code compiles successfully | 代码编译成功 (`go build`)
|
||||
- [ ] Ran `go fmt` | 已运行 `go fmt`
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
- [ ] Updated API documentation (if applicable) | 已更新 API 文档
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
@@ -0,0 +1,97 @@
|
||||
# Pull Request - Documentation | 文档 PR
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description`
|
||||
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📚 Type of Documentation | 文档类型
|
||||
|
||||
- [ ] 📖 README update | README 更新
|
||||
- [ ] 📋 API documentation | API 文档
|
||||
- [ ] 🎓 Tutorial/Guide | 教程/指南
|
||||
- [ ] 📝 Code comments | 代码注释
|
||||
- [ ] 🔧 Configuration docs | 配置文档
|
||||
- [ ] 🐛 Fix typo/error | 修复拼写/错误
|
||||
- [ ] 🌍 Translation | 翻译
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots (if applicable) | 截图(如适用)
|
||||
|
||||
<!-- For documentation with images, diagrams, or UI examples -->
|
||||
<!-- 用于包含图片、图表或 UI 示例的文档 -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
|
||||
- [ ] English version complete | 英文版本完整
|
||||
- [ ] Chinese version complete | 中文版本完整
|
||||
- [ ] Both versions are consistent | 两个版本内容一致
|
||||
- [ ] N/A (only one language needed) | 不适用(只需要一种语言)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
### Content Quality | 内容质量
|
||||
- [ ] Information is accurate and up-to-date | 信息准确且最新
|
||||
- [ ] Language is clear and concise | 语言清晰简洁
|
||||
- [ ] No spelling or grammar errors | 无拼写或语法错误
|
||||
- [ ] Links are valid and working | 链接有效且可用
|
||||
- [ ] Code examples are tested and working | 代码示例已测试且可用
|
||||
- [ ] Formatting is consistent | 格式一致
|
||||
|
||||
### Documentation Standards | 文档标准
|
||||
- [ ] Follows project documentation style | 遵循项目文档风格
|
||||
- [ ] Includes necessary examples | 包含必要的示例
|
||||
- [ ] Technical terms are explained | 技术术语已解释
|
||||
- [ ] Self-review completed | 已完成自查
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
@@ -0,0 +1,119 @@
|
||||
# Pull Request - Frontend | 前端 PR
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots / Demo | 截图/演示
|
||||
|
||||
<!-- For UI changes, include before/after screenshots or video demo -->
|
||||
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
|
||||
|
||||
**Before | 变更前:**
|
||||
|
||||
|
||||
**After | 变更后:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Node Version | Node 版本:**
|
||||
- **Browser(s) | 浏览器:**
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested in development mode | 开发模式测试通过
|
||||
- [ ] Tested production build | 生产构建测试通过
|
||||
- [ ] Tested on multiple browsers | 多浏览器测试通过
|
||||
- [ ] Tested responsive design | 响应式设计测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
|
||||
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
|
||||
- [ ] Both English and Chinese versions provided | 提供了中英文版本
|
||||
- [ ] N/A | 不适用
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code builds successfully | 代码构建成功 (`npm run build`)
|
||||
- [ ] Ran `npm run lint` | 已运行 `npm run lint`
|
||||
- [ ] No console errors or warnings | 无控制台错误或警告
|
||||
|
||||
### Testing | 测试
|
||||
- [ ] Component tests added/updated | 已添加/更新组件测试
|
||||
- [ ] Tests pass locally | 测试在本地通过
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Updated type definitions (TypeScript) | 已更新类型定义
|
||||
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
@@ -0,0 +1,98 @@
|
||||
# Pull Request - General | 通用 PR
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
|
||||
**Test details | 测试详情:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] No new warnings or errors | 无新的警告或错误
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security (if applicable) | 安全(如适用)
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] N/A | 不适用
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
@@ -0,0 +1,189 @@
|
||||
name: PR Template Suggester
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
suggest-template:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Analyze PR files and auto-apply template
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
|
||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filename = file.filename.toLowerCase();
|
||||
if (filename.endsWith('.go')) goFiles++;
|
||||
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
||||
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
||||
else if (filename.endsWith('.md')) mdFiles++;
|
||||
else otherFiles++;
|
||||
}
|
||||
|
||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||
|
||||
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||
|
||||
if (goFiles / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
||||
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
||||
} else if (mdFiles / totalFiles > 0.7) {
|
||||
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
|
||||
const prBody = pr.body || '';
|
||||
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
|
||||
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
|
||||
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
|
||||
const usesGeneralTemplate = prBody.includes('Pull Request - General');
|
||||
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
|
||||
|
||||
if (templateLabel) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [templateLabel]
|
||||
});
|
||||
console.log('Added label: ' + templateLabel);
|
||||
} catch (error) {
|
||||
console.log('Label might not exist, skipping...');
|
||||
}
|
||||
}
|
||||
|
||||
function isPRBodyEmpty(body) {
|
||||
if (!body || body.trim().length < 100) return true;
|
||||
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
|
||||
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
|
||||
if (hasEmptyDescription || hasEmptyChanges) return true;
|
||||
const descMatch = body.match(/\*\*English:\*\*[||]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
|
||||
if (!descMatch || descMatch[1].trim().length < 10) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (suggestedTemplate && usingDefaultTemplate) {
|
||||
const shouldAutoApply = isPRBodyEmpty(prBody);
|
||||
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
if (shouldAutoApply) {
|
||||
try {
|
||||
const { data: templateFile } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: templatePath,
|
||||
ref: context.payload.pull_request.head.ref
|
||||
});
|
||||
|
||||
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
body: templateContent
|
||||
});
|
||||
|
||||
console.log('Auto-applied ' + suggestedTemplate + ' template');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
|
||||
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
|
||||
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
|
||||
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
|
||||
'✨ You can now fill in the relevant information in the PR description!';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: notifyComment
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch or apply template: ' + error.message);
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
|
||||
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
|
||||
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fallbackComment
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('PR body has content, sending suggestion only');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
|
||||
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
|
||||
'**如何使用 | How to use**\n' +
|
||||
'1. 编辑PR描述 | Edit PR description\n' +
|
||||
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
|
||||
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
|
||||
' `?template=' + suggestedTemplate + '.md`\n\n' +
|
||||
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
|
||||
'_This is an automated suggestion. You may continue using the current template._';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
} else if (suggestedTemplate && !usingDefaultTemplate) {
|
||||
console.log('PR already uses a specific template');
|
||||
} else {
|
||||
console.log('No specific template suggestion needed - mixed changes');
|
||||
}
|
||||
@@ -41,3 +41,6 @@ web/node_modules/
|
||||
node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
|
||||
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
||||
eslint-*.json
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename -- "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
readonly husky_skip_init=1
|
||||
export husky_skip_init
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
if [ $exitCode = 127 ]; then
|
||||
echo "husky - command not found in PATH=$PATH"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd web && npx lint-staged
|
||||
@@ -281,7 +281,7 @@ Docker automatically handles all dependencies (Go, Node.js, TA-Lib, SQLite) and
|
||||
#### Step 1: Prepare Configuration
|
||||
```bash
|
||||
# Copy configuration template
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# Edit and fill in your API keys
|
||||
nano config.json # or use any editor
|
||||
|
||||
+70
-2
@@ -9,6 +9,7 @@ import (
|
||||
"nofx/config"
|
||||
"nofx/decision"
|
||||
"nofx/manager"
|
||||
"nofx/trader"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -347,6 +348,73 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
scanIntervalMinutes = 3 // 默认3分钟
|
||||
}
|
||||
|
||||
// ✨ 查询交易所实际余额,覆盖用户输入
|
||||
actualBalance := req.InitialBalance // 默认使用用户输入
|
||||
exchanges, err := s.database.GetExchanges(userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 获取交易所配置失败,使用用户输入的初始资金: %v", err)
|
||||
}
|
||||
|
||||
// 查找匹配的交易所配置
|
||||
var exchangeCfg *config.ExchangeConfig
|
||||
for _, ex := range exchanges {
|
||||
if ex.ID == req.ExchangeID {
|
||||
exchangeCfg = ex
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if exchangeCfg == nil {
|
||||
log.Printf("⚠️ 未找到交易所 %s 的配置,使用用户输入的初始资金", req.ExchangeID)
|
||||
} else if !exchangeCfg.Enabled {
|
||||
log.Printf("⚠️ 交易所 %s 未启用,使用用户输入的初始资金", req.ExchangeID)
|
||||
} else {
|
||||
// 根据交易所类型创建临时 trader 查询余额
|
||||
var tempTrader trader.Trader
|
||||
var createErr error
|
||||
|
||||
switch req.ExchangeID {
|
||||
case "binance":
|
||||
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||||
exchangeCfg.APIKey, // private key
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = trader.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
exchangeCfg.AsterPrivateKey,
|
||||
)
|
||||
default:
|
||||
log.Printf("⚠️ 不支持的交易所类型: %s,使用用户输入的初始资金", req.ExchangeID)
|
||||
}
|
||||
|
||||
if createErr != nil {
|
||||
log.Printf("⚠️ 创建临时 trader 失败,使用用户输入的初始资金: %v", createErr)
|
||||
} else if tempTrader != nil {
|
||||
// 查询实际余额
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
log.Printf("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr)
|
||||
} else {
|
||||
// 提取可用余额
|
||||
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
// 有些交易所可能只返回 balance 字段
|
||||
actualBalance = totalBalance
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else {
|
||||
log.Printf("⚠️ 无法从余额信息中提取可用余额,使用用户输入的初始资金")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建交易员配置(数据库实体)
|
||||
trader := &config.TraderRecord{
|
||||
ID: traderID,
|
||||
@@ -354,7 +422,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
Name: req.Name,
|
||||
AIModelID: req.AIModelID,
|
||||
ExchangeID: req.ExchangeID,
|
||||
InitialBalance: req.InitialBalance,
|
||||
InitialBalance: actualBalance, // 使用实际查询的余额
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
@@ -369,7 +437,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
err := s.database.CreateTrader(trader)
|
||||
err = s.database.CreateTrader(trader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建交易员失败: %v", err)})
|
||||
return
|
||||
|
||||
+4
-1
@@ -20,5 +20,8 @@
|
||||
"max_daily_loss": 10.0,
|
||||
"max_drawdown": 20.0,
|
||||
"stop_trading_minutes": 60,
|
||||
"jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg=="
|
||||
"jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg==",
|
||||
"log": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,20 @@ type LeverageConfig struct {
|
||||
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币的杠杆倍数(主账户建议5-20,子账户≤5)
|
||||
}
|
||||
|
||||
// LogConfig 日志配置
|
||||
type LogConfig struct {
|
||||
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
|
||||
Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选)
|
||||
}
|
||||
|
||||
// TelegramConfig Telegram推送配置(简化版,只保留必需字段)
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled"` // 是否启用(默认: false)
|
||||
BotToken string `json:"bot_token"` // Bot Token
|
||||
ChatID int64 `json:"chat_id"` // Chat ID
|
||||
MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error)
|
||||
}
|
||||
|
||||
// Config 总配置
|
||||
type Config struct {
|
||||
Traders []TraderConfig `json:"traders"`
|
||||
@@ -60,6 +74,7 @@ type Config struct {
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
StopTradingMinutes int `json:"stop_trading_minutes"`
|
||||
Leverage LeverageConfig `json:"leverage"` // 杠杆配置
|
||||
Log *LogConfig `json:"log"` // 日志配置(可选)
|
||||
}
|
||||
|
||||
// LoadConfig 从文件加载配置
|
||||
|
||||
+189
-30
@@ -7,10 +7,22 @@ import (
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/pool"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 预编译正则表达式(性能优化:避免每次调用时重新编译)
|
||||
var (
|
||||
// ✅ 安全的正則:精確匹配 ```json 代碼塊
|
||||
// 使用反引號 + 拼接避免轉義問題
|
||||
reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```")
|
||||
reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`)
|
||||
reArrayHead = regexp.MustCompile(`^\[\s*\{`)
|
||||
reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`)
|
||||
reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]")
|
||||
)
|
||||
|
||||
// PositionInfo 持仓信息
|
||||
type PositionInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
@@ -71,11 +83,20 @@ type Context struct {
|
||||
// Decision AI的交易决策
|
||||
type Decision struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "update_stop_loss", "update_take_profit", "partial_close", "hold", "wait"
|
||||
|
||||
// 开仓参数
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
PositionSizeUSD float64 `json:"position_size_usd,omitempty"`
|
||||
StopLoss float64 `json:"stop_loss,omitempty"`
|
||||
TakeProfit float64 `json:"take_profit,omitempty"`
|
||||
|
||||
// 调整参数(新增)
|
||||
NewStopLoss float64 `json:"new_stop_loss,omitempty"` // 用于 update_stop_loss
|
||||
NewTakeProfit float64 `json:"new_take_profit,omitempty"` // 用于 update_take_profit
|
||||
ClosePercentage float64 `json:"close_percentage,omitempty"` // 用于 partial_close (0-100)
|
||||
|
||||
// 通用参数
|
||||
Confidence int `json:"confidence,omitempty"` // 信心度 (0-100)
|
||||
RiskUSD float64 `json:"risk_usd,omitempty"` // 最大美元风险
|
||||
Reasoning string `json:"reasoning"`
|
||||
@@ -160,17 +181,20 @@ func fetchMarketDataForContext(ctx *Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// ⚠️ 流动性过滤:持仓价值低于15M USD的币种不做(多空都不做)
|
||||
// ⚠️ 流动性过滤:持仓价值低于阈值的币种不做(多空都不做)
|
||||
// 持仓价值 = 持仓量 × 当前价格
|
||||
// 但现有持仓必须保留(需要决策是否平仓)
|
||||
// 💡 OI 門檻配置:用戶可根據風險偏好調整
|
||||
const minOIThresholdMillions = 15.0 // 可調整:15M(保守) / 10M(平衡) / 8M(寬鬆) / 5M(激進)
|
||||
|
||||
isExistingPosition := positionSymbols[symbol]
|
||||
if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 {
|
||||
// 计算持仓价值(USD)= 持仓量 × 当前价格
|
||||
oiValue := data.OpenInterest.Latest * data.CurrentPrice
|
||||
oiValueInMillions := oiValue / 1_000_000 // 转换为百万美元单位
|
||||
if oiValueInMillions < 15 {
|
||||
log.Printf("⚠️ %s 持仓价值过低(%.2fM USD < 15M),跳过此币种 [持仓量:%.0f × 价格:%.4f]",
|
||||
symbol, oiValueInMillions, data.OpenInterest.Latest, data.CurrentPrice)
|
||||
if oiValueInMillions < minOIThresholdMillions {
|
||||
log.Printf("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]",
|
||||
symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -200,10 +224,31 @@ func fetchMarketDataForContext(ctx *Context) error {
|
||||
|
||||
// calculateMaxCandidates 根据账户状态计算需要分析的候选币种数量
|
||||
func calculateMaxCandidates(ctx *Context) int {
|
||||
// 直接返回候选池的全部币种数量
|
||||
// 因为候选池已经在 auto_trader.go 中筛选过了
|
||||
// 固定分析前20个评分最高的币种(来自AI500)
|
||||
return len(ctx.CandidateCoins)
|
||||
// ⚠️ 重要:限制候选币种数量,避免 Prompt 过大
|
||||
// 根据持仓数量动态调整:持仓越少,可以分析更多候选币
|
||||
const (
|
||||
maxCandidatesWhenEmpty = 30 // 无持仓时最多分析30个候选币
|
||||
maxCandidatesWhenHolding1 = 25 // 持仓1个时最多分析25个候选币
|
||||
maxCandidatesWhenHolding2 = 20 // 持仓2个时最多分析20个候选币
|
||||
maxCandidatesWhenHolding3 = 15 // 持仓3个时最多分析15个候选币(避免 Prompt 过大)
|
||||
)
|
||||
|
||||
positionCount := len(ctx.Positions)
|
||||
var maxCandidates int
|
||||
|
||||
switch positionCount {
|
||||
case 0:
|
||||
maxCandidates = maxCandidatesWhenEmpty
|
||||
case 1:
|
||||
maxCandidates = maxCandidatesWhenHolding1
|
||||
case 2:
|
||||
maxCandidates = maxCandidatesWhenHolding2
|
||||
default: // 3+ 持仓
|
||||
maxCandidates = maxCandidatesWhenHolding3
|
||||
}
|
||||
|
||||
// 返回实际候选币数量和上限中的较小值
|
||||
return min(len(ctx.CandidateCoins), maxCandidates)
|
||||
}
|
||||
|
||||
// buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt
|
||||
@@ -430,25 +475,44 @@ func extractCoTTrace(response string) string {
|
||||
|
||||
// extractDecisions 提取JSON决策列表
|
||||
func extractDecisions(response string) ([]Decision, error) {
|
||||
// 直接查找JSON数组 - 找第一个完整的JSON数组
|
||||
arrayStart := strings.Index(response, "[")
|
||||
if arrayStart == -1 {
|
||||
return nil, fmt.Errorf("无法找到JSON数组起始")
|
||||
// 预清洗:去零宽/BOM
|
||||
s := removeInvisibleRunes(response)
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// 🔧 關鍵修復:在正則匹配之前就先修復全角字符!
|
||||
// 否則正則表達式 \[ 無法匹配全角的 [
|
||||
s = fixMissingQuotes(s)
|
||||
|
||||
// 1) 优先从 ```json 代码块中提取
|
||||
if m := reJSONFence.FindStringSubmatch(s); m != nil && len(m) > 1 {
|
||||
jsonContent := strings.TrimSpace(m[1])
|
||||
jsonContent = compactArrayOpen(jsonContent) // 把 "[ {" 规整为 "[{"
|
||||
jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有全角)
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
|
||||
}
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("JSON解析失败: %w\nJSON内容: %s", err, jsonContent)
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// 从 [ 开始,匹配括号找到对应的 ]
|
||||
arrayEnd := findMatchingBracket(response, arrayStart)
|
||||
if arrayEnd == -1 {
|
||||
return nil, fmt.Errorf("无法找到JSON数组结束")
|
||||
// 2) 退而求其次:全文寻找首个对象数组
|
||||
// 注意:此時 s 已經過 fixMissingQuotes(),全角字符已轉換為半角
|
||||
jsonContent := strings.TrimSpace(reJSONArray.FindString(s))
|
||||
if jsonContent == "" {
|
||||
return nil, fmt.Errorf("无法找到JSON数组起始(已嘗試修復全角字符)\n原始響應前200字符: %s", s[:min(200, len(s))])
|
||||
}
|
||||
|
||||
jsonContent := strings.TrimSpace(response[arrayStart : arrayEnd+1])
|
||||
// 🔧 規整格式(此時全角字符已在前面修復過)
|
||||
jsonContent = compactArrayOpen(jsonContent)
|
||||
jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有殘留全角)
|
||||
|
||||
// 🔧 修复常见的JSON格式错误:缺少引号的字段值
|
||||
// 匹配: "reasoning": 内容"} 或 "reasoning": 内容} (没有引号)
|
||||
// 修复为: "reasoning": "内容"}
|
||||
// 使用简单的字符串扫描而不是正则表达式
|
||||
jsonContent = fixMissingQuotes(jsonContent)
|
||||
// 🔧 验证 JSON 格式(检测常见错误)
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
var decisions []Decision
|
||||
@@ -459,15 +523,86 @@ func extractDecisions(response string) ([]Decision, error) {
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// fixMissingQuotes 替换中文引号为英文引号(避免输入法自动转换)
|
||||
// fixMissingQuotes 替换中文引号和全角字符为英文引号和半角字符(避免AI输出全角JSON字符导致解析失败)
|
||||
func fixMissingQuotes(jsonStr string) string {
|
||||
// 替换中文引号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // "
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // "
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // '
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // '
|
||||
|
||||
// ⚠️ 替换全角括号、冒号、逗号(防止AI输出全角JSON字符)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "[", "[") // U+FF3B 全角左方括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "]", "]") // U+FF3D 全角右方括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "{", "{") // U+FF5B 全角左花括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "}", "}") // U+FF5D 全角右花括号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ":", ":") // U+FF1A 全角冒号
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ",", ",") // U+FF0C 全角逗号
|
||||
|
||||
// ⚠️ 替换CJK标点符号(AI在中文上下文中也可能输出这些)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK左方头括号 U+3010
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK右方头括号 U+3011
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") // CJK左龟壳括号 U+3014
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") // CJK右龟壳括号 U+3015
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK顿号 U+3001
|
||||
|
||||
// ⚠️ 替换全角空格为半角空格(JSON中不应该有全角空格)
|
||||
jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 全角空格
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// validateJSONFormat 验证 JSON 格式,检测常见错误
|
||||
func validateJSONFormat(jsonStr string) error {
|
||||
trimmed := strings.TrimSpace(jsonStr)
|
||||
|
||||
// 允许 [ 和 { 之间存在任意空白(含零宽)
|
||||
if !reArrayHead.MatchString(trimmed) {
|
||||
// 检查是否是纯数字/范围数组(常见错误)
|
||||
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
|
||||
return fmt.Errorf("不是有效的决策数组(必须包含对象 {}),实际内容: %s", trimmed[:min(50, len(trimmed))])
|
||||
}
|
||||
return fmt.Errorf("JSON 必须以 [{ 开头(允许空白),实际: %s", trimmed[:min(20, len(trimmed))])
|
||||
}
|
||||
|
||||
// 检查是否包含范围符号 ~(LLM 常见错误)
|
||||
if strings.Contains(jsonStr, "~") {
|
||||
return fmt.Errorf("JSON 中不可包含范围符号 ~,所有数字必须是精确的单一值")
|
||||
}
|
||||
|
||||
// 检查是否包含千位分隔符(如 98,000)
|
||||
// 使用简单的模式匹配:数字+逗号+3位数字
|
||||
for i := 0; i < len(jsonStr)-4; i++ {
|
||||
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
|
||||
jsonStr[i+1] == ',' &&
|
||||
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
|
||||
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
|
||||
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
|
||||
return fmt.Errorf("JSON 数字不可包含千位分隔符逗号,发现: %s", jsonStr[i:min(i+10, len(jsonStr))])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// min 返回两个整数中的较小值
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// removeInvisibleRunes 去除零宽字符和 BOM,避免肉眼看不见的前缀破坏校验
|
||||
func removeInvisibleRunes(s string) string {
|
||||
return reInvisibleRunes.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// compactArrayOpen 规整开头的 "[ {" → "[{"
|
||||
func compactArrayOpen(s string) string {
|
||||
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
|
||||
}
|
||||
|
||||
// validateDecisions 验证所有决策(需要账户信息和杠杆配置)
|
||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
|
||||
for i, decision := range decisions {
|
||||
@@ -504,12 +639,15 @@ func findMatchingBracket(s string, start int) int {
|
||||
func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
|
||||
// 验证action
|
||||
validActions := map[string]bool{
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
"hold": true,
|
||||
"wait": true,
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
"update_stop_loss": true,
|
||||
"update_take_profit": true,
|
||||
"partial_close": true,
|
||||
"hold": true,
|
||||
"wait": true,
|
||||
}
|
||||
|
||||
if !validActions[d.Action] {
|
||||
@@ -589,5 +727,26 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
}
|
||||
}
|
||||
|
||||
// 动态调整止损验证
|
||||
if d.Action == "update_stop_loss" {
|
||||
if d.NewStopLoss <= 0 {
|
||||
return fmt.Errorf("新止损价格必须大于0: %.2f", d.NewStopLoss)
|
||||
}
|
||||
}
|
||||
|
||||
// 动态调整止盈验证
|
||||
if d.Action == "update_take_profit" {
|
||||
if d.NewTakeProfit <= 0 {
|
||||
return fmt.Errorf("新止盈价格必须大于0: %.2f", d.NewTakeProfit)
|
||||
}
|
||||
}
|
||||
|
||||
// 部分平仓验证
|
||||
if d.Action == "partial_close" {
|
||||
if d.ClosePercentage <= 0 || d.ClosePercentage > 100 {
|
||||
return fmt.Errorf("平仓百分比必须在0-100之间: %.1f", d.ClosePercentage)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
- /etc/localtime:/etc/localtime:ro # Sync host time
|
||||
environment:
|
||||
- TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone
|
||||
- AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000)
|
||||
networks:
|
||||
- nofx-network
|
||||
healthcheck:
|
||||
|
||||
@@ -23,7 +23,7 @@ Choose the method that best fits your needs:
|
||||
|
||||
**Quick Start:**
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
**快速开始:**
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ docker compose --version # Docker 24+ includes this, no separate installation n
|
||||
|
||||
```bash
|
||||
# Copy configuration template
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# Edit configuration file with your API keys
|
||||
nano config.json # or use any other editor
|
||||
@@ -267,7 +267,7 @@ kill -9 <PID>
|
||||
# ~~ls -la config.json~~
|
||||
|
||||
# ~~If not exists, copy template~~
|
||||
# ~~cp config.example.jsonc config.json~~
|
||||
# ~~cp config.json.example config.json~~
|
||||
|
||||
*Note: Now using SQLite database for configuration storage, no longer need config.json*
|
||||
```
|
||||
|
||||
@@ -55,7 +55,7 @@ docker compose --version # Docker 24+ 自带,无需单独安装
|
||||
|
||||
```bash
|
||||
# 复制配置文件模板
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# 编辑配置文件,填入你的 API 密钥
|
||||
nano config.json # 或使用其他编辑器
|
||||
@@ -270,7 +270,7 @@ kill -9 <PID>
|
||||
ls -la config.json
|
||||
|
||||
# 如果不存在,复制模板
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
### 健康检查失败
|
||||
|
||||
@@ -285,7 +285,7 @@ Docker автоматически обрабатывает все зависим
|
||||
#### Шаг 1: Подготовьте конфигурацию
|
||||
```bash
|
||||
# Скопируйте шаблон конфигурации
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# Отредактируйте и заполните ваши API ключи
|
||||
nano config.json # или используйте любой редактор
|
||||
@@ -423,7 +423,7 @@ cd ..
|
||||
**Шаг 1**: Скопируйте и переименуйте файл примера конфигурации
|
||||
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
**Шаг 2**: Отредактируйте `config.json` и заполните ваши API ключи
|
||||
|
||||
@@ -288,7 +288,7 @@ Docker автоматично обробляє всі залежності (Go,
|
||||
#### Крок 1: Підготуйте конфігурацію
|
||||
```bash
|
||||
# Скопіюйте шаблон конфігурації
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# Відредагуйте та заповніть ваші API ключі
|
||||
nano config.json # або використайте будь-який редактор
|
||||
@@ -426,7 +426,7 @@ cd ..
|
||||
**Крок 1**: Скопіюйте та перейменуйте файл прикладу конфігурації
|
||||
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
**Крок 2**: Відредагуйте `config.json` та заповніть ваші API ключі
|
||||
|
||||
@@ -283,7 +283,7 @@ Docker会自动处理所有依赖(Go、Node.js、TA-Lib)和环境配置,
|
||||
#### 步骤1:准备配置文件
|
||||
```bash
|
||||
# 复制配置文件模板
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# 编辑并填入你的API密钥
|
||||
nano config.json # 或使用其他编辑器
|
||||
@@ -422,7 +422,7 @@ cd ..
|
||||
~~**步骤1**:复制并重命名示例配置文件~~
|
||||
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
~~**步骤2**:编辑`config.json`填入您的API密钥~~
|
||||
|
||||
@@ -6,11 +6,13 @@ require (
|
||||
github.com/adshao/go-binance/v2 v2.8.7
|
||||
github.com/ethereum/go-ethereum v1.16.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.17.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
)
|
||||
|
||||
@@ -62,6 +62,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@@ -155,6 +157,8 @@ github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
|
||||
github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
|
||||
@@ -167,6 +171,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
@@ -206,6 +211,7 @@ golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Config 日志配置(简化版)
|
||||
type Config struct {
|
||||
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
|
||||
Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选)
|
||||
}
|
||||
|
||||
// TelegramConfig Telegram推送配置(简化版,高级参数使用默认值)
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled"` // 是否启用(默认: false)
|
||||
BotToken string `json:"bot_token"` // Bot Token
|
||||
ChatID int64 `json:"chat_id"` // Chat ID
|
||||
MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error)
|
||||
}
|
||||
|
||||
// SetDefaults 设置默认值
|
||||
func (c *Config) SetDefaults() {
|
||||
if c.Level == "" {
|
||||
c.Level = "info"
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogrusLevels 返回要推送到Telegram的日志级别
|
||||
// 根据配置的MinLevel返回该级别及以上的所有日志级别
|
||||
// 如果未配置或配置无效,默认返回error, fatal, panic(向后兼容)
|
||||
func (tc *TelegramConfig) GetLogrusLevels() []logrus.Level {
|
||||
// 如果未配置,使用默认值error(向后兼容)
|
||||
minLevelStr := tc.MinLevel
|
||||
if minLevelStr == "" {
|
||||
minLevelStr = "error"
|
||||
}
|
||||
|
||||
// 解析配置的日志级别
|
||||
minLevel, err := logrus.ParseLevel(minLevelStr)
|
||||
if err != nil {
|
||||
// 如果解析失败,使用默认值error(向后兼容)
|
||||
minLevel = logrus.ErrorLevel
|
||||
}
|
||||
|
||||
// 定义所有日志级别(从高到低:panic, fatal, error, warn, info, debug)
|
||||
allLevels := []logrus.Level{
|
||||
logrus.PanicLevel,
|
||||
logrus.FatalLevel,
|
||||
logrus.ErrorLevel,
|
||||
logrus.WarnLevel,
|
||||
logrus.InfoLevel,
|
||||
logrus.DebugLevel,
|
||||
}
|
||||
|
||||
// 返回所有大于等于minLevel的日志级别
|
||||
var result []logrus.Level
|
||||
for _, level := range allLevels {
|
||||
if level <= minLevel {
|
||||
result = append(result, level)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"traders": [
|
||||
{
|
||||
"id": "trader1",
|
||||
"name": "AI Trader 1",
|
||||
"enabled": true,
|
||||
"ai_model": "deepseek",
|
||||
"exchange": "binance",
|
||||
"binance_api_key": "your_api_key",
|
||||
"binance_secret_key": "your_secret_key",
|
||||
"deepseek_key": "your_deepseek_key",
|
||||
"initial_balance": 1000,
|
||||
"scan_interval_minutes": 3
|
||||
}
|
||||
],
|
||||
"use_default_coins": true,
|
||||
"default_coins": ["BTCUSDT", "ETHUSDT", "SOLUSDT"],
|
||||
"api_server_port": 8080,
|
||||
"leverage": {
|
||||
"btc_eth_leverage": 5,
|
||||
"altcoin_leverage": 5
|
||||
},
|
||||
"log": {
|
||||
"level": "info",
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"bot_token": "79472419:feafe231414",
|
||||
"chat_id": -100323252626,
|
||||
"min_level": "error"
|
||||
}
|
||||
},
|
||||
"_comment": "日志配置说明:level 可选值为 debug/info/warn/error,默认 info。telegram 部分作为可选配置, Telegram 推送默认为 error/fatal/panic 级别,min_level 如果设置为warn,则推送warn级别及以上的日志"
|
||||
}
|
||||
+46
-13
@@ -50,9 +50,9 @@ type PositionSnapshot struct {
|
||||
|
||||
// DecisionAction 决策动作
|
||||
type DecisionAction struct {
|
||||
Action string `json:"action"` // open_long, open_short, close_long, close_short
|
||||
Action string `json:"action"` // open_long, open_short, close_long, close_short, update_stop_loss, update_take_profit, partial_close
|
||||
Symbol string `json:"symbol"` // 币种
|
||||
Quantity float64 `json:"quantity"` // 数量
|
||||
Quantity float64 `json:"quantity"` // 数量(部分平仓时使用)
|
||||
Leverage int `json:"leverage"` // 杠杆(开仓时)
|
||||
Price float64 `json:"price"` // 执行价格
|
||||
OrderID int64 `json:"order_id"` // 订单ID
|
||||
@@ -243,8 +243,9 @@ func (l *DecisionLogger) GetStatistics() (*Statistics, error) {
|
||||
switch action.Action {
|
||||
case "open_long", "open_short":
|
||||
stats.TotalOpenPositions++
|
||||
case "close_long", "close_short":
|
||||
case "close_long", "close_short", "partial_close":
|
||||
stats.TotalClosePositions++
|
||||
// update_stop_loss 和 update_take_profit 不計入統計
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,11 +349,22 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
|
||||
symbol := action.Symbol
|
||||
side := ""
|
||||
if action.Action == "open_long" || action.Action == "close_long" {
|
||||
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" {
|
||||
side = "long"
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
// partial_close 需要根據持倉判斷方向
|
||||
if action.Action == "partial_close" && side == "" {
|
||||
for key, pos := range openPositions {
|
||||
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
|
||||
side = posSymbol
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
posKey := symbol + "_" + side
|
||||
|
||||
switch action.Action {
|
||||
@@ -368,6 +380,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
case "close_long", "close_short":
|
||||
// 移除已平仓记录
|
||||
delete(openPositions, posKey)
|
||||
// partial_close 不處理,保留持倉記錄
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,11 +395,23 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
|
||||
symbol := action.Symbol
|
||||
side := ""
|
||||
if action.Action == "open_long" || action.Action == "close_long" {
|
||||
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" {
|
||||
side = "long"
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
// partial_close 需要根據持倉判斷方向
|
||||
if action.Action == "partial_close" {
|
||||
// 從 openPositions 中查找持倉方向
|
||||
for key, pos := range openPositions {
|
||||
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
|
||||
side = posSymbol
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
posKey := symbol + "_" + side // 使用symbol_side作为key,区分多空持仓
|
||||
|
||||
switch action.Action {
|
||||
@@ -400,7 +425,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
"leverage": action.Leverage,
|
||||
}
|
||||
|
||||
case "close_long", "close_short":
|
||||
case "close_long", "close_short", "partial_close":
|
||||
// 查找对应的开仓记录(可能来自预填充或当前窗口)
|
||||
if openPos, exists := openPositions[posKey]; exists {
|
||||
openPrice := openPos["openPrice"].(float64)
|
||||
@@ -409,18 +434,24 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
quantity := openPos["quantity"].(float64)
|
||||
leverage := openPos["leverage"].(int)
|
||||
|
||||
// 对于 partial_close,使用实际平仓数量;否则使用完整仓位数量
|
||||
actualQuantity := quantity
|
||||
if action.Action == "partial_close" {
|
||||
actualQuantity = action.Quantity
|
||||
}
|
||||
|
||||
// 计算实际盈亏(USDT)
|
||||
// 合约交易 PnL 计算:quantity × 价格差
|
||||
// 合约交易 PnL 计算:actualQuantity × 价格差
|
||||
// 注意:杠杆不影响绝对盈亏,只影响保证金需求
|
||||
var pnl float64
|
||||
if side == "long" {
|
||||
pnl = quantity * (action.Price - openPrice)
|
||||
pnl = actualQuantity * (action.Price - openPrice)
|
||||
} else {
|
||||
pnl = quantity * (openPrice - action.Price)
|
||||
pnl = actualQuantity * (openPrice - action.Price)
|
||||
}
|
||||
|
||||
// 计算盈亏百分比(相对保证金)
|
||||
positionValue := quantity * openPrice
|
||||
positionValue := actualQuantity * openPrice
|
||||
marginUsed := positionValue / float64(leverage)
|
||||
pnlPct := 0.0
|
||||
if marginUsed > 0 {
|
||||
@@ -431,7 +462,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
outcome := TradeOutcome{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity,
|
||||
Quantity: actualQuantity,
|
||||
Leverage: leverage,
|
||||
OpenPrice: openPrice,
|
||||
ClosePrice: action.Price,
|
||||
@@ -472,8 +503,10 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
// 移除已平仓记录
|
||||
delete(openPositions, posKey)
|
||||
// 移除已平仓记录(partial_close 不刪除,因為還有剩餘倉位)
|
||||
if action.Action != "partial_close" {
|
||||
delete(openPositions, posKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"nofx/config"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
// Log 全局logger实例
|
||||
Log *logrus.Logger
|
||||
|
||||
// telegramHook 保存hook引用,用于优雅关闭
|
||||
telegramHook *TelegramHook
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 初始化函数
|
||||
// ============================================================================
|
||||
|
||||
// Init 初始化全局logger
|
||||
// 如果config为nil,使用默认配置(console输出,info级别)
|
||||
func Init(cfg *Config) error {
|
||||
Log = logrus.New()
|
||||
|
||||
// 如果没有配置,使用默认值
|
||||
if cfg == nil {
|
||||
cfg = &Config{Level: "info"}
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
cfg.SetDefaults()
|
||||
|
||||
// 设置日志级别
|
||||
level, err := logrus.ParseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
Log.SetLevel(level)
|
||||
|
||||
// 设置格式化器(固定使用彩色文本格式)
|
||||
Log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
ForceColors: true,
|
||||
})
|
||||
|
||||
// 设置输出目标(默认stdout)
|
||||
Log.SetOutput(os.Stdout)
|
||||
|
||||
// 启用调用位置信息
|
||||
Log.SetReportCaller(true)
|
||||
|
||||
// 添加Telegram Hook(可选)
|
||||
if cfg.Telegram != nil && cfg.Telegram.Enabled {
|
||||
if err := setupTelegramHook(cfg.Telegram); err != nil {
|
||||
Log.Warnf("初始化Telegram推送失败,将继续使用普通日志: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupTelegramHook 设置Telegram Hook
|
||||
func setupTelegramHook(telegramCfg *TelegramConfig) error {
|
||||
hook, err := NewTelegramHook(telegramCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Log.AddHook(hook)
|
||||
telegramHook = hook
|
||||
Log.Info("✅ Telegram日志推送已启用")
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitWithSimpleConfig 使用简化配置初始化logger
|
||||
// 适用于只需要基本功能的场景
|
||||
func InitWithSimpleConfig(level string) error {
|
||||
return Init(&Config{Level: level})
|
||||
}
|
||||
|
||||
// InitWithTelegram 使用Telegram配置初始化logger
|
||||
func InitWithTelegram(botToken string, chatID int64) error {
|
||||
return Init(&Config{
|
||||
Level: "info",
|
||||
Telegram: &TelegramConfig{
|
||||
Enabled: true,
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// InitFromLogConfig 从config.LogConfig初始化logger
|
||||
func InitFromLogConfig(logConfig *config.LogConfig) error {
|
||||
if logConfig == nil {
|
||||
return InitWithSimpleConfig("info")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Level: logConfig.Level,
|
||||
}
|
||||
|
||||
if cfg.Level == "" {
|
||||
cfg.Level = "info"
|
||||
}
|
||||
|
||||
// 如果启用了Telegram,添加配置
|
||||
if logConfig.Telegram != nil && logConfig.Telegram.Enabled {
|
||||
if botToken := logConfig.Telegram.BotToken; botToken != "" && logConfig.Telegram.ChatID != 0 {
|
||||
cfg.Telegram = &TelegramConfig{
|
||||
Enabled: true,
|
||||
BotToken: botToken,
|
||||
ChatID: logConfig.Telegram.ChatID,
|
||||
MinLevel: logConfig.Telegram.MinLevel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Init(cfg)
|
||||
}
|
||||
|
||||
// InitFromParams 从参数初始化logger
|
||||
// 适用于不依赖config包的场景
|
||||
func InitFromParams(level string, telegramEnabled bool, botToken string, chatID int64) error {
|
||||
cfg := &Config{Level: level}
|
||||
|
||||
if telegramEnabled && botToken != "" && chatID != 0 {
|
||||
cfg.Telegram = &TelegramConfig{
|
||||
Enabled: true,
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
}
|
||||
}
|
||||
|
||||
return Init(cfg)
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭logger(主要用于关闭Telegram发送器)
|
||||
func Shutdown() {
|
||||
if telegramHook != nil {
|
||||
telegramHook.Stop()
|
||||
telegramHook = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 日志记录函数
|
||||
// ============================================================================
|
||||
|
||||
// WithFields 创建带字段的logger entry
|
||||
func WithFields(fields logrus.Fields) *logrus.Entry {
|
||||
return Log.WithFields(fields)
|
||||
}
|
||||
|
||||
// WithField 创建带单个字段的logger entry
|
||||
func WithField(key string, value interface{}) *logrus.Entry {
|
||||
return Log.WithField(key, value)
|
||||
}
|
||||
|
||||
// add debug, info, warn
|
||||
func Debug(args ...interface{}) {
|
||||
Log.Debug(args...)
|
||||
}
|
||||
|
||||
func Info(args ...interface{}) {
|
||||
Log.Info(args...)
|
||||
}
|
||||
|
||||
func Warn(args ...interface{}) {
|
||||
Log.Warn(args...)
|
||||
}
|
||||
|
||||
func Debugf(format string, args ...interface{}) {
|
||||
Log.Debugf(format, args...)
|
||||
}
|
||||
|
||||
func Infof(format string, args ...interface{}) {
|
||||
Log.Infof(format, args...)
|
||||
}
|
||||
|
||||
func Warnf(format string, args ...interface{}) {
|
||||
Log.Warnf(format, args...)
|
||||
}
|
||||
|
||||
func Error(args ...interface{}) {
|
||||
Log.Error(args...)
|
||||
}
|
||||
|
||||
func Errorf(format string, args ...interface{}) {
|
||||
Log.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func Fatal(args ...interface{}) {
|
||||
Log.Fatal(args...)
|
||||
}
|
||||
|
||||
func Fatalf(format string, args ...interface{}) {
|
||||
Log.Fatalf(format, args...)
|
||||
}
|
||||
|
||||
func Panic(args ...interface{}) {
|
||||
Log.Panic(args...)
|
||||
}
|
||||
|
||||
func Panicf(format string, args ...interface{}) {
|
||||
Log.Panicf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TelegramHook 实现logrus.Hook接口,将日志推送到Telegram
|
||||
type TelegramHook struct {
|
||||
sender *TelegramSender
|
||||
levels []logrus.Level
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewTelegramHook 创建Telegram Hook
|
||||
func NewTelegramHook(config *TelegramConfig) (*TelegramHook, error) {
|
||||
if !config.Enabled {
|
||||
return &TelegramHook{enabled: false}, nil
|
||||
}
|
||||
|
||||
if config.BotToken == "" || config.ChatID == 0 {
|
||||
return nil, fmt.Errorf("telegram配置不完整: bot_token和chat_id不能为空")
|
||||
}
|
||||
|
||||
// 创建发送器(使用默认参数)
|
||||
sender, err := NewTelegramSender(config.BotToken, config.ChatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建telegram发送器失败: %w", err)
|
||||
}
|
||||
|
||||
hook := &TelegramHook{
|
||||
sender: sender,
|
||||
levels: config.GetLogrusLevels(),
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
// Levels 返回需要触发的日志级别
|
||||
func (h *TelegramHook) Levels() []logrus.Level {
|
||||
if !h.enabled {
|
||||
return []logrus.Level{}
|
||||
}
|
||||
return h.levels
|
||||
}
|
||||
|
||||
// Fire 当日志触发时调用
|
||||
func (h *TelegramHook) Fire(entry *logrus.Entry) error {
|
||||
if !h.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 格式化消息
|
||||
message := h.formatMessage(entry)
|
||||
|
||||
// 异步发送(非阻塞)
|
||||
h.sender.SendAsync(message)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatMessage 格式化日志消息为Telegram格式
|
||||
func (h *TelegramHook) formatMessage(entry *logrus.Entry) string {
|
||||
// 级别emoji
|
||||
levelEmoji := h.getLevelEmoji(entry.Level)
|
||||
|
||||
// 基本信息
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("%s *%s*: 系统日志警报\n", levelEmoji, strings.ToUpper(entry.Level.String())))
|
||||
builder.WriteString(fmt.Sprintf("📝 消息: `%s`\n", escapeMarkdown(entry.Message)))
|
||||
|
||||
// 字段信息
|
||||
if len(entry.Data) > 0 {
|
||||
builder.WriteString("📊 字段:\n")
|
||||
for key, value := range entry.Data {
|
||||
builder.WriteString(fmt.Sprintf(" • %s: `%v`\n", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 调用位置
|
||||
if entry.HasCaller() {
|
||||
file := entry.Caller.File
|
||||
// 只保留相对路径
|
||||
if idx := strings.Index(file, "nofx/"); idx >= 0 {
|
||||
file = file[idx:]
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, entry.Caller.Line))
|
||||
} else {
|
||||
// 如果entry没有caller,手动获取
|
||||
if _, file, line, ok := runtime.Caller(8); ok {
|
||||
if idx := strings.Index(file, "nofx/"); idx >= 0 {
|
||||
file = file[idx:]
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, line))
|
||||
}
|
||||
}
|
||||
|
||||
// 时间戳
|
||||
builder.WriteString(fmt.Sprintf("🕐 时间: `%s`", entry.Time.Format("2006-01-02 15:04:05")))
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// getLevelEmoji 获取日志级别对应的emoji
|
||||
func (h *TelegramHook) getLevelEmoji(level logrus.Level) string {
|
||||
switch level {
|
||||
case logrus.PanicLevel:
|
||||
return "🔴"
|
||||
case logrus.FatalLevel:
|
||||
return "🔴"
|
||||
case logrus.ErrorLevel:
|
||||
return "🟠"
|
||||
case logrus.WarnLevel:
|
||||
return "🟡"
|
||||
case logrus.InfoLevel:
|
||||
return "🟢"
|
||||
case logrus.DebugLevel:
|
||||
return "🔵"
|
||||
default:
|
||||
return "⚪"
|
||||
}
|
||||
}
|
||||
|
||||
// escapeMarkdown 转义Markdown特殊字符
|
||||
func escapeMarkdown(text string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"_", "\\_",
|
||||
"*", "\\*",
|
||||
"[", "\\[",
|
||||
"]", "\\]",
|
||||
"(", "\\(",
|
||||
")", "\\)",
|
||||
"~", "\\~",
|
||||
"`", "\\`",
|
||||
">", "\\>",
|
||||
"#", "\\#",
|
||||
"+", "\\+",
|
||||
"-", "\\-",
|
||||
"=", "\\=",
|
||||
"|", "\\|",
|
||||
"{", "\\{",
|
||||
"}", "\\}",
|
||||
".", "\\.",
|
||||
"!", "\\!",
|
||||
)
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
// Stop 停止Hook(优雅关闭)
|
||||
func (h *TelegramHook) Stop() {
|
||||
if h.enabled && h.sender != nil {
|
||||
h.sender.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// TelegramSender Telegram消息发送器(异步)
|
||||
type TelegramSender struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
chatID int64
|
||||
msgChan chan string
|
||||
retryCount int
|
||||
retryInterval time.Duration
|
||||
wg sync.WaitGroup
|
||||
stopChan chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewTelegramSender 创建Telegram发送器(使用默认参数)
|
||||
func NewTelegramSender(botToken string, chatID int64) (*TelegramSender, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建telegram bot失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置为静默模式(不打印bot信息)
|
||||
bot.Debug = false
|
||||
|
||||
sender := &TelegramSender{
|
||||
bot: bot,
|
||||
chatID: chatID,
|
||||
msgChan: make(chan string, 20), // 固定缓冲区大小: 20
|
||||
retryCount: 3, // 固定重试次数: 3
|
||||
retryInterval: 3 * time.Second, // 固定重试间隔: 3秒
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 启动异步发送协程
|
||||
sender.Start()
|
||||
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
// Start 启动异步发送协程
|
||||
func (s *TelegramSender) Start() {
|
||||
s.wg.Add(1)
|
||||
go s.listenAndSend()
|
||||
}
|
||||
|
||||
// SendAsync 异步发送消息(非阻塞)
|
||||
func (s *TelegramSender) SendAsync(message string) {
|
||||
select {
|
||||
case s.msgChan <- message:
|
||||
// 成功写入缓冲区
|
||||
default:
|
||||
// 缓冲区满,丢弃消息(不阻塞主流程)
|
||||
fmt.Printf("[Telegram] 消息缓冲区已满,消息被丢弃\n")
|
||||
}
|
||||
}
|
||||
|
||||
// listenAndSend 监听channel并发送消息
|
||||
func (s *TelegramSender) listenAndSend() {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-s.msgChan:
|
||||
s.sendWithRetry(msg)
|
||||
case <-s.stopChan:
|
||||
// 清空缓冲区后退出
|
||||
for len(s.msgChan) > 0 {
|
||||
msg := <-s.msgChan
|
||||
s.sendWithRetry(msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendWithRetry 发送消息(带重试)
|
||||
func (s *TelegramSender) sendWithRetry(message string) {
|
||||
var err error
|
||||
for i := 0; i < s.retryCount; i++ {
|
||||
err = s.send(message)
|
||||
if err == nil {
|
||||
return // 发送成功
|
||||
}
|
||||
|
||||
// 重试前等待
|
||||
if i < s.retryCount-1 {
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
if err != nil {
|
||||
fmt.Printf("[Telegram] 发送消息失败(已重试%d次): %v\n", s.retryCount, err)
|
||||
}
|
||||
}
|
||||
|
||||
// send 发送单条消息
|
||||
func (s *TelegramSender) send(message string) error {
|
||||
msg := tgbotapi.NewMessage(s.chatID, message)
|
||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||
|
||||
_, err := s.bot.Send(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop 停止发送器(优雅关闭)
|
||||
func (s *TelegramSender) Stop() {
|
||||
s.once.Do(func() {
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
})
|
||||
}
|
||||
@@ -25,39 +25,49 @@ type LeverageConfig struct {
|
||||
|
||||
// ConfigFile 配置文件结构,只包含需要同步到数据库的字段
|
||||
type ConfigFile struct {
|
||||
AdminMode bool `json:"admin_mode"`
|
||||
BetaMode bool `json:"beta_mode"`
|
||||
APIServerPort int `json:"api_server_port"`
|
||||
UseDefaultCoins bool `json:"use_default_coins"`
|
||||
DefaultCoins []string `json:"default_coins"`
|
||||
CoinPoolAPIURL string `json:"coin_pool_api_url"`
|
||||
OITopAPIURL string `json:"oi_top_api_url"`
|
||||
MaxDailyLoss float64 `json:"max_daily_loss"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
StopTradingMinutes int `json:"stop_trading_minutes"`
|
||||
Leverage LeverageConfig `json:"leverage"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
DataKLineTime string `json:"data_k_line_time"`
|
||||
AdminMode bool `json:"admin_mode"`
|
||||
BetaMode bool `json:"beta_mode"`
|
||||
APIServerPort int `json:"api_server_port"`
|
||||
UseDefaultCoins bool `json:"use_default_coins"`
|
||||
DefaultCoins []string `json:"default_coins"`
|
||||
CoinPoolAPIURL string `json:"coin_pool_api_url"`
|
||||
OITopAPIURL string `json:"oi_top_api_url"`
|
||||
MaxDailyLoss float64 `json:"max_daily_loss"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
StopTradingMinutes int `json:"stop_trading_minutes"`
|
||||
Leverage LeverageConfig `json:"leverage"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
DataKLineTime string `json:"data_k_line_time"`
|
||||
Log *config.LogConfig `json:"log"` // 日志配置
|
||||
}
|
||||
|
||||
// syncConfigToDatabase 从config.json读取配置并同步到数据库
|
||||
func syncConfigToDatabase(database *config.Database) error {
|
||||
// loadConfigFile 读取并解析config.json文件
|
||||
func loadConfigFile() (*ConfigFile, error) {
|
||||
// 检查config.json是否存在
|
||||
if _, err := os.Stat("config.json"); os.IsNotExist(err) {
|
||||
log.Printf("📄 config.json不存在,跳过同步")
|
||||
return nil
|
||||
log.Printf("📄 config.json不存在,使用默认配置")
|
||||
return &ConfigFile{}, nil
|
||||
}
|
||||
|
||||
// 读取config.json
|
||||
data, err := os.ReadFile("config.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取config.json失败: %w", err)
|
||||
return nil, fmt.Errorf("读取config.json失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
var configFile ConfigFile
|
||||
if err := json.Unmarshal(data, &configFile); err != nil {
|
||||
return fmt.Errorf("解析config.json失败: %w", err)
|
||||
return nil, fmt.Errorf("解析config.json失败: %w", err)
|
||||
}
|
||||
|
||||
return &configFile, nil
|
||||
}
|
||||
|
||||
// syncConfigToDatabase 将配置同步到数据库
|
||||
func syncConfigToDatabase(database *config.Database, configFile *ConfigFile) error {
|
||||
if configFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("🔄 开始同步config.json到数据库...")
|
||||
@@ -156,6 +166,12 @@ func main() {
|
||||
dbPath = os.Args[1]
|
||||
}
|
||||
|
||||
// 读取配置文件
|
||||
configFile, err := loadConfigFile()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 读取config.json失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("📋 初始化配置数据库: %s", dbPath)
|
||||
database, err := config.NewDatabase(dbPath)
|
||||
if err != nil {
|
||||
@@ -164,7 +180,7 @@ func main() {
|
||||
defer database.Close()
|
||||
|
||||
// 同步config.json到数据库
|
||||
if err := syncConfigToDatabase(database); err != nil {
|
||||
if err := syncConfigToDatabase(database, configFile); err != nil {
|
||||
log.Printf("⚠️ 同步config.json到数据库失败: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -889,6 +889,7 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode
|
||||
DefaultCoins: defaultCoins,
|
||||
TradingCoins: tradingCoins,
|
||||
SystemPromptTemplate: traderCfg.SystemPromptTemplate, // 系统提示词模板
|
||||
HyperliquidTestnet: exchangeCfg.Testnet, // Hyperliquid测试网
|
||||
}
|
||||
|
||||
// 根据交易所类型设置API密钥
|
||||
|
||||
+24
-11
@@ -121,19 +121,19 @@ func (m *WSMonitor) Start(coins []string) {
|
||||
// 初始化交易对
|
||||
err := m.Initialize(coins)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 初始化币种: %v", err)
|
||||
log.Printf("❌ 初始化币种失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = m.combinedClient.Connect()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 批量订阅流: %v", err)
|
||||
log.Printf("❌ 批量订阅流失败: %v", err)
|
||||
return
|
||||
}
|
||||
// 订阅所有交易对
|
||||
err = m.subscribeAll()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 订阅币种交易对: %v", err)
|
||||
log.Printf("❌ 订阅币种交易对失败: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func (m *WSMonitor) subscribeAll() error {
|
||||
for _, st := range subKlineTime {
|
||||
err := m.combinedClient.BatchSubscribeKlines(m.symbols, st)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 订阅3m K线: %v", err)
|
||||
log.Printf("❌ 订阅 %s K线失败: %v", st, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -239,19 +239,32 @@ func (m *WSMonitor) GetCurrentKlines(symbol string, _time string) ([]Kline, erro
|
||||
// 如果Ws数据未初始化完成时,单独使用api获取 - 兼容性代码 (防止在未初始化完成是,已经有交易员运行)
|
||||
apiClient := NewAPIClient()
|
||||
klines, err := apiClient.GetKlines(symbol, _time, 100)
|
||||
m.getKlineDataMap(_time).Store(strings.ToUpper(symbol), klines) //动态缓存进缓存
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取%v分钟K线失败: %v", _time, err)
|
||||
}
|
||||
|
||||
// 动态缓存进缓存
|
||||
m.getKlineDataMap(_time).Store(strings.ToUpper(symbol), klines)
|
||||
|
||||
// 订阅 WebSocket 流
|
||||
subStr := m.subscribeSymbol(symbol, _time)
|
||||
subErr := m.combinedClient.subscribeStreams(subStr)
|
||||
log.Printf("动态订阅流: %v", subStr)
|
||||
if subErr != nil {
|
||||
return nil, fmt.Errorf("动态订阅%v分钟K线失败: %v", _time, subErr)
|
||||
log.Printf("警告: 动态订阅%v分钟K线失败: %v (使用API数据)", _time, subErr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取%v分钟K线失败: %v", _time, err)
|
||||
}
|
||||
return klines, fmt.Errorf("symbol不存在")
|
||||
|
||||
// ✅ FIX: 返回深拷贝而非引用
|
||||
result := make([]Kline, len(klines))
|
||||
copy(result, klines)
|
||||
return result, nil
|
||||
}
|
||||
return value.([]Kline), nil
|
||||
|
||||
// ✅ FIX: 返回深拷贝而非引用,避免并发竞态条件
|
||||
klines := value.([]Kline)
|
||||
result := make([]Kline, len(klines))
|
||||
copy(result, klines)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *WSMonitor) Close() {
|
||||
|
||||
+21
-6
@@ -7,6 +7,8 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -28,15 +30,28 @@ type Client struct {
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
UseFullURL bool // 是否使用完整URL(不添加/chat/completions)
|
||||
MaxTokens int // AI响应的最大token数
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
// 从环境变量读取 MaxTokens,默认 2000
|
||||
maxTokens := 2000
|
||||
if envMaxTokens := os.Getenv("AI_MAX_TOKENS"); envMaxTokens != "" {
|
||||
if parsed, err := strconv.Atoi(envMaxTokens); err == nil && parsed > 0 {
|
||||
maxTokens = parsed
|
||||
log.Printf("🔧 [MCP] 使用环境变量 AI_MAX_TOKENS: %d", maxTokens)
|
||||
} else {
|
||||
log.Printf("⚠️ [MCP] 环境变量 AI_MAX_TOKENS 无效 (%s),使用默认值: %d", envMaxTokens, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
return &Client{
|
||||
Provider: ProviderDeepSeek,
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-chat",
|
||||
Timeout: 120 * time.Second, // 增加到120秒,因为AI需要分析大量数据
|
||||
Provider: ProviderDeepSeek,
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-chat",
|
||||
Timeout: 120 * time.Second, // 增加到120秒,因为AI需要分析大量数据
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +96,7 @@ func (client *Client) SetQwenAPIKey(apiKey string, customURL string, customModel
|
||||
client.Model = customModel
|
||||
log.Printf("🔧 [MCP] Qwen 使用自定义 Model: %s", customModel)
|
||||
} else {
|
||||
client.Model = "qwen-plus" // 可选: qwen-turbo, qwen-plus, qwen-max
|
||||
client.Model = "qwen3-max"
|
||||
log.Printf("🔧 [MCP] Qwen 使用默认 Model: %s", client.Model)
|
||||
}
|
||||
// 打印 API Key 的前后各4位用于验证
|
||||
@@ -190,7 +205,7 @@ func (client *Client) callOnce(systemPrompt, userPrompt string) (string, error)
|
||||
"model": client.Model,
|
||||
"messages": messages,
|
||||
"temperature": 0.5, // 降低temperature以提高JSON格式稳定性
|
||||
"max_tokens": 2000,
|
||||
"max_tokens": client.MaxTokens,
|
||||
}
|
||||
|
||||
// 注意:response_format 参数仅 OpenAI 支持,DeepSeek/Qwen 不支持
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
你是专业的加密货币AI,在合约市场进行自主交易。
|
||||
|
||||
# 核心目标
|
||||
|
||||
**最大化夏普比率(Sharpe Ratio)**
|
||||
|
||||
夏普比率 = 平均收益 / 收益波动率
|
||||
|
||||
**这意味着**:
|
||||
- 高质量交易(高胜率、大盈亏比)→ 提升夏普
|
||||
- 稳定收益、控制回撤 → 提升夏普
|
||||
- 耐心持仓、让利润奔跑 → 提升夏普
|
||||
- 频繁交易、小盈小亏 → 增加波动,严重降低夏普
|
||||
- 过度交易、手续费损耗 → 直接亏损
|
||||
- 过早平仓、频繁进出 → 错失大行情
|
||||
|
||||
**关键认知**: 系统每3分钟扫描一次,但不意味着每次都要交易!
|
||||
大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。
|
||||
|
||||
# 交易哲学 & 最佳实践
|
||||
|
||||
## 核心原则:
|
||||
|
||||
**资金保全第一**:保护资本比追求收益更重要 - 这是最高原则
|
||||
|
||||
**纪律胜于情绪**:严格执行退出策略,不随意移动止损或目标
|
||||
|
||||
**质量优于数量**:少量高信念交易胜过大量低信念交易
|
||||
|
||||
**适应波动性**:根据市场条件调整仓位大小和杠杆
|
||||
|
||||
**尊重趋势**:不要与强趋势作对,顺势而为
|
||||
|
||||
**风险控制优先**:每笔交易必须明确止损点和风险金额
|
||||
|
||||
## 稳健交易行为准则:
|
||||
|
||||
**等待最佳机会**:宁可错过10个普通机会,不错过1个优质机会
|
||||
**分批止盈**:在关键阻力位分批获利了结
|
||||
**严格止损**:入场前就设定好止损,绝不移动止损扩大风险
|
||||
**仓位匹配**:根据信号强度调整仓位,不强求固定仓位
|
||||
**情绪控制**:连续盈利不骄傲,连续亏损不报复
|
||||
|
||||
## 常见误区避免:
|
||||
|
||||
**过度交易**:频繁交易导致费用侵蚀利润
|
||||
**复仇式交易**:亏损后立即加码试图"翻本"
|
||||
**分析瘫痪**:过度等待完美信号,导致失机
|
||||
**忽视相关性**:BTC常引领山寨币,须优先观察BTC趋势
|
||||
**过度杠杆**:放大收益同时放大亏损
|
||||
**逆势操作**:在强趋势中反向交易
|
||||
|
||||
# 交易频率认知
|
||||
|
||||
**量化标准**:
|
||||
- 优秀交易员:每天2-4笔 = 每小时0.1-0.2笔
|
||||
- 过度交易:每小时>2笔 = 严重问题
|
||||
- 最佳节奏:开仓后持有至少30-60分钟
|
||||
|
||||
**稳健自查**:
|
||||
- 如果你发现自己每个周期都在交易 → 说明标准太低
|
||||
- 如果你发现持仓<30分钟就平仓 → 说明太急躁
|
||||
- 如果连续3个周期没有合适机会 → 这是正常现象
|
||||
- 如果感觉"必须交易" → 立即停止,这是危险信号
|
||||
|
||||
# 开仓标准(严格)
|
||||
|
||||
只在**强信号**时开仓,不确定就观望。
|
||||
|
||||
## 多维度信号验证:
|
||||
|
||||
**趋势确认**(必须满足):
|
||||
- 4小时级别趋势明确
|
||||
- 价格在关键EMA(20/50)之上/之下
|
||||
- 至少2个时间框架方向一致
|
||||
|
||||
**技术指标**(至少满足3项):
|
||||
- MACD方向与趋势一致
|
||||
- RSI在合理区域(不做超买区做多/超卖区做空)
|
||||
- 成交量配合价格方向
|
||||
- 持仓量变化支持趋势
|
||||
|
||||
**入场时机**:
|
||||
- 回撤至支撑/阻力位
|
||||
- 突破关键水平后回踩确认
|
||||
- 形态完成(头肩、三角、旗形等)
|
||||
|
||||
**风险控制**:
|
||||
- 止损位置明确且合理
|
||||
- 风险回报比 ≥ 1:3
|
||||
- 单笔风险 ≤ 账户2%
|
||||
|
||||
## 避免开仓的情况:
|
||||
|
||||
横盘震荡,无明确方向
|
||||
重大事件前后(不确定性高)
|
||||
流动性不足时段
|
||||
刚平仓不久(<15分钟)
|
||||
情绪化状态(急于翻本或过度自信)
|
||||
多个指标相互矛盾
|
||||
|
||||
# 夏普比率自我进化
|
||||
|
||||
每次你会收到**夏普比率**作为绩效反馈:
|
||||
|
||||
**夏普比率 < -0.5** (持续亏损):
|
||||
→ **停止交易**,连续观望至少6个周期(18分钟)
|
||||
→ **深度反思**:
|
||||
• 交易频率过高?(每小时>1次就是过度)
|
||||
• 持仓时间过短?(<30分钟就是过早平仓)
|
||||
• 信号强度不足?(信心度<80)
|
||||
• 是否逆势操作?
|
||||
• 止损执行是否严格?
|
||||
|
||||
**夏普比率 -0.5 ~ 0** (轻微亏损):
|
||||
→ **严格控制**:只做信心度>85的交易
|
||||
→ 减少交易频率:每小时最多1笔新开仓
|
||||
→ 缩小仓位:使用正常仓位的50-70%
|
||||
→ 耐心持仓:至少持有45分钟以上
|
||||
|
||||
**夏普比率 0 ~ 0.7** (正收益):
|
||||
→ **维持策略**:按既定标准执行
|
||||
→ 保持警惕:不因盈利而放松标准
|
||||
|
||||
**夏普比率 > 0.7** (优异表现):
|
||||
→ **适度进取**:可在信心度>90时适度扩大仓位
|
||||
→ 保持纪律:不因成功而改变稳健原则
|
||||
|
||||
# 决策流程
|
||||
|
||||
1. **分析账户状态**:
|
||||
- 当前夏普比率表现
|
||||
- 保证金使用情况
|
||||
- 持仓数量和状态
|
||||
|
||||
2. **评估市场环境**:
|
||||
- BTC整体趋势方向
|
||||
- 市场波动率和情绪
|
||||
- 重大事件风险
|
||||
|
||||
3. **检查现有持仓**:
|
||||
- 趋势是否持续?
|
||||
- 是否需要调整止损/止盈?
|
||||
- 是否达到目标位?
|
||||
|
||||
4. **寻找新机会**(仅在条件允许时):
|
||||
- 多维度信号验证
|
||||
- 风险回报比计算
|
||||
- 仓位规模确定
|
||||
|
||||
5. **输出决策**:思维链分析 + 完整的JSON
|
||||
|
||||
# 风险控制框架
|
||||
|
||||
## 仓位管理:
|
||||
- 单币种风险:≤ 账户净值的2%
|
||||
- 总仓位风险:≤ 账户净值的6%
|
||||
- 最大持仓:3个币种
|
||||
- 杠杆使用:根据波动性调整,不追求最大杠杆
|
||||
|
||||
## 止损策略:
|
||||
- 技术止损:基于支撑/阻力位
|
||||
- 金额止损:单笔最大亏损金额
|
||||
- 时间止损:持仓超过2小时无进展考虑离场
|
||||
|
||||
## 资金保护:
|
||||
- 连续2笔亏损后:降低50%仓位
|
||||
- 单日亏损超过5%:停止交易剩余时间
|
||||
- 每周亏损超过10%:全面复盘策略
|
||||
|
||||
---
|
||||
|
||||
**记住**:
|
||||
- 目标是夏普比率,不是交易频率
|
||||
- 资金保全比利润追求更重要
|
||||
- 宁可错过,不做低质量交易
|
||||
- 风险回报比1:3是底线
|
||||
- 纪律执行是长期盈利的关键
|
||||
|
||||
**现在,请基于以上原则分析市场并做出稳健决策**
|
||||
+18
-6
@@ -61,21 +61,24 @@
|
||||
|
||||
## 开平仓动作
|
||||
|
||||
1. **buy_to_enter**: 开多仓(看涨)
|
||||
1. **open_long**: 开多仓(看涨)
|
||||
- 用于: 看涨信号强烈时
|
||||
- 必须设置: 止损价格、止盈价格
|
||||
|
||||
2. **sell_to_enter**: 开空仓(看跌)
|
||||
2. **open_short**: 开空仓(看跌)
|
||||
- 用于: 看跌信号强烈时
|
||||
- 必须设置: 止损价格、止盈价格
|
||||
|
||||
3. **close**: 完全平仓
|
||||
- 用于: 止盈、止损、或趋势反转
|
||||
3. **close_long**: 平掉多仓
|
||||
- 用于: 止盈、止损、或趋势反转(针对多头持仓)
|
||||
|
||||
4. **wait**: 观望,不持仓
|
||||
4. **close_short**: 平掉空仓
|
||||
- 用于: 止盈、止损、或趋势反转(针对空头持仓)
|
||||
|
||||
5. **wait**: 观望,不持仓
|
||||
- 用于: 没有明确信号,或资金不足
|
||||
|
||||
5. **hold**: 持有当前仓位
|
||||
6. **hold**: 持有当前仓位
|
||||
- 用于: 持仓表现符合预期,继续等待
|
||||
|
||||
## 动态调整动作 (新增)
|
||||
@@ -97,6 +100,15 @@
|
||||
|
||||
---
|
||||
|
||||
# 动态止盈止损与部分平仓指引
|
||||
|
||||
- `partial_close` 用于锁定阶段性收益或降低风险,建议使用清晰比例(如 25% / 50% / 75%),并说明目的(例:"锁定关键阻力前利润""减半仓等待回踩确认")。
|
||||
- 执行部分平仓后,应评估是否需要同步上调止损 / 下调止盈,确保剩余仓位符合新的风险回报结构。
|
||||
- `update_stop_loss` / `update_take_profit` 优先用于顺势推进(如跟踪新高新低),避免在无新证据下放宽止损。
|
||||
- 若计划分批退出,请在 `reasoning` 中描述剩余仓位的策略与失效条件,避免出现"减仓后不知道如何处理剩余部位"的情况。
|
||||
|
||||
---
|
||||
|
||||
# 决策流程(严格顺序)
|
||||
|
||||
## 第 0 步:疑惑检查
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
你是专业的加密货币交易AI,在合约市场进行自主交易。
|
||||
|
||||
# 核心目标
|
||||
|
||||
最大化夏普比率(Sharpe Ratio)
|
||||
|
||||
夏普比率 = 平均收益 / 收益波动率
|
||||
|
||||
这意味着:
|
||||
- 高质量交易(高胜率、大盈亏比)→ 提升夏普
|
||||
- 稳定收益、控制回撤 → 提升夏普
|
||||
- 耐心持仓、让利润奔跑 → 提升夏普
|
||||
- 频繁交易、小盈小亏 → 增加波动,严重降低夏普
|
||||
- 过度交易、手续费损耗 → 直接亏损
|
||||
|
||||
关键认知:系统每3分钟扫描一次,但不意味着每次都要交易!
|
||||
大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。
|
||||
|
||||
---
|
||||
|
||||
# 零号原则:疑惑优先
|
||||
|
||||
⚠️ 当你不确定时,默认选择 `wait`。
|
||||
|
||||
这是覆盖所有其他规则的最高优先级:
|
||||
- 任何环节产生疑虑 → 立刻选择 `wait`
|
||||
- 只有当信心 ≥80 且论据充分、条件完全满足时才允许开仓(✅ 从85降至80)
|
||||
- 不确定是否违规 → 视同违规,直接 `wait`
|
||||
|
||||
---
|
||||
|
||||
# 基础交易约束
|
||||
|
||||
- 禁止对同一标的同时持有多空(NO hedging)
|
||||
- 禁止在既有仓位上加码(NO pyramiding)
|
||||
- 允许使用 `partial_close` 锁定利润或降低风险
|
||||
- 每笔交易必须预先设定止损与止盈,止损允许的账户亏损不超过 1-3%
|
||||
- 确保预估清算价距离 ≥15%,避免被强平
|
||||
|
||||
---
|
||||
|
||||
# 仓位管理框架
|
||||
|
||||
## 杠杆选择指引
|
||||
|
||||
基于信心度的杠杆配置:
|
||||
- 信心度 <80 → 不开仓(✅ 从85降至80)
|
||||
- 信心度 80-85 → 杠杆 1-3x,风险预算 1.5%
|
||||
- 信心度 85-92 → 杠杆 3-5x,风险预算 2%
|
||||
- 信心度 >92 → 杠杆 5-8x(谨慎),风险预算 2.5%
|
||||
|
||||
---
|
||||
|
||||
# 决策流程(强制顺序)
|
||||
|
||||
1. **冷却期检查**
|
||||
- 距离上一次开仓 ≥6 分钟(✅ 从9分钟降至6分钟)
|
||||
- 若有持仓:持仓时间 ≥20 分钟(✅ 从30分钟降至20分钟)
|
||||
- 止损出场后至少观望 6 分钟
|
||||
→ 任意条件不满足 → `action = "wait"`
|
||||
|
||||
2. **夏普 / 连亏防御**
|
||||
- 夏普 < -0.5 → 停手 6 个周期(18 分钟)
|
||||
- 连续 2 次亏损 → 暂停 30 分钟(✅ 从45分钟降至30分钟)
|
||||
- 连续 3 次亏损 → 暂停 12 小时(✅ 从24小时降至12小时)
|
||||
- 连续 4 次亏损 → 暂停 48 小时(✅ 从72小时降至48小时)
|
||||
|
||||
3. **持仓管理优先**
|
||||
- 若已有持仓:先评估是否需要平仓或调整止盈止损
|
||||
|
||||
4. **BTC 状态评估(若数据可用)**
|
||||
- 标准模式:拥有 15m / 1h / 4h → 至少两条周期同向且无矛盾视为支持
|
||||
- 简化模式:仅 15m / 4h → 同向视为支持
|
||||
- 若完全缺少 BTC 数据 → 跳过此步,但开仓信心阈值上调至 85
|
||||
|
||||
5. **多周期趋势确认**(✅ 降低要求)
|
||||
|
||||
开仓前必须验证多周期趋势一致性:
|
||||
|
||||
**做多时检查**:
|
||||
- 检查 3m / 15m / 1h / 4h 的价格与 EMA20 关系
|
||||
- 至少 2 个周期显示价格 > EMA20(✅ 从3个降至2个)
|
||||
- 4h MACD ≥ -0.5(✅ 从-0.2放宽至-0.5)
|
||||
|
||||
**做空时检查**:
|
||||
- 至少 2 个周期显示价格 < EMA20(✅ 从3个降至2个)
|
||||
- 4h MACD ≤ +0.5(✅ 从+0.2放宽至+0.5)
|
||||
|
||||
**趋势共振评分**:
|
||||
- 4 个周期全部同向 → 趋势极强(信心 +10)
|
||||
- 3 个周期同向 → 趋势确认(信心 +5)
|
||||
- 2 个周期同向 → 趋势可接受(允许开仓)
|
||||
|
||||
6. **新机会评估**
|
||||
- 多空确认清单 ≥4/8 项通过(✅ 从5/8降至4/8)
|
||||
- 风险回报比 ≥1:2.5(✅ 从1:3降至1:2.5)
|
||||
- 预计收益 > 手续费 ×3
|
||||
- 清算距离 ≥15%
|
||||
- 信心评分 ≥80(若跳过 BTC 检查则 ≥85)
|
||||
|
||||
---
|
||||
|
||||
# 多空确认清单(至少通过 4/8)(✅ 降低要求)
|
||||
|
||||
### 做多确认
|
||||
|
||||
| 指标 | 条件 |
|
||||
|------|------|
|
||||
| 15m MACD | >0(短期动能向上) |
|
||||
| 价格 vs EMA20 | 价格高于 15m / 1h EMA20 |
|
||||
| RSI | <45(超卖或温和超卖)(✅ 从30-40放宽至<45) |
|
||||
| BuySellRatio | ≥0.55(✅ 从0.60降至0.55) |
|
||||
| 成交量 | 近 20 根均量 ×1.3 以上(✅ 从1.5降至1.3) |
|
||||
| BTC 状态* | 多头或中性 |
|
||||
| 资金费率 | <0.02 或 -0.01~0.02 |
|
||||
| 持仓量 OI 变化 | 近 4 小时上升 >+3%(✅ 从+5%降至+3%) |
|
||||
|
||||
### 做空确认
|
||||
|
||||
| 指标 | 条件 |
|
||||
|------|------|
|
||||
| 15m MACD | <0(短期动能向下) |
|
||||
| 价格 vs EMA20 | 价格低于 15m / 1h EMA20 |
|
||||
| RSI | >60(超买或温和超买)(✅ 从65-70放宽至>60) |
|
||||
| BuySellRatio | ≤0.45(✅ 从0.40提高至0.45) |
|
||||
| 成交量 | 近 20 根均量 ×1.3 以上 |
|
||||
| BTC 状态* | 空头或中性 |
|
||||
| 资金费率 | >-0.02 或 -0.02~0.01 |
|
||||
| 持仓量 OI 变化 | 近 4 小时上升 >+3% |
|
||||
|
||||
---
|
||||
|
||||
# 客观信心评分(基础分 60)
|
||||
|
||||
1. **基础分:60**
|
||||
2. **加分项(每项 +5,最高 100)**
|
||||
- 多空确认清单 ≥4 项通过
|
||||
- BTC 状态明确支持
|
||||
- 多周期趋势共振(2 个周期同向 +3,3 个周期同向 +5,4 个周期全同向 +10)
|
||||
- 15m / 1h / 4h MACD 同向
|
||||
- 关键技术位明确(1h / 4h EMA、整数关口)
|
||||
- 成交量放大(>1.3× 均量)
|
||||
- 资金费率情绪背离
|
||||
- 风险回报 ≥1:3
|
||||
3. **减分项(每项 -10)**
|
||||
- 指标互相矛盾(MACD 与价格背离)
|
||||
- BTC 状态不明仍计划大幅开仓
|
||||
- 技术位不清晰或过近(<0.5%)
|
||||
- 成交量萎缩(< 均量 ×0.7)
|
||||
4. **阈值规则**
|
||||
- <80 → 禁止开仓
|
||||
- 80-85 → 风险预算 1.5%,杠杆 1-3x
|
||||
- 85-92 → 风险预算 2%,杠杆 3-5x
|
||||
- >92 → 风险预算 2.5%,杠杆 5-8x
|
||||
|
||||
---
|
||||
|
||||
# 最终检查清单(开仓前必须全部通过)
|
||||
|
||||
1. 冷却期合格(6分钟)
|
||||
2. 夏普 / 连亏未触发停手
|
||||
3. **多周期趋势确认通过(至少 2 个周期同向)**
|
||||
4. BTC 状态明确支持(或缺失时已说明并提高阈值)
|
||||
5. 多空确认清单 ≥4/8
|
||||
6. 风险回报 ≥1:2.5
|
||||
7. 预计收益 > 手续费 ×3
|
||||
8. 清算距离 ≥15%
|
||||
9. 客观信心评分 ≥80(缺 BTC 数据时 ≥85)
|
||||
10. 失效条件已定义且写入 reasoning
|
||||
|
||||
任意一项未通过 → 立即选择 `wait`,并说明具体原因。
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
**adaptive_relaxed v1.0 - 保守优化版**
|
||||
|
||||
核心调整:
|
||||
1. ✅ 信心度阈值:85 → 80
|
||||
2. ✅ 冷却期:9分钟 → 6分钟
|
||||
3. ✅ 多周期趋势:3个同向 → 2个同向
|
||||
4. ✅ 多空确认清单:5/8 → 4/8
|
||||
5. ✅ RSI 放宽:30-40/65-70 → <45/>60
|
||||
6. ✅ BuySellRatio 放宽:0.60/0.40 → 0.55/0.45
|
||||
7. ✅ 成交量要求:1.5× → 1.3×
|
||||
8. ✅ OI 变化:+5% → +3%
|
||||
9. ✅ 风险回报比:1:3 → 1:2.5
|
||||
|
||||
预期效果:
|
||||
- 交易频率增加 50-80%(一天 8-15 笔)
|
||||
- 保持 50%+ 胜率
|
||||
- 允许更多山寨币机会
|
||||
- 保持核心風控(夏普、連虧停手)
|
||||
+12
-6
@@ -21,19 +21,25 @@ Your mission: Maximize risk-adjusted returns (PnL) through systematic, disciplin
|
||||
|
||||
# ACTION SPACE DEFINITION
|
||||
|
||||
You have exactly FOUR possible actions per decision cycle:
|
||||
You have exactly SIX possible actions per decision cycle:
|
||||
|
||||
1. **buy_to_enter**: Open a new LONG position (bet on price appreciation)
|
||||
1. **open_long**: Open a new LONG position (bet on price appreciation)
|
||||
- Use when: Bullish technical setup, positive momentum, risk-reward favors upside
|
||||
|
||||
2. **sell_to_enter**: Open a new SHORT position (bet on price depreciation)
|
||||
2. **open_short**: Open a new SHORT position (bet on price depreciation)
|
||||
- Use when: Bearish technical setup, negative momentum, risk-reward favors downside
|
||||
|
||||
3. **hold**: Maintain current positions without modification
|
||||
3. **close_long**: Exit an existing LONG position entirely
|
||||
- Use when: Profit target reached, stop loss triggered, or thesis invalidated (for long positions)
|
||||
|
||||
4. **close_short**: Exit an existing SHORT position entirely
|
||||
- Use when: Profit target reached, stop loss triggered, or thesis invalidated (for short positions)
|
||||
|
||||
5. **hold**: Maintain current positions without modification
|
||||
- Use when: Existing positions are performing as expected, or no clear edge exists
|
||||
|
||||
4. **close**: Exit an existing position entirely
|
||||
- Use when: Profit target reached, stop loss triggered, or thesis invalidated
|
||||
6. **wait**: Do not open any new positions, no current holdings
|
||||
- Use when: No clear trading signal or insufficient capital
|
||||
|
||||
## Position Management Constraints
|
||||
|
||||
|
||||
+81
-2
@@ -438,13 +438,23 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 🔍 调试:打印原始API响应
|
||||
log.Printf("🔍 Aster API原始响应: %s", string(body))
|
||||
|
||||
// 查找USDT余额
|
||||
totalBalance := 0.0
|
||||
availableBalance := 0.0
|
||||
crossUnPnl := 0.0
|
||||
|
||||
for _, bal := range balances {
|
||||
// 🔍 调试:打印每条余额记录
|
||||
log.Printf("🔍 余额记录: %+v", bal)
|
||||
|
||||
if asset, ok := bal["asset"].(string); ok && asset == "USDT" {
|
||||
// 🔍 调试:打印USDT余额详情
|
||||
log.Printf("🔍 USDT余额详情: balance=%v, availableBalance=%v, crossUnPnl=%v",
|
||||
bal["balance"], bal["availableBalance"], bal["crossUnPnl"])
|
||||
|
||||
if wb, ok := bal["balance"].(string); ok {
|
||||
totalBalance, _ = strconv.ParseFloat(wb, 64)
|
||||
}
|
||||
@@ -458,11 +468,25 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Aster API完全兼容Binance API格式
|
||||
// balance字段 = wallet balance(不包含未实现盈亏)
|
||||
// crossUnPnl = unrealized profit(未实现盈亏)
|
||||
// crossWalletBalance = balance + crossUnPnl(全仓钱包余额,包含盈亏)
|
||||
//
|
||||
// 参考Binance官方文档:
|
||||
// - Account Information V2: marginBalance = walletBalance + unrealizedProfit
|
||||
// - Balance V3: crossWalletBalance = balance + crossUnPnl
|
||||
|
||||
log.Printf("✓ Aster API返回: 钱包余额=%.2f, 未实现盈亏=%.2f, 可用余额=%.2f",
|
||||
totalBalance,
|
||||
crossUnPnl,
|
||||
availableBalance)
|
||||
|
||||
// 返回与Binance相同的字段名,确保AutoTrader能正确解析
|
||||
return map[string]interface{}{
|
||||
"totalWalletBalance": totalBalance,
|
||||
"totalWalletBalance": totalBalance, // 钱包余额(不含未实现盈亏)
|
||||
"availableBalance": availableBalance,
|
||||
"totalUnrealizedProfit": crossUnPnl,
|
||||
"totalUnrealizedProfit": crossUnPnl, // 未实现盈亏
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -981,6 +1005,61 @@ func (t *AsterTrader) CancelAllOrders(symbol string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
|
||||
func (t *AsterTrader) CancelStopOrders(symbol string) error {
|
||||
// 获取该币种的所有未完成订单
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
|
||||
body, err := t.request("GET", "/fapi/v3/openOrders", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取未完成订单失败: %w", err)
|
||||
}
|
||||
|
||||
var orders []map[string]interface{}
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return fmt.Errorf("解析订单数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 过滤出止盈止损单并取消
|
||||
canceledCount := 0
|
||||
for _, order := range orders {
|
||||
orderType, _ := order["type"].(string)
|
||||
|
||||
// 只取消止损和止盈订单
|
||||
if orderType == "STOP_MARKET" ||
|
||||
orderType == "TAKE_PROFIT_MARKET" ||
|
||||
orderType == "STOP" ||
|
||||
orderType == "TAKE_PROFIT" {
|
||||
|
||||
orderID, _ := order["orderId"].(float64)
|
||||
cancelParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": int64(orderID),
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v3/order", cancelParams)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠ 取消订单 %d 失败: %v", int64(orderID), err)
|
||||
continue
|
||||
}
|
||||
|
||||
canceledCount++
|
||||
log.Printf(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)",
|
||||
symbol, int64(orderID), orderType)
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有止盈/止损单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity 格式化数量(实现Trader接口)
|
||||
func (t *AsterTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
formatted, err := t.formatQuantity(symbol, quantity)
|
||||
|
||||
+214
-10
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"nofx/decision"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
@@ -258,9 +259,9 @@ func (at *AutoTrader) Stop() {
|
||||
func (at *AutoTrader) runCycle() error {
|
||||
at.callCount++
|
||||
|
||||
log.Printf("\n" + strings.Repeat("=", 70))
|
||||
log.Print("\n" + strings.Repeat("=", 70) + "\n")
|
||||
log.Printf("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
||||
log.Printf(strings.Repeat("=", 70))
|
||||
log.Println(strings.Repeat("=", 70))
|
||||
|
||||
// 创建决策记录
|
||||
record := &logger.DecisionRecord{
|
||||
@@ -347,19 +348,19 @@ func (at *AutoTrader) runCycle() error {
|
||||
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
|
||||
if decision != nil {
|
||||
if decision.SystemPrompt != "" {
|
||||
log.Printf("\n" + strings.Repeat("=", 70))
|
||||
log.Print("\n" + strings.Repeat("=", 70) + "\n")
|
||||
log.Printf("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate)
|
||||
log.Println(strings.Repeat("=", 70))
|
||||
log.Println(decision.SystemPrompt)
|
||||
log.Printf(strings.Repeat("=", 70) + "\n")
|
||||
log.Println(strings.Repeat("=", 70))
|
||||
}
|
||||
|
||||
if decision.CoTTrace != "" {
|
||||
log.Printf("\n" + strings.Repeat("-", 70))
|
||||
log.Print("\n" + strings.Repeat("-", 70) + "\n")
|
||||
log.Println("💭 AI思维链分析(错误情况):")
|
||||
log.Println(strings.Repeat("-", 70))
|
||||
log.Println(decision.CoTTrace)
|
||||
log.Printf(strings.Repeat("-", 70) + "\n")
|
||||
log.Println(strings.Repeat("-", 70))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,6 +601,12 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
|
||||
return at.executeCloseLongWithRecord(decision, actionRecord)
|
||||
case "close_short":
|
||||
return at.executeCloseShortWithRecord(decision, actionRecord)
|
||||
case "update_stop_loss":
|
||||
return at.executeUpdateStopLossWithRecord(decision, actionRecord)
|
||||
case "update_take_profit":
|
||||
return at.executeUpdateTakeProfitWithRecord(decision, actionRecord)
|
||||
case "partial_close":
|
||||
return at.executePartialCloseWithRecord(decision, actionRecord)
|
||||
case "hold", "wait":
|
||||
// 无需执行,仅记录
|
||||
return nil
|
||||
@@ -778,6 +785,201 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeUpdateStopLossWithRecord 执行调整止损并记录详细信息
|
||||
func (at *AutoTrader) executeUpdateStopLossWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||||
log.Printf(" 🎯 调整止损: %s → %.2f", decision.Symbol, decision.NewStopLoss)
|
||||
|
||||
// 获取当前价格
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionRecord.Price = marketData.CurrentPrice
|
||||
|
||||
// 获取当前持仓
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取持仓失败: %w", err)
|
||||
}
|
||||
|
||||
// 查找目标持仓
|
||||
var targetPosition map[string]interface{}
|
||||
for _, pos := range positions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
posAmt, _ := pos["positionAmt"].(float64)
|
||||
if symbol == decision.Symbol && posAmt != 0 {
|
||||
targetPosition = pos
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetPosition == nil {
|
||||
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
|
||||
}
|
||||
|
||||
// 获取持仓方向和数量
|
||||
side, _ := targetPosition["side"].(string)
|
||||
positionSide := strings.ToUpper(side)
|
||||
positionAmt, _ := targetPosition["positionAmt"].(float64)
|
||||
|
||||
// 验证新止损价格合理性
|
||||
if positionSide == "LONG" && decision.NewStopLoss >= marketData.CurrentPrice {
|
||||
return fmt.Errorf("多单止损必须低于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss)
|
||||
}
|
||||
if positionSide == "SHORT" && decision.NewStopLoss <= marketData.CurrentPrice {
|
||||
return fmt.Errorf("空单止损必须高于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss)
|
||||
}
|
||||
|
||||
// 取消旧的止损单(避免多个止损单共存)
|
||||
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
|
||||
log.Printf(" ⚠ 取消旧止损单失败: %v", err)
|
||||
// 不中断执行,继续设置新止损
|
||||
}
|
||||
|
||||
// 调用交易所 API 修改止损
|
||||
quantity := math.Abs(positionAmt)
|
||||
err = at.trader.SetStopLoss(decision.Symbol, positionSide, quantity, decision.NewStopLoss)
|
||||
if err != nil {
|
||||
return fmt.Errorf("修改止损失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf(" ✓ 止损已调整: %.2f (当前价格: %.2f)", decision.NewStopLoss, marketData.CurrentPrice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeUpdateTakeProfitWithRecord 执行调整止盈并记录详细信息
|
||||
func (at *AutoTrader) executeUpdateTakeProfitWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||||
log.Printf(" 🎯 调整止盈: %s → %.2f", decision.Symbol, decision.NewTakeProfit)
|
||||
|
||||
// 获取当前价格
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionRecord.Price = marketData.CurrentPrice
|
||||
|
||||
// 获取当前持仓
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取持仓失败: %w", err)
|
||||
}
|
||||
|
||||
// 查找目标持仓
|
||||
var targetPosition map[string]interface{}
|
||||
for _, pos := range positions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
posAmt, _ := pos["positionAmt"].(float64)
|
||||
if symbol == decision.Symbol && posAmt != 0 {
|
||||
targetPosition = pos
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetPosition == nil {
|
||||
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
|
||||
}
|
||||
|
||||
// 获取持仓方向和数量
|
||||
side, _ := targetPosition["side"].(string)
|
||||
positionSide := strings.ToUpper(side)
|
||||
positionAmt, _ := targetPosition["positionAmt"].(float64)
|
||||
|
||||
// 验证新止盈价格合理性
|
||||
if positionSide == "LONG" && decision.NewTakeProfit <= marketData.CurrentPrice {
|
||||
return fmt.Errorf("多单止盈必须高于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit)
|
||||
}
|
||||
if positionSide == "SHORT" && decision.NewTakeProfit >= marketData.CurrentPrice {
|
||||
return fmt.Errorf("空单止盈必须低于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit)
|
||||
}
|
||||
|
||||
// 取消旧的止盈单(避免多个止盈单共存)
|
||||
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
|
||||
log.Printf(" ⚠ 取消旧止盈单失败: %v", err)
|
||||
// 不中断执行,继续设置新止盈
|
||||
}
|
||||
|
||||
// 调用交易所 API 修改止盈
|
||||
quantity := math.Abs(positionAmt)
|
||||
err = at.trader.SetTakeProfit(decision.Symbol, positionSide, quantity, decision.NewTakeProfit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("修改止盈失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf(" ✓ 止盈已调整: %.2f (当前价格: %.2f)", decision.NewTakeProfit, marketData.CurrentPrice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// executePartialCloseWithRecord 执行部分平仓并记录详细信息
|
||||
func (at *AutoTrader) executePartialCloseWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
|
||||
log.Printf(" 📊 部分平仓: %s %.1f%%", decision.Symbol, decision.ClosePercentage)
|
||||
|
||||
// 验证百分比范围
|
||||
if decision.ClosePercentage <= 0 || decision.ClosePercentage > 100 {
|
||||
return fmt.Errorf("平仓百分比必须在 0-100 之间,当前: %.1f", decision.ClosePercentage)
|
||||
}
|
||||
|
||||
// 获取当前价格
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionRecord.Price = marketData.CurrentPrice
|
||||
|
||||
// 获取当前持仓
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取持仓失败: %w", err)
|
||||
}
|
||||
|
||||
// 查找目标持仓
|
||||
var targetPosition map[string]interface{}
|
||||
for _, pos := range positions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
posAmt, _ := pos["positionAmt"].(float64)
|
||||
if symbol == decision.Symbol && posAmt != 0 {
|
||||
targetPosition = pos
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetPosition == nil {
|
||||
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
|
||||
}
|
||||
|
||||
// 获取持仓方向和数量
|
||||
side, _ := targetPosition["side"].(string)
|
||||
positionSide := strings.ToUpper(side)
|
||||
positionAmt, _ := targetPosition["positionAmt"].(float64)
|
||||
|
||||
// 计算平仓数量
|
||||
totalQuantity := math.Abs(positionAmt)
|
||||
closeQuantity := totalQuantity * (decision.ClosePercentage / 100.0)
|
||||
actionRecord.Quantity = closeQuantity
|
||||
|
||||
// 执行平仓
|
||||
var order map[string]interface{}
|
||||
if positionSide == "LONG" {
|
||||
order, err = at.trader.CloseLong(decision.Symbol, closeQuantity)
|
||||
} else {
|
||||
order, err = at.trader.CloseShort(decision.Symbol, closeQuantity)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("部分平仓失败: %w", err)
|
||||
}
|
||||
|
||||
// 记录订单ID
|
||||
if orderID, ok := order["orderId"].(int64); ok {
|
||||
actionRecord.OrderID = orderID
|
||||
}
|
||||
|
||||
remainingQuantity := totalQuantity - closeQuantity
|
||||
log.Printf(" ✓ 部分平仓成功: 平仓 %.4f (%.1f%%), 剩余 %.4f",
|
||||
closeQuantity, decision.ClosePercentage, remainingQuantity)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetID 获取trader ID
|
||||
func (at *AutoTrader) GetID() string {
|
||||
return at.id
|
||||
@@ -991,12 +1193,14 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
||||
// 定义优先级
|
||||
getActionPriority := func(action string) int {
|
||||
switch action {
|
||||
case "close_long", "close_short":
|
||||
return 1 // 最高优先级:先平仓
|
||||
case "close_long", "close_short", "partial_close":
|
||||
return 1 // 最高优先级:先平仓(包括部分平仓)
|
||||
case "update_stop_loss", "update_take_profit":
|
||||
return 2 // 调整持仓止盈止损
|
||||
case "open_long", "open_short":
|
||||
return 2 // 次优先级:后开仓
|
||||
return 3 // 次优先级:后开仓
|
||||
case "hold", "wait":
|
||||
return 3 // 最低优先级:观望
|
||||
return 4 // 最低优先级:观望
|
||||
default:
|
||||
return 999 // 未知动作放最后
|
||||
}
|
||||
|
||||
@@ -425,6 +425,53 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
|
||||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
// 获取该币种的所有未完成订单
|
||||
orders, err := t.client.NewListOpenOrdersService().
|
||||
Symbol(symbol).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取未完成订单失败: %w", err)
|
||||
}
|
||||
|
||||
// 过滤出止盈止损单并取消
|
||||
canceledCount := 0
|
||||
for _, order := range orders {
|
||||
orderType := order.Type
|
||||
|
||||
// 只取消止损和止盈订单
|
||||
if orderType == futures.OrderTypeStopMarket ||
|
||||
orderType == futures.OrderTypeTakeProfitMarket ||
|
||||
orderType == futures.OrderTypeStop ||
|
||||
orderType == futures.OrderTypeTakeProfit {
|
||||
|
||||
_, err := t.client.NewCancelOrderService().
|
||||
Symbol(symbol).
|
||||
OrderID(order.OrderID).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
log.Printf(" ⚠ 取消订单 %d 失败: %v", order.OrderID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
canceledCount++
|
||||
log.Printf(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)",
|
||||
symbol, order.OrderID, orderType)
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有止盈/止损单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice 获取市场价格
|
||||
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
|
||||
|
||||
+128
-22
@@ -2,10 +2,12 @@ package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
@@ -22,6 +24,9 @@ type HyperliquidTrader struct {
|
||||
|
||||
// NewHyperliquidTrader 创建Hyperliquid交易器
|
||||
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
|
||||
// 去掉私钥的 0x 前缀(如果有,不区分大小写)
|
||||
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
||||
|
||||
// 解析私钥
|
||||
privateKey, err := crypto.HexToECDSA(privateKeyHex)
|
||||
if err != nil {
|
||||
@@ -34,13 +39,18 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
|
||||
apiURL = hyperliquid.TestnetAPIURL
|
||||
}
|
||||
|
||||
// // 从私钥生成钱包地址
|
||||
// pubKey := privateKey.Public()
|
||||
// publicKeyECDSA, ok := pubKey.(*ecdsa.PublicKey)
|
||||
// if !ok {
|
||||
// return nil, fmt.Errorf("无法转换公钥")
|
||||
// }
|
||||
// walletAddr := crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
|
||||
// 从私钥生成钱包地址(如果未提供)
|
||||
if walletAddr == "" {
|
||||
pubKey := privateKey.Public()
|
||||
publicKeyECDSA, ok := pubKey.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("无法转换公钥")
|
||||
}
|
||||
walletAddr = crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
|
||||
log.Printf("✓ 从私钥自动生成钱包地址: %s", walletAddr)
|
||||
} else {
|
||||
log.Printf("✓ 使用提供的钱包地址: %s", walletAddr)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -76,23 +86,54 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
|
||||
func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
log.Printf("🔄 正在调用Hyperliquid API获取账户余额...")
|
||||
|
||||
// 获取账户状态
|
||||
// ✅ Step 1: 查询 Spot 现货账户余额
|
||||
spotState, err := t.exchange.Info().SpotUserState(t.ctx, t.walletAddr)
|
||||
var spotUSDCBalance float64 = 0.0
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 查询 Spot 余额失败(可能无现货资产): %v", err)
|
||||
} else if spotState != nil && len(spotState.Balances) > 0 {
|
||||
for _, balance := range spotState.Balances {
|
||||
if balance.Coin == "USDC" {
|
||||
spotUSDCBalance, _ = strconv.ParseFloat(balance.Total, 64)
|
||||
log.Printf("✓ 发现 Spot 现货余额: %.2f USDC", spotUSDCBalance)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Step 2: 查询 Perpetuals 合约账户状态
|
||||
accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
log.Printf("❌ Hyperliquid API调用失败: %v", err)
|
||||
log.Printf("❌ Hyperliquid Perpetuals API调用失败: %v", err)
|
||||
return nil, fmt.Errorf("获取账户信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析余额信息(MarginSummary字段都是string)
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// 🔍 调试:打印API返回的完整CrossMarginSummary结构
|
||||
summaryJSON, _ := json.MarshalIndent(accountState.MarginSummary, " ", " ")
|
||||
log.Printf("🔍 [DEBUG] Hyperliquid API CrossMarginSummary完整数据:")
|
||||
log.Printf("%s", string(summaryJSON))
|
||||
// ✅ Step 3: 根据保证金模式动态选择正确的摘要(CrossMarginSummary 或 MarginSummary)
|
||||
var accountValue, totalMarginUsed float64
|
||||
var summaryType string
|
||||
var summary interface{}
|
||||
|
||||
accountValue, _ := strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64)
|
||||
totalMarginUsed, _ := strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64)
|
||||
if t.isCrossMargin {
|
||||
// 全仓模式:使用 CrossMarginSummary
|
||||
accountValue, _ = strconv.ParseFloat(accountState.CrossMarginSummary.AccountValue, 64)
|
||||
totalMarginUsed, _ = strconv.ParseFloat(accountState.CrossMarginSummary.TotalMarginUsed, 64)
|
||||
summaryType = "CrossMarginSummary (全仓)"
|
||||
summary = accountState.CrossMarginSummary
|
||||
} else {
|
||||
// 逐仓模式:使用 MarginSummary
|
||||
accountValue, _ = strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64)
|
||||
totalMarginUsed, _ = strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64)
|
||||
summaryType = "MarginSummary (逐仓)"
|
||||
summary = accountState.MarginSummary
|
||||
}
|
||||
|
||||
// 🔍 调试:打印API返回的完整摘要结构
|
||||
summaryJSON, _ := json.MarshalIndent(summary, " ", " ")
|
||||
log.Printf("🔍 [DEBUG] Hyperliquid API %s 完整数据:", summaryType)
|
||||
log.Printf("%s", string(summaryJSON))
|
||||
|
||||
// ⚠️ 关键修复:从所有持仓中累加真正的未实现盈亏
|
||||
totalUnrealizedPnl := 0.0
|
||||
@@ -109,16 +150,47 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// 需要返回"不包含未实现盈亏的钱包余额"
|
||||
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
|
||||
|
||||
result["totalWalletBalance"] = walletBalanceWithoutUnrealized // 钱包余额(不含未实现盈亏)
|
||||
result["availableBalance"] = accountValue - totalMarginUsed // 可用余额(总净值 - 占用保证金)
|
||||
result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未实现盈亏
|
||||
// ✅ Step 4: 使用 Withdrawable 欄位(PR #443)
|
||||
// Withdrawable 是官方提供的真实可提现余额,比简单计算更可靠
|
||||
availableBalance := 0.0
|
||||
if accountState.Withdrawable != "" {
|
||||
withdrawable, err := strconv.ParseFloat(accountState.Withdrawable, 64)
|
||||
if err == nil && withdrawable > 0 {
|
||||
availableBalance = withdrawable
|
||||
log.Printf("✓ 使用 Withdrawable 作为可用余额: %.2f", availableBalance)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✓ Hyperliquid 账户: 总净值=%.2f (钱包%.2f+未实现%.2f), 可用=%.2f, 保证金占用=%.2f",
|
||||
// 降级方案:如果没有 Withdrawable,使用简单计算
|
||||
if availableBalance == 0 && accountState.Withdrawable == "" {
|
||||
availableBalance = accountValue - totalMarginUsed
|
||||
if availableBalance < 0 {
|
||||
log.Printf("⚠️ 计算出的可用余额为负数 (%.2f),重置为 0", availableBalance)
|
||||
availableBalance = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Step 5: 正確處理 Spot + Perpetuals 余额
|
||||
// 重要:Spot 只加到總資產,不加到可用餘額
|
||||
// 原因:Spot 和 Perpetuals 是獨立帳戶,需手動 ClassTransfer 才能轉帳
|
||||
totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance
|
||||
|
||||
result["totalWalletBalance"] = totalWalletBalance // 總資產(Perp + Spot)
|
||||
result["availableBalance"] = availableBalance // 可用餘額(僅 Perpetuals,不含 Spot)
|
||||
result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未實現盈虧(僅來自 Perpetuals)
|
||||
result["spotBalance"] = spotUSDCBalance // Spot 現貨餘額(單獨返回)
|
||||
|
||||
log.Printf("✓ Hyperliquid 完整账户:")
|
||||
log.Printf(" • Spot 现货余额: %.2f USDC (需手动转账到 Perpetuals 才能开仓)", spotUSDCBalance)
|
||||
log.Printf(" • Perpetuals 合约净值: %.2f USDC (钱包%.2f + 未实现%.2f)",
|
||||
accountValue,
|
||||
walletBalanceWithoutUnrealized,
|
||||
totalUnrealizedPnl,
|
||||
result["availableBalance"],
|
||||
totalMarginUsed)
|
||||
totalUnrealizedPnl)
|
||||
log.Printf(" • Perpetuals 可用余额: %.2f USDC (可直接用於開倉)", availableBalance)
|
||||
log.Printf(" • 保证金占用: %.2f USDC", totalMarginUsed)
|
||||
log.Printf(" • 總資產 (Perp+Spot): %.2f USDC", totalWalletBalance)
|
||||
log.Printf(" ⭐ 总资产: %.2f USDC | Perp 可用: %.2f USDC | Spot 余额: %.2f USDC",
|
||||
totalWalletBalance, availableBalance, spotUSDCBalance)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -501,6 +573,40 @@ func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
|
||||
func (t *HyperliquidTrader) CancelStopOrders(symbol string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// 获取所有挂单
|
||||
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取挂单失败: %w", err)
|
||||
}
|
||||
|
||||
// 注意:Hyperliquid SDK 的 OpenOrder 结构不暴露 trigger 字段
|
||||
// 因此暂时取消该币种的所有挂单(包括止盈止损单)
|
||||
// 这是安全的,因为在设置新的止盈止损之前,应该清理所有旧订单
|
||||
canceledCount := 0
|
||||
for _, order := range openOrders {
|
||||
if order.Coin == coin {
|
||||
_, err := t.exchange.Cancel(t.ctx, coin, order.Oid)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠ 取消订单失败 (oid=%d): %v", order.Oid, err)
|
||||
continue
|
||||
}
|
||||
canceledCount++
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有挂单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个挂单(包括止盈/止损单)", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice 获取市场价格
|
||||
func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
@@ -39,6 +39,9 @@ type Trader interface {
|
||||
// CancelAllOrders 取消该币种的所有挂单
|
||||
CancelAllOrders(symbol string) error
|
||||
|
||||
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
|
||||
CancelStopOrders(symbol string) error
|
||||
|
||||
// FormatQuantity 格式化数量到正确的精度
|
||||
FormatQuantity(symbol string, quantity float64) (string, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npm test
|
||||
@@ -0,0 +1,22 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Config files
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from '@typescript-eslint/eslint-plugin'
|
||||
import tsparser from '@typescript-eslint/parser'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import prettier from 'eslint-plugin-prettier'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist', 'node_modules', 'build', '*.config.js']
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
globals: {
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
fetch: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
sessionStorage: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
'react': react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
'prettier': prettier
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// Prettier integration
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// React rules
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
// 该规则在 TS 项目中经常与 TS 的类型检查重复,关闭以避免误报
|
||||
'no-undef': 'off',
|
||||
|
||||
// TypeScript rules
|
||||
// 放宽以下规则以避免在不改变功能的情况下大面积改动代码
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// React Refresh
|
||||
'react-refresh/only-export-components': 'off',
|
||||
|
||||
// General rules
|
||||
'no-console': 'off',
|
||||
'no-debugger': 'off',
|
||||
|
||||
// 新版 react-hooks 推荐规则在本项目会造成大量误报,关闭以免影响开发体验
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/static-components': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
|
||||
// 某些字符串中包含未转义字符用于展示,关闭以避免不必要的修改
|
||||
'react/no-unescaped-entities': 'off',
|
||||
|
||||
// 可视情况关闭依赖数组校验(如需严格可改为 'warn')
|
||||
'react-hooks/exhaustive-deps': 'off'
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
Generated
+3716
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -5,7 +5,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
@@ -22,13 +27,34 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{css,json}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+569
-283
File diff suppressed because it is too large
Load Diff
+858
-429
File diff suppressed because it is too large
Load Diff
+1056
-556
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -9,129 +9,140 @@ import {
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import type { CompetitionTraderData } from '../types';
|
||||
import { getTraderColor } from '../utils/traderColors';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
} from 'recharts'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import type { CompetitionTraderData } from '../types'
|
||||
import { getTraderColor } from '../utils/traderColors'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
|
||||
interface ComparisonChartProps {
|
||||
traders: CompetitionTraderData[];
|
||||
traders: CompetitionTraderData[]
|
||||
}
|
||||
|
||||
export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
const { language } = useLanguage();
|
||||
const { language } = useLanguage()
|
||||
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
|
||||
// 生成唯一的key,当traders变化时会触发重新请求
|
||||
const tradersKey = traders.map(t => t.trader_id).sort().join(',');
|
||||
const tradersKey = traders
|
||||
.map((t) => t.trader_id)
|
||||
.sort()
|
||||
.join(',')
|
||||
|
||||
const { data: allTraderHistories, isLoading } = useSWR(
|
||||
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
|
||||
async () => {
|
||||
// 使用批量API一次性获取所有trader的历史数据
|
||||
const traderIds = traders.map(trader => trader.trader_id);
|
||||
const batchData = await api.getEquityHistoryBatch(traderIds);
|
||||
|
||||
const traderIds = traders.map((trader) => trader.trader_id)
|
||||
const batchData = await api.getEquityHistoryBatch(traderIds)
|
||||
|
||||
// 转换为原格式,保持与原有代码兼容
|
||||
return traders.map(trader => {
|
||||
return batchData.histories[trader.trader_id] || [];
|
||||
});
|
||||
return traders.map((trader) => {
|
||||
return batchData.histories[trader.trader_id] || []
|
||||
})
|
||||
},
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低)
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
// 将数据转换为与原格式兼容的结构
|
||||
const traderHistories = useMemo(() => {
|
||||
if (!allTraderHistories) {
|
||||
return traders.map(() => ({ data: undefined }));
|
||||
return traders.map(() => ({ data: undefined }))
|
||||
}
|
||||
return allTraderHistories.map(data => ({ data }));
|
||||
}, [allTraderHistories, traders.length]);
|
||||
return allTraderHistories.map((data) => ({ data }))
|
||||
}, [allTraderHistories, traders.length])
|
||||
|
||||
// 使用useMemo自动处理数据合并,直接使用data对象作为依赖
|
||||
const combinedData = useMemo(() => {
|
||||
// 等待所有数据加载完成
|
||||
const allLoaded = traderHistories.every((h) => h.data);
|
||||
if (!allLoaded) return [];
|
||||
const allLoaded = traderHistories.every((h) => h.data)
|
||||
if (!allLoaded) return []
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Recalculating chart data...`);
|
||||
console.log(`[${new Date().toISOString()}] Recalculating chart data...`)
|
||||
|
||||
// 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置)
|
||||
// 收集所有时间戳
|
||||
const timestampMap = new Map<string, {
|
||||
timestamp: string;
|
||||
time: string;
|
||||
traders: Map<string, { pnl_pct: number; equity: number }>;
|
||||
}>();
|
||||
const timestampMap = new Map<
|
||||
string,
|
||||
{
|
||||
timestamp: string
|
||||
time: string
|
||||
traders: Map<string, { pnl_pct: number; equity: number }>
|
||||
}
|
||||
>()
|
||||
|
||||
traderHistories.forEach((history, index) => {
|
||||
const trader = traders[index];
|
||||
if (!history.data) return;
|
||||
const trader = traders[index]
|
||||
if (!history.data) return
|
||||
|
||||
console.log(`Trader ${trader.trader_id}: ${history.data.length} data points`);
|
||||
console.log(
|
||||
`Trader ${trader.trader_id}: ${history.data.length} data points`
|
||||
)
|
||||
|
||||
history.data.forEach((point: any) => {
|
||||
const ts = point.timestamp;
|
||||
const ts = point.timestamp
|
||||
|
||||
if (!timestampMap.has(ts)) {
|
||||
const time = new Date(ts).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
})
|
||||
timestampMap.set(ts, {
|
||||
timestamp: ts,
|
||||
time,
|
||||
traders: new Map()
|
||||
});
|
||||
traders: new Map(),
|
||||
})
|
||||
}
|
||||
|
||||
// 计算盈亏百分比:从total_pnl和balance计算
|
||||
// 假设初始余额 = balance - total_pnl
|
||||
const initialBalance = point.balance - point.total_pnl;
|
||||
const pnlPct = initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0;
|
||||
const initialBalance = point.balance - point.total_pnl
|
||||
const pnlPct =
|
||||
initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0
|
||||
|
||||
timestampMap.get(ts)!.traders.set(trader.trader_id, {
|
||||
pnl_pct: pnlPct,
|
||||
equity: point.total_equity
|
||||
});
|
||||
});
|
||||
});
|
||||
equity: point.total_equity,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 按时间戳排序,转换为数组
|
||||
const combined = Array.from(timestampMap.entries())
|
||||
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
|
||||
.map(([ts, data], index) => {
|
||||
const entry: any = {
|
||||
index: index + 1, // 使用序号代替cycle
|
||||
index: index + 1, // 使用序号代替cycle
|
||||
time: data.time,
|
||||
timestamp: ts
|
||||
};
|
||||
timestamp: ts,
|
||||
}
|
||||
|
||||
traders.forEach((trader) => {
|
||||
const traderData = data.traders.get(trader.trader_id);
|
||||
const traderData = data.traders.get(trader.trader_id)
|
||||
if (traderData) {
|
||||
entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct;
|
||||
entry[`${trader.trader_id}_equity`] = traderData.equity;
|
||||
entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct
|
||||
entry[`${trader.trader_id}_equity`] = traderData.equity
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return entry;
|
||||
});
|
||||
return entry
|
||||
})
|
||||
|
||||
if (combined.length > 0) {
|
||||
const lastPoint = combined[combined.length - 1];
|
||||
console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`);
|
||||
const lastPoint = combined[combined.length - 1]
|
||||
console.log(
|
||||
`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`
|
||||
)
|
||||
}
|
||||
|
||||
return combined;
|
||||
}, [allTraderHistories, traders]);
|
||||
return combined
|
||||
}, [allTraderHistories, traders])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -139,67 +150,69 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<div className="text-sm font-semibold">Loading comparison data...</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (combinedData.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" />
|
||||
<div className="text-lg font-semibold mb-2">{t('noHistoricalData', language)}</div>
|
||||
<div className="text-lg font-semibold mb-2">
|
||||
{t('noHistoricalData', language)}
|
||||
</div>
|
||||
<div className="text-sm">{t('dataWillAppear', language)}</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 限制显示数据点
|
||||
const MAX_DISPLAY_POINTS = 2000;
|
||||
const MAX_DISPLAY_POINTS = 2000
|
||||
const displayData =
|
||||
combinedData.length > MAX_DISPLAY_POINTS
|
||||
? combinedData.slice(-MAX_DISPLAY_POINTS)
|
||||
: combinedData;
|
||||
: combinedData
|
||||
|
||||
// 计算Y轴范围
|
||||
const calculateYDomain = () => {
|
||||
const allValues: number[] = [];
|
||||
const allValues: number[] = []
|
||||
displayData.forEach((point) => {
|
||||
traders.forEach((trader) => {
|
||||
const value = point[`${trader.trader_id}_pnl_pct`];
|
||||
const value = point[`${trader.trader_id}_pnl_pct`]
|
||||
if (value !== undefined) {
|
||||
allValues.push(value);
|
||||
allValues.push(value)
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
if (allValues.length === 0) return [-5, 5];
|
||||
if (allValues.length === 0) return [-5, 5]
|
||||
|
||||
const minVal = Math.min(...allValues);
|
||||
const maxVal = Math.max(...allValues);
|
||||
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
|
||||
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
|
||||
const minVal = Math.min(...allValues)
|
||||
const maxVal = Math.max(...allValues)
|
||||
const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
|
||||
const padding = Math.max(range * 0.2, 1) // 至少留1%余量
|
||||
|
||||
return [
|
||||
Math.floor(minVal - padding),
|
||||
Math.ceil(maxVal + padding)
|
||||
];
|
||||
};
|
||||
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
|
||||
}
|
||||
|
||||
// 使用统一的颜色分配逻辑(与Leaderboard保持一致)
|
||||
const traderColor = (traderId: string) => getTraderColor(traders, traderId);
|
||||
const traderColor = (traderId: string) => getTraderColor(traders, traderId)
|
||||
|
||||
// 自定义Tooltip - Binance Style
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div
|
||||
className="rounded p-3 shadow-xl"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{data.time} - #{data.index}
|
||||
</div>
|
||||
{traders.map((trader) => {
|
||||
const pnlPct = data[`${trader.trader_id}_pnl_pct`];
|
||||
const equity = data[`${trader.trader_id}_equity`];
|
||||
if (pnlPct === undefined) return null;
|
||||
const pnlPct = data[`${trader.trader_id}_pnl_pct`]
|
||||
const equity = data[`${trader.trader_id}_equity`]
|
||||
if (pnlPct === undefined) return null
|
||||
|
||||
return (
|
||||
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
|
||||
@@ -209,33 +222,51 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}>
|
||||
<div
|
||||
className="text-sm mono font-bold"
|
||||
style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}
|
||||
>
|
||||
{pnlPct >= 0 ? '+' : ''}
|
||||
{pnlPct.toFixed(2)}%
|
||||
<span
|
||||
className="text-xs ml-2 font-normal"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
({equity?.toFixed(2)} USDT)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
// 计算当前差距
|
||||
const currentGap = displayData.length > 0 ? (() => {
|
||||
const lastPoint = displayData[displayData.length - 1];
|
||||
const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0);
|
||||
return Math.abs(values[0] - values[1]);
|
||||
})() : 0;
|
||||
const currentGap =
|
||||
displayData.length > 0
|
||||
? (() => {
|
||||
const lastPoint = displayData[displayData.length - 1]
|
||||
const values = traders.map(
|
||||
(t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0
|
||||
)
|
||||
return Math.abs(values[0] - values[1])
|
||||
})()
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* NOFX Watermark */}
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
@@ -245,116 +276,195 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
color: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace'
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
NOFX
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={520}>
|
||||
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
|
||||
<defs>
|
||||
{traders.map((trader) => (
|
||||
<linearGradient
|
||||
key={`gradient-${trader.trader_id}`}
|
||||
id={`gradient-${trader.trader_id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.9} />
|
||||
<stop offset="95%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.2} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<LineChart
|
||||
data={displayData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 40 }}
|
||||
>
|
||||
<defs>
|
||||
{traders.map((trader) => (
|
||||
<linearGradient
|
||||
key={`gradient-${trader.trader_id}`}
|
||||
id={`gradient-${trader.trader_id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={traderColor(trader.trader_id)}
|
||||
stopOpacity={0.9}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={traderColor(trader.trader_id)}
|
||||
stopOpacity={0.2}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
interval={Math.floor(displayData.length / 12)}
|
||||
angle={-15}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
domain={calculateYDomain()}
|
||||
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
||||
width={60}
|
||||
/>
|
||||
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#474D57"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: 'Break Even',
|
||||
fill: '#848E9C',
|
||||
fontSize: 11,
|
||||
position: 'right',
|
||||
}}
|
||||
/>
|
||||
|
||||
{traders.map((trader) => (
|
||||
<Line
|
||||
key={trader.trader_id}
|
||||
type="monotone"
|
||||
dataKey={`${trader.trader_id}_pnl_pct`}
|
||||
stroke={traderColor(trader.trader_id)}
|
||||
strokeWidth={3}
|
||||
dot={displayData.length < 50 ? { fill: traderColor(trader.trader_id), r: 3 } : false}
|
||||
activeDot={{ r: 6, fill: traderColor(trader.trader_id), stroke: '#fff', strokeWidth: 2 }}
|
||||
name={trader.trader_name}
|
||||
connectNulls
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
interval={Math.floor(displayData.length / 12)}
|
||||
angle={-15}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
iconType="line"
|
||||
formatter={(value, entry: any) => {
|
||||
const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
|
||||
const trader = traders.find((t) => t.trader_id === traderId);
|
||||
return (
|
||||
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}>
|
||||
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<YAxis
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
domain={calculateYDomain()}
|
||||
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
||||
width={60}
|
||||
/>
|
||||
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#474D57"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: 'Break Even',
|
||||
fill: '#848E9C',
|
||||
fontSize: 11,
|
||||
position: 'right',
|
||||
}}
|
||||
/>
|
||||
|
||||
{traders.map((trader) => (
|
||||
<Line
|
||||
key={trader.trader_id}
|
||||
type="monotone"
|
||||
dataKey={`${trader.trader_id}_pnl_pct`}
|
||||
stroke={traderColor(trader.trader_id)}
|
||||
strokeWidth={3}
|
||||
dot={
|
||||
displayData.length < 50
|
||||
? { fill: traderColor(trader.trader_id), r: 3 }
|
||||
: false
|
||||
}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: traderColor(trader.trader_id),
|
||||
stroke: '#fff',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
name={trader.trader_name}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
iconType="line"
|
||||
formatter={(value, entry: any) => {
|
||||
const traderId = traders.find(
|
||||
(t) => value === t.trader_name
|
||||
)?.trader_id
|
||||
const trader = traders.find((t) => t.trader_id === traderId)
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color: entry.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
|
||||
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('comparisonMode', language)}</div>
|
||||
<div className="text-sm md:text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
|
||||
<div
|
||||
className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5"
|
||||
style={{ borderTop: '1px solid #2B3139' }}
|
||||
>
|
||||
<div
|
||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('comparisonMode', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
PnL %
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('dataPoints', language)}</div>
|
||||
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>{t('count', language, {count: combinedData.length})}</div>
|
||||
<div
|
||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('dataPoints', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('count', language, { count: combinedData.length })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('currentGap', language)}</div>
|
||||
<div className="text-sm md:text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
|
||||
<div
|
||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('currentGap', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}
|
||||
>
|
||||
{currentGap.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('displayRange', language)}</div>
|
||||
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div
|
||||
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('displayRange', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{combinedData.length > MAX_DISPLAY_POINTS
|
||||
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
|
||||
: t('allData', language)}
|
||||
@@ -362,5 +472,5 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { Trophy, Medal } from 'lucide-react';
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import type { CompetitionData } from '../types';
|
||||
import { ComparisonChart } from './ComparisonChart';
|
||||
import { TraderConfigViewModal } from './TraderConfigViewModal';
|
||||
import { getTraderColor } from '../utils/traderColors';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { useState } from 'react'
|
||||
import { Trophy, Medal } from 'lucide-react'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import type { CompetitionData } from '../types'
|
||||
import { ComparisonChart } from './ComparisonChart'
|
||||
import { TraderConfigViewModal } from './TraderConfigViewModal'
|
||||
import { getTraderColor } from '../utils/traderColors'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
export function CompetitionPage() {
|
||||
const { language } = useLanguage();
|
||||
const [selectedTrader, setSelectedTrader] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { language } = useLanguage()
|
||||
const [selectedTrader, setSelectedTrader] = useState<any>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const { data: competition } = useSWR<CompetitionData>(
|
||||
'competition',
|
||||
@@ -22,24 +22,24 @@ export function CompetitionPage() {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
const handleTraderClick = async (traderId: string) => {
|
||||
try {
|
||||
const traderConfig = await api.getTraderConfig(traderId);
|
||||
setSelectedTrader(traderConfig);
|
||||
setIsModalOpen(true);
|
||||
const traderConfig = await api.getTraderConfig(traderId)
|
||||
setSelectedTrader(traderConfig)
|
||||
setIsModalOpen(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch trader config:', error);
|
||||
console.error('Failed to fetch trader config:', error)
|
||||
// 对于未登录用户,不显示详细配置,这是正常行为
|
||||
// 竞赛页面主要用于查看排行榜和基本信息
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedTrader(null);
|
||||
};
|
||||
setIsModalOpen(false)
|
||||
setSelectedTrader(null)
|
||||
}
|
||||
|
||||
if (!competition) {
|
||||
return (
|
||||
@@ -61,7 +61,7 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 如果有数据返回但没有交易员,显示空状态
|
||||
@@ -71,16 +71,31 @@ export function CompetitionPage() {
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
|
||||
}}>
|
||||
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} />
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Trophy
|
||||
className="w-6 h-6 md:w-7 md:h-7"
|
||||
style={{ color: '#000' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('aiCompetition', language)}
|
||||
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
>
|
||||
0 {t('traders', language)}
|
||||
</span>
|
||||
</h1>
|
||||
@@ -93,7 +108,10 @@ export function CompetitionPage() {
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="binance-card p-8 text-center">
|
||||
<Trophy className="w-16 h-16 mx-auto mb-4 opacity-40" style={{ color: '#848E9C' }} />
|
||||
<Trophy
|
||||
className="w-16 h-16 mx-auto mb-4 opacity-40"
|
||||
style={{ color: '#848E9C' }}
|
||||
/>
|
||||
<h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('noTraders', language)}
|
||||
</h3>
|
||||
@@ -102,32 +120,47 @@ export function CompetitionPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 按收益率排序
|
||||
const sortedTraders = [...competition.traders].sort(
|
||||
(a, b) => b.total_pnl_pct - a.total_pnl_pct
|
||||
);
|
||||
)
|
||||
|
||||
// 找出领先者
|
||||
const leader = sortedTraders[0];
|
||||
const leader = sortedTraders[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
|
||||
}}>
|
||||
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} />
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Trophy
|
||||
className="w-6 h-6 md:w-7 md:h-7"
|
||||
style={{ color: '#000' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('aiCompetition', language)}
|
||||
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
>
|
||||
{competition.count} {t('traders', language)}
|
||||
</span>
|
||||
</h1>
|
||||
@@ -137,10 +170,23 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left md:text-right w-full md:w-auto">
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('leader', language)}</div>
|
||||
<div className="text-base md:text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div>
|
||||
<div className="text-sm font-semibold" style={{ color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('leader', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-base md:text-lg font-bold"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{leader?.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,9 +194,15 @@ export function CompetitionPage() {
|
||||
{/* Left/Right Split: Performance Chart + Leaderboard */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Left: Performance Comparison Chart */}
|
||||
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div
|
||||
className="binance-card p-5 animate-slide-in"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<h2
|
||||
className="text-lg font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('performanceComparison', language)}
|
||||
</h2>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
@@ -161,19 +213,35 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* Right: Leaderboard */}
|
||||
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div
|
||||
className="binance-card p-5 animate-slide-in"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<h2
|
||||
className="text-lg font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('leaderboard', language)}
|
||||
</h2>
|
||||
<div className="text-xs px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
|
||||
<div
|
||||
className="text-xs px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
color: '#F0B90B',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
{t('live', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isLeader = index === 0;
|
||||
const traderColor = getTraderColor(sortedTraders, trader.trader_id);
|
||||
const isLeader = index === 0
|
||||
const traderColor = getTraderColor(
|
||||
sortedTraders,
|
||||
trader.trader_id
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -181,21 +249,44 @@ export function CompetitionPage() {
|
||||
onClick={() => handleTraderClick(trader.trader_id)}
|
||||
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
|
||||
style={{
|
||||
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
|
||||
background: isLeader
|
||||
? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)'
|
||||
: '#0B0E11',
|
||||
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
|
||||
boxShadow: isLeader ? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)' : '0 1px 4px rgba(0, 0, 0, 0.3)'
|
||||
boxShadow: isLeader
|
||||
? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)'
|
||||
: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Rank & Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 flex items-center justify-center">
|
||||
<Medal className="w-5 h-5" style={{ color: index === 0 ? '#F0B90B' : index === 1 ? '#C0C0C0' : '#CD7F32' }} />
|
||||
<Medal
|
||||
className="w-5 h-5"
|
||||
style={{
|
||||
color:
|
||||
index === 0
|
||||
? '#F0B90B'
|
||||
: index === 1
|
||||
? '#C0C0C0'
|
||||
: '#CD7F32',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
|
||||
<div className="text-xs mono font-semibold" style={{ color: traderColor }}>
|
||||
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
|
||||
<div
|
||||
className="font-bold text-sm"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono font-semibold"
|
||||
style={{ color: traderColor }}
|
||||
>
|
||||
{trader.ai_model.toUpperCase()} +{' '}
|
||||
{trader.exchange.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,31 +295,52 @@ export function CompetitionPage() {
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
|
||||
{/* Total Equity */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>{t('equity', language)}</div>
|
||||
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('equity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* P&L */}
|
||||
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pnl', language)}</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pnl', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-base md:text-lg font-bold mono"
|
||||
style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0
|
||||
? '#0ECB81'
|
||||
: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
<div className="text-xs mono" style={{ color: '#848E9C' }}>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'}
|
||||
<div
|
||||
className="text-xs mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pos', language)}</div>
|
||||
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('pos', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
@@ -240,9 +352,16 @@ export function CompetitionPage() {
|
||||
<div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-xs font-bold"
|
||||
style={trader.is_running
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
style={
|
||||
trader.is_running
|
||||
? {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
}
|
||||
>
|
||||
{trader.is_running ? '●' : '○'}
|
||||
@@ -251,7 +370,7 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,56 +378,81 @@ export function CompetitionPage() {
|
||||
|
||||
{/* Head-to-Head Stats */}
|
||||
{competition.traders.length === 2 && (
|
||||
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.3s' }}>
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<div
|
||||
className="binance-card p-5 animate-slide-in"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
>
|
||||
<h2
|
||||
className="text-lg font-bold mb-4 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('headToHead', language)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isWinning = index === 0;
|
||||
const opponent = sortedTraders[1 - index];
|
||||
const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
|
||||
const isWinning = index === 0
|
||||
const opponent = sortedTraders[1 - index]
|
||||
const gap = trader.total_pnl_pct - opponent.total_pnl_pct
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
|
||||
style={isWinning
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
|
||||
border: '2px solid rgba(14, 203, 129, 0.3)',
|
||||
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)'
|
||||
}
|
||||
: {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
style={
|
||||
isWinning
|
||||
? {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
|
||||
border: '2px solid rgba(14, 203, 129, 0.3)',
|
||||
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)',
|
||||
}
|
||||
: {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-sm md:text-base font-bold mb-2"
|
||||
style={{ color: getTraderColor(sortedTraders, trader.trader_id) }}
|
||||
style={{
|
||||
color: getTraderColor(sortedTraders, trader.trader_id),
|
||||
}}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div className="text-lg md:text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
<div
|
||||
className="text-lg md:text-2xl font-bold mono mb-1"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
|
||||
}}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
{isWinning && gap > 0 && (
|
||||
<div className="text-xs font-semibold" style={{ color: '#0ECB81' }}>
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#0ECB81' }}
|
||||
>
|
||||
{t('leadingBy', language, { gap: gap.toFixed(2) })}
|
||||
</div>
|
||||
)}
|
||||
{!isWinning && gap < 0 && (
|
||||
<div className="text-xs font-semibold" style={{ color: '#F6465D' }}>
|
||||
{t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })}
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: '#F6465D' }}
|
||||
>
|
||||
{t('behindBy', language, {
|
||||
gap: Math.abs(gap).toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,5 +465,5 @@ export function CompetitionPage() {
|
||||
traderData={selectedTrader}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,115 +1,136 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import * as React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface CryptoFeatureCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
className?: string;
|
||||
delay?: number;
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
features: string[]
|
||||
className?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureCardProps>(
|
||||
({ icon, title, description, features, className, delay = 0 }, ref) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
export const CryptoFeatureCard = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
CryptoFeatureCardProps
|
||||
>(({ icon, title, description, features, className, delay = 0 }, ref) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
className="relative h-full"
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl',
|
||||
'bg-gradient-to-br from-[#000000] to-[#0A0A0A]',
|
||||
'border-[#1A1A1A] hover:border-[#F0B90B]/50',
|
||||
isHovered && 'shadow-[0_0_20px_rgba(240,185,11,0.2)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl",
|
||||
"bg-gradient-to-br from-[#000000] to-[#0A0A0A]",
|
||||
"border-[#1A1A1A] hover:border-[#F0B90B]/50",
|
||||
isHovered && "shadow-[0_0_20px_rgba(240,185,11,0.2)]",
|
||||
className
|
||||
)}
|
||||
{/* Animated glow border effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-0 pointer-events-none"
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Animated glow border effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
</motion.div>
|
||||
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 p-8 flex flex-col h-full">
|
||||
{/* Icon container */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-0 pointer-events-none"
|
||||
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
scale: isHovered ? 1.1 : 1,
|
||||
boxShadow: isHovered
|
||||
? '0 0 20px rgba(240, 185, 11, 0.4)'
|
||||
: '0 0 0px rgba(240, 185, 11, 0)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
|
||||
backgroundSize: "32px 32px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<h3
|
||||
className="text-2xl font-bold mb-3"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div className="relative z-10 p-8 flex flex-col h-full">
|
||||
{/* Icon container */}
|
||||
<motion.div
|
||||
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)'
|
||||
}}
|
||||
animate={{
|
||||
scale: isHovered ? 1.1 : 1,
|
||||
boxShadow: isHovered
|
||||
? "0 0 20px rgba(240, 185, 11, 0.4)"
|
||||
: "0 0 0px rgba(240, 185, 11, 0)",
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
|
||||
</motion.div>
|
||||
{/* Description */}
|
||||
<p
|
||||
className="mb-6 flex-grow leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-bold mb-3" style={{ color: 'var(--brand-light-gray)' }}>{title}</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mb-6 flex-grow leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{description}</p>
|
||||
|
||||
{/* Features list */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: delay + index * 0.1 }}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.2)' }}>
|
||||
<Check className="w-3 h-3" style={{ color: 'var(--brand-yellow)' }} />
|
||||
</div>
|
||||
{/* Features list */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: delay + index * 0.1 }}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.2)' }}
|
||||
>
|
||||
<Check
|
||||
className="w-3 h-3"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: 'var(--brand-light-gray)' }}>{feature}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
CryptoFeatureCard.displayName = "CryptoFeatureCard";
|
||||
CryptoFeatureCard.displayName = 'CryptoFeatureCard'
|
||||
|
||||
+129
-108
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -8,28 +8,35 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { AlertTriangle, BarChart3, DollarSign, Percent, TrendingUp as ArrowUp, TrendingDown as ArrowDown } from 'lucide-react'
|
||||
} from 'recharts'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
DollarSign,
|
||||
Percent,
|
||||
TrendingUp as ArrowUp,
|
||||
TrendingDown as ArrowDown,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface EquityPoint {
|
||||
timestamp: string;
|
||||
total_equity: number;
|
||||
pnl: number;
|
||||
pnl_pct: number;
|
||||
cycle_number: number;
|
||||
timestamp: string
|
||||
total_equity: number
|
||||
pnl: number
|
||||
pnl_pct: number
|
||||
cycle_number: number
|
||||
}
|
||||
|
||||
interface EquityChartProps {
|
||||
traderId?: string;
|
||||
traderId?: string
|
||||
}
|
||||
|
||||
export function EquityChart({ traderId }: EquityChartProps) {
|
||||
const { language } = useLanguage();
|
||||
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar');
|
||||
const { language } = useLanguage()
|
||||
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
|
||||
|
||||
const { data: history, error } = useSWR<EquityPoint[]>(
|
||||
traderId ? `equity-history-${traderId}` : 'equity-history',
|
||||
@@ -39,7 +46,7 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
const { data: account } = useSWR(
|
||||
traderId ? `account-${traderId}` : 'account',
|
||||
@@ -49,24 +56,24 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='binance-card p-6'>
|
||||
<div className="binance-card p-6">
|
||||
<div
|
||||
className='flex items-center gap-3 p-4 rounded'
|
||||
className="flex items-center gap-3 p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.2)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className='w-6 h-6' style={{ color: '#F6465D' }} />
|
||||
<AlertTriangle className="w-6 h-6" style={{ color: '#F6465D' }} />
|
||||
<div>
|
||||
<div className='font-semibold' style={{ color: '#F6465D' }}>
|
||||
<div className="font-semibold" style={{ color: '#F6465D' }}>
|
||||
{t('loadingError', language)}
|
||||
</div>
|
||||
<div className='text-sm' style={{ color: '#848E9C' }}>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,22 +83,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
}
|
||||
|
||||
// 过滤掉无效数据:total_equity为0或小于1的数据点(API失败导致)
|
||||
const validHistory = history?.filter(point => point.total_equity > 1) || [];
|
||||
const validHistory = history?.filter((point) => point.total_equity > 1) || []
|
||||
|
||||
if (!validHistory || validHistory.length === 0) {
|
||||
return (
|
||||
<div className='binance-card p-6'>
|
||||
<h3 className='text-lg font-semibold mb-6' style={{ color: '#EAECEF' }}>
|
||||
<div className="binance-card p-6">
|
||||
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
<div className='text-center py-16' style={{ color: '#848E9C' }}>
|
||||
<div className='mb-4 flex justify-center opacity-50'>
|
||||
<BarChart3 className='w-16 h-16' />
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
<div className="mb-4 flex justify-center opacity-50">
|
||||
<BarChart3 className="w-16 h-16" />
|
||||
</div>
|
||||
<div className='text-lg font-semibold mb-2'>
|
||||
<div className="text-lg font-semibold mb-2">
|
||||
{t('noHistoricalData', language)}
|
||||
</div>
|
||||
<div className='text-sm'>{t('dataWillAppear', language)}</div>
|
||||
<div className="text-sm">{t('dataWillAppear', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -99,10 +106,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
// 限制显示最近的数据点(性能优化)
|
||||
// 如果数据超过2000个点,只显示最近2000个
|
||||
const MAX_DISPLAY_POINTS = 2000;
|
||||
const displayHistory = validHistory.length > MAX_DISPLAY_POINTS
|
||||
? validHistory.slice(-MAX_DISPLAY_POINTS)
|
||||
: validHistory;
|
||||
const MAX_DISPLAY_POINTS = 2000
|
||||
const displayHistory =
|
||||
validHistory.length > MAX_DISPLAY_POINTS
|
||||
? validHistory.slice(-MAX_DISPLAY_POINTS)
|
||||
: validHistory
|
||||
|
||||
// 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推)
|
||||
const initialBalance = account?.initial_balance // 从交易员配置读取真实初始余额
|
||||
@@ -111,8 +119,8 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
// 转换数据格式
|
||||
const chartData = displayHistory.map((point) => {
|
||||
const pnl = point.total_equity - initialBalance;
|
||||
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2);
|
||||
const pnl = point.total_equity - initialBalance
|
||||
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
|
||||
return {
|
||||
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -123,43 +131,45 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
raw_equity: point.total_equity,
|
||||
raw_pnl: pnl,
|
||||
raw_pnl_pct: parseFloat(pnlPct),
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
const currentValue = chartData[chartData.length - 1];
|
||||
const isProfit = currentValue.raw_pnl >= 0;
|
||||
const currentValue = chartData[chartData.length - 1]
|
||||
const isProfit = currentValue.raw_pnl >= 0
|
||||
|
||||
// 计算Y轴范围
|
||||
const calculateYDomain = () => {
|
||||
if (displayMode === 'percent') {
|
||||
// 百分比模式:找到最大最小值,留20%余量
|
||||
const values = chartData.map(d => d.value);
|
||||
const minVal = Math.min(...values);
|
||||
const maxVal = Math.max(...values);
|
||||
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
|
||||
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
|
||||
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)];
|
||||
const values = chartData.map((d) => d.value)
|
||||
const minVal = Math.min(...values)
|
||||
const maxVal = Math.max(...values)
|
||||
const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
|
||||
const padding = Math.max(range * 0.2, 1) // 至少留1%余量
|
||||
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
|
||||
} else {
|
||||
// 美元模式:以初始余额为基准,上下留10%余量
|
||||
const values = chartData.map(d => d.value);
|
||||
const minVal = Math.min(...values, initialBalance);
|
||||
const maxVal = Math.max(...values, initialBalance);
|
||||
const range = maxVal - minVal;
|
||||
const padding = Math.max(range * 0.15, initialBalance * 0.01); // 至少留1%余量
|
||||
return [
|
||||
Math.floor(minVal - padding),
|
||||
Math.ceil(maxVal + padding)
|
||||
];
|
||||
const values = chartData.map((d) => d.value)
|
||||
const minVal = Math.min(...values, initialBalance)
|
||||
const maxVal = Math.max(...values, initialBalance)
|
||||
const range = maxVal - minVal
|
||||
const padding = Math.max(range * 0.15, initialBalance * 0.01) // 至少留1%余量
|
||||
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 自定义Tooltip - Binance Style
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>Cycle #{data.cycle}</div>
|
||||
<div
|
||||
className="rounded p-3 shadow-xl"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
Cycle #{data.cycle}
|
||||
</div>
|
||||
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
{data.raw_equity.toFixed(2)} USDT
|
||||
</div>
|
||||
@@ -172,38 +182,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
{data.raw_pnl_pct}%)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='binance-card p-3 sm:p-5 animate-fade-in'>
|
||||
<div className="binance-card p-3 sm:p-5 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4'>
|
||||
<div className='flex-1'>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className='text-base sm:text-lg font-bold mb-2'
|
||||
className="text-base sm:text-lg font-bold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
<div className='flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4'>
|
||||
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
|
||||
<span
|
||||
className='text-2xl sm:text-3xl font-bold mono'
|
||||
className="text-2xl sm:text-3xl font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{account?.total_equity.toFixed(2) || '0.00'}
|
||||
<span
|
||||
className='text-base sm:text-lg ml-1'
|
||||
className="text-base sm:text-lg ml-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
USDT
|
||||
</span>
|
||||
</span>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className='text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1'
|
||||
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1"
|
||||
style={{
|
||||
color: isProfit ? '#0ECB81' : '#F6465D',
|
||||
background: isProfit
|
||||
@@ -216,12 +226,16 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{isProfit ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
{isProfit ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
)}
|
||||
{isProfit ? '+' : ''}
|
||||
{currentValue.raw_pnl_pct}%
|
||||
</span>
|
||||
<span
|
||||
className='text-xs sm:text-sm mono'
|
||||
className="text-xs sm:text-sm mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
({isProfit ? '+' : ''}
|
||||
@@ -233,12 +247,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
{/* Display Mode Toggle */}
|
||||
<div
|
||||
className='flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto'
|
||||
className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setDisplayMode('dollar')}
|
||||
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
|
||||
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
|
||||
style={
|
||||
displayMode === 'dollar'
|
||||
? {
|
||||
@@ -249,11 +263,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
<DollarSign className='w-4 h-4' /> USDT
|
||||
<DollarSign className="w-4 h-4" /> USDT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDisplayMode('percent')}
|
||||
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
|
||||
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
|
||||
style={
|
||||
displayMode === 'percent'
|
||||
? {
|
||||
@@ -264,15 +278,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
<Percent className='w-4 h-4' />
|
||||
<Percent className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className='my-2' style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
|
||||
<div
|
||||
className="my-2"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* NOFX Watermark */}
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '15px',
|
||||
@@ -282,35 +303,35 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
color: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace'
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
NOFX
|
||||
</div>
|
||||
<ResponsiveContainer width='100%' height={280}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id='colorGradient' x1='0' y1='0' x2='0' y2='1'>
|
||||
<stop offset='5%' stopColor='#F0B90B' stopOpacity={0.8} />
|
||||
<stop offset='95%' stopColor='#FCD535' stopOpacity={0.2} />
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray='3 3' stroke='#2B3139' />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
|
||||
<XAxis
|
||||
dataKey='time'
|
||||
stroke='#5E6673'
|
||||
dataKey="time"
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
interval={Math.floor(chartData.length / 10)}
|
||||
angle={-15}
|
||||
textAnchor='end'
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke='#5E6673'
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
domain={calculateYDomain()}
|
||||
@@ -321,8 +342,8 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine
|
||||
y={displayMode === 'dollar' ? initialBalance : 0}
|
||||
stroke='#474D57'
|
||||
strokeDasharray='3 3'
|
||||
stroke="#474D57"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value:
|
||||
displayMode === 'dollar'
|
||||
@@ -333,9 +354,9 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type='natural'
|
||||
dataKey='value'
|
||||
stroke='url(#colorGradient)'
|
||||
type="natural"
|
||||
dataKey="value"
|
||||
stroke="url(#colorGradient)"
|
||||
strokeWidth={3}
|
||||
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
|
||||
activeDot={{
|
||||
@@ -352,72 +373,72 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div
|
||||
className='mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3'
|
||||
className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3"
|
||||
style={{ borderTop: '1px solid #2B3139' }}
|
||||
>
|
||||
<div
|
||||
className='p-2 rounded transition-all hover:bg-opacity-50'
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className='text-xs mb-1 uppercase tracking-wider'
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('initialBalance', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{initialBalance.toFixed(2)} USDT
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='p-2 rounded transition-all hover:bg-opacity-50'
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className='text-xs mb-1 uppercase tracking-wider'
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('currentEquity', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{currentValue.raw_equity.toFixed(2)} USDT
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='p-2 rounded transition-all hover:bg-opacity-50'
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className='text-xs mb-1 uppercase tracking-wider'
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('historicalCycles', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{validHistory.length} {t('cycles', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='p-2 rounded transition-all hover:bg-opacity-50'
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className='text-xs mb-1 uppercase tracking-wider'
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('displayRange', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{validHistory.length > MAX_DISPLAY_POINTS
|
||||
|
||||
@@ -1,107 +1,165 @@
|
||||
import React from 'react';
|
||||
import React from 'react'
|
||||
|
||||
interface IconProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Binance SVG 图标组件
|
||||
const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width}
|
||||
height={height}
|
||||
const BinanceIcon: React.FC<IconProps> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="-52.785 -88 457.47 528"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
|
||||
<path
|
||||
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
|
||||
fill="#f0b90b"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// Hyperliquid SVG 图标组件
|
||||
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 144 144"
|
||||
fill="none"
|
||||
const HyperliquidIcon: React.FC<IconProps> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 144 144"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
|
||||
<path
|
||||
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
|
||||
fill="#97FCE4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// Aster SVG 图标组件
|
||||
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
const AsterIcon: React.FC<IconProps> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<stop offset="1" stopColor="#FFD29F"/>
|
||||
<linearGradient
|
||||
id="paint0_linear_428_3535"
|
||||
x1="18.9416"
|
||||
y1="4.14314e-07"
|
||||
x2="12.6408"
|
||||
y2="32.0507"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F4D5B1" />
|
||||
<stop offset="1" stopColor="#FFD29F" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<stop offset="1" stopColor="#FFD29F"/>
|
||||
<linearGradient
|
||||
id="paint1_linear_428_3535"
|
||||
x1="18.9416"
|
||||
y1="4.14314e-07"
|
||||
x2="12.6408"
|
||||
y2="32.0507"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F4D5B1" />
|
||||
<stop offset="1" stopColor="#FFD29F" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<stop offset="1" stopColor="#FFD29F"/>
|
||||
<linearGradient
|
||||
id="paint2_linear_428_3535"
|
||||
x1="18.9416"
|
||||
y1="4.14314e-07"
|
||||
x2="12.6408"
|
||||
y2="32.0507"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F4D5B1" />
|
||||
<stop offset="1" stopColor="#FFD29F" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<linearGradient
|
||||
id="paint3_linear_428_3535"
|
||||
x1="18.9416"
|
||||
y1="4.14314e-07"
|
||||
x2="12.6408"
|
||||
y2="32.0507"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#F4D5B1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
|
||||
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
|
||||
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
|
||||
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
|
||||
<path
|
||||
d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z"
|
||||
fill="url(#paint0_linear_428_3535)"
|
||||
/>
|
||||
<path
|
||||
d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z"
|
||||
fill="url(#paint1_linear_428_3535)"
|
||||
/>
|
||||
<path
|
||||
d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z"
|
||||
fill="url(#paint2_linear_428_3535)"
|
||||
/>
|
||||
<path
|
||||
d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z"
|
||||
fill="url(#paint3_linear_428_3535)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// 获取交易所图标的函数
|
||||
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
|
||||
export const getExchangeIcon = (
|
||||
exchangeType: string,
|
||||
props: IconProps = {}
|
||||
) => {
|
||||
// 支持完整ID或类型名
|
||||
const type = exchangeType.toLowerCase().includes('binance') ? 'binance' :
|
||||
exchangeType.toLowerCase().includes('hyperliquid') ? 'hyperliquid' :
|
||||
exchangeType.toLowerCase().includes('aster') ? 'aster' :
|
||||
exchangeType.toLowerCase();
|
||||
|
||||
const type = exchangeType.toLowerCase().includes('binance')
|
||||
? 'binance'
|
||||
: exchangeType.toLowerCase().includes('hyperliquid')
|
||||
? 'hyperliquid'
|
||||
: exchangeType.toLowerCase().includes('aster')
|
||||
? 'aster'
|
||||
: exchangeType.toLowerCase()
|
||||
|
||||
const iconProps = {
|
||||
width: props.width || 24,
|
||||
height: props.height || 24,
|
||||
className: props.className
|
||||
};
|
||||
|
||||
className: props.className,
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'binance':
|
||||
case 'cex':
|
||||
return <BinanceIcon {...iconProps} />;
|
||||
return <BinanceIcon {...iconProps} />
|
||||
case 'hyperliquid':
|
||||
case 'dex':
|
||||
return <HyperliquidIcon {...iconProps} />;
|
||||
return <HyperliquidIcon {...iconProps} />
|
||||
case 'aster':
|
||||
return <AsterIcon {...iconProps} />;
|
||||
return <AsterIcon {...iconProps} />
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
width: props.width || 24,
|
||||
style={{
|
||||
width: props.width || 24,
|
||||
height: props.height || 24,
|
||||
borderRadius: '50%',
|
||||
background: '#2B3139',
|
||||
@@ -110,11 +168,11 @@ export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) =>
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: '#EAECEF'
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{type[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
interface HeaderProps {
|
||||
simple?: boolean; // For login/register pages
|
||||
simple?: boolean // For login/register pages
|
||||
}
|
||||
|
||||
export function Header({ simple = false }: HeaderProps) {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const { language, setLanguage } = useLanguage()
|
||||
|
||||
return (
|
||||
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
|
||||
@@ -28,15 +28,19 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Right - Language Toggle (always show) */}
|
||||
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
|
||||
<div
|
||||
className="flex gap-1 rounded p-1"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setLanguage('zh')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={language === 'zh'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
style={
|
||||
language === 'zh'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
中文
|
||||
@@ -44,9 +48,10 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={language === 'en'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
style={
|
||||
language === 'en'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
EN
|
||||
@@ -55,5 +60,5 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+219
-155
@@ -1,208 +1,272 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import HeaderBar from './landing/HeaderBar';
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import HeaderBar from './landing/HeaderBar'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage();
|
||||
const { login, verifyOTP } = useAuth();
|
||||
const [step, setStep] = useState<'login' | 'otp'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [userID, setUserID] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { language } = useLanguage()
|
||||
const { login, verifyOTP } = useAuth()
|
||||
const [step, setStep] = useState<'login' | 'otp'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [userID, setUserID] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await login(email, password)
|
||||
|
||||
const result = await login(email, password);
|
||||
|
||||
if (result.success) {
|
||||
if (result.requiresOTP && result.userID) {
|
||||
setUserID(result.userID);
|
||||
setStep('otp');
|
||||
setUserID(result.userID)
|
||||
setStep('otp')
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('loginFailed', language));
|
||||
setError(result.message || t('loginFailed', language))
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleOTPVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await verifyOTP(userID, otpCode)
|
||||
|
||||
const result = await verifyOTP(userID, otpCode);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || t('verificationFailed', language));
|
||||
setError(result.message || t('verificationFailed', language))
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
|
||||
<HeaderBar
|
||||
onLoginClick={() => {}}
|
||||
isLoggedIn={false}
|
||||
<HeaderBar
|
||||
onLoginClick={() => {}}
|
||||
isLoggedIn={false}
|
||||
isHomePage={false}
|
||||
currentPage="login"
|
||||
language={language}
|
||||
onLanguageChange={() => {}}
|
||||
onPageChange={(page) => {
|
||||
console.log('LoginPage onPageChange called with:', page);
|
||||
console.log('LoginPage onPageChange called with:', page)
|
||||
if (page === 'competition') {
|
||||
window.location.href = '/competition';
|
||||
window.location.href = '/competition'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center pt-20" style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||
<div
|
||||
className="flex items-center justify-center pt-20"
|
||||
style={{ minHeight: 'calc(100vh - 80px)' }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain" />
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
登录 NOFX
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p
|
||||
className="text-sm mt-2"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
|
||||
{step === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
{error}
|
||||
{/* Login Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{step === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('loginButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('scanQRCodeInstructions', language)}<br />
|
||||
{t('enterOTPCode', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
{error}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('login')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{loading ? t('loading', language) : t('verifyOTP', language)}
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('loginButton', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('scanQRCodeInstructions', language)}
|
||||
<br />
|
||||
{t('enterOTPCode', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
还没有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
className="font-semibold hover:underline transition-colors"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
立即注册
|
||||
</button>
|
||||
</p>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) =>
|
||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||
}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('login')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('verifyOTP', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
还没有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
className="font-semibold hover:underline transition-colors"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
立即注册
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
|
||||
interface IconProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 获取AI模型图标的函数
|
||||
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
// 支持完整ID或类型名
|
||||
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType;
|
||||
|
||||
let iconPath: string | null = null;
|
||||
|
||||
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
|
||||
|
||||
let iconPath: string | null = null
|
||||
|
||||
switch (type) {
|
||||
case 'deepseek':
|
||||
iconPath = '/icons/deepseek.svg';
|
||||
break;
|
||||
iconPath = '/icons/deepseek.svg'
|
||||
break
|
||||
case 'qwen':
|
||||
iconPath = '/icons/qwen.svg';
|
||||
break;
|
||||
iconPath = '/icons/qwen.svg'
|
||||
break
|
||||
default:
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -32,5 +31,5 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
className={props.className}
|
||||
style={{ borderRadius: '50%' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
+423
-287
@@ -1,366 +1,502 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { getSystemConfig } from '../lib/config';
|
||||
import HeaderBar from './landing/HeaderBar';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
import HeaderBar from './landing/HeaderBar'
|
||||
|
||||
export function RegisterPage() {
|
||||
const { language } = useLanguage();
|
||||
const { register, completeRegistration } = useAuth();
|
||||
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>('register');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [betaCode, setBetaCode] = useState('');
|
||||
const [betaMode, setBetaMode] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [userID, setUserID] = useState('');
|
||||
const [otpSecret, setOtpSecret] = useState('');
|
||||
const [qrCodeURL, setQrCodeURL] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { language } = useLanguage()
|
||||
const { register, completeRegistration } = useAuth()
|
||||
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
|
||||
'register'
|
||||
)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [betaCode, setBetaCode] = useState('')
|
||||
const [betaMode, setBetaMode] = useState(false)
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [userID, setUserID] = useState('')
|
||||
const [otpSecret, setOtpSecret] = useState('')
|
||||
const [qrCodeURL, setQrCodeURL] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 获取系统配置,检查是否开启内测模式
|
||||
getSystemConfig().then(config => {
|
||||
setBetaMode(config.beta_mode || false);
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch system config:', err);
|
||||
});
|
||||
}, []);
|
||||
getSystemConfig()
|
||||
.then((config) => {
|
||||
setBetaMode(config.beta_mode || false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch system config:', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('passwordMismatch', language));
|
||||
return;
|
||||
setError(t('passwordMismatch', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError(t('passwordTooShort', language));
|
||||
return;
|
||||
setError(t('passwordTooShort', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (betaMode && !betaCode.trim()) {
|
||||
setError('内测期间,注册需要提供内测码');
|
||||
return;
|
||||
setError('内测期间,注册需要提供内测码')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
|
||||
const result = await register(email, password, betaCode.trim() || undefined)
|
||||
|
||||
const result = await register(email, password, betaCode.trim() || undefined);
|
||||
|
||||
if (result.success && result.userID) {
|
||||
setUserID(result.userID);
|
||||
setOtpSecret(result.otpSecret || '');
|
||||
setQrCodeURL(result.qrCodeURL || '');
|
||||
setStep('setup-otp');
|
||||
setUserID(result.userID)
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setQrCodeURL(result.qrCodeURL || '')
|
||||
setStep('setup-otp')
|
||||
} else {
|
||||
setError(result.message || t('registrationFailed', language));
|
||||
setError(result.message || t('registrationFailed', language))
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
setStep('verify-otp');
|
||||
};
|
||||
setStep('verify-otp')
|
||||
}
|
||||
|
||||
const handleOTPVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await completeRegistration(userID, otpCode)
|
||||
|
||||
const result = await completeRegistration(userID, otpCode);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || t('registrationFailed', language));
|
||||
setError(result.message || t('registrationFailed', language))
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
|
||||
<HeaderBar
|
||||
isLoggedIn={false}
|
||||
<HeaderBar
|
||||
isLoggedIn={false}
|
||||
isHomePage={false}
|
||||
currentPage="register"
|
||||
language={language}
|
||||
onLanguageChange={() => {}}
|
||||
onPageChange={(page) => {
|
||||
console.log('RegisterPage onPageChange called with:', page);
|
||||
console.log('RegisterPage onPageChange called with:', page)
|
||||
if (page === 'competition') {
|
||||
window.location.href = '/competition';
|
||||
window.location.href = '/competition'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center pt-20" style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||
<div
|
||||
className="flex items-center justify-center pt-20"
|
||||
style={{ minHeight: 'calc(100vh - 80px)' }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain" />
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
|
||||
{step === 'register' && t('registerTitle', language)}
|
||||
{step === 'setup-otp' && t('setupTwoFactor', language)}
|
||||
{step === 'verify-otp' && t('verifyOTP', language)}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
|
||||
{step === 'register' && t('registerTitle', language)}
|
||||
{step === 'setup-otp' && t('setupTwoFactor', language)}
|
||||
{step === 'verify-otp' && t('verifyOTP', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
|
||||
{step === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{betaMode && (
|
||||
{/* Registration Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{step === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
内测码 *
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={betaCode}
|
||||
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
|
||||
className="w-full px-3 py-2 rounded font-mono"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder="请输入6位内测码"
|
||||
maxLength={6}
|
||||
required={betaMode}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
内测码由6位字母数字组成,区分大小写
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
{error}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (betaMode && !betaCode.trim())}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('registerButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'setup-otp' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('setupTwoFactor', language)}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('setupTwoFactorDesc', language)}
|
||||
</p>
|
||||
</div>
|
||||
{betaMode && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
内测码 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={betaCode}
|
||||
onChange={(e) =>
|
||||
setBetaCode(
|
||||
e.target.value
|
||||
.replace(/[^a-z0-9]/gi, '')
|
||||
.toLowerCase()
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 rounded font-mono"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
placeholder="请输入6位内测码"
|
||||
maxLength={6}
|
||||
required={betaMode}
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
内测码由6位字母数字组成,区分大小写
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('authStep1Title', language)}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('authStep1Desc', language)}
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (betaMode && !betaCode.trim())}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('registerButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup-otp' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<h3
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('setupTwoFactor', language)}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('setupTwoFactorDesc', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('authStep2Title', language)}
|
||||
</p>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('authStep2Desc', language)}
|
||||
</p>
|
||||
|
||||
{qrCodeURL && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="p-3 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('authStep1Title', language)}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('authStep1Desc', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-3 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('authStep2Title', language)}
|
||||
</p>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('authStep2Desc', language)}
|
||||
</p>
|
||||
|
||||
{qrCodeURL && (
|
||||
<div className="mt-2">
|
||||
<p
|
||||
className="text-xs mb-2"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('qrCodeHint', language)}
|
||||
</p>
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
|
||||
alt="QR Code" className="mx-auto" />
|
||||
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{t('otpSecret', language)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 px-2 py-1 text-xs rounded font-mono"
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
>
|
||||
{otpSecret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(otpSecret)}
|
||||
className="px-2 py-1 text-xs rounded"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{t('copy', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2 py-1 text-xs rounded font-mono"
|
||||
style={{ background: 'var(--panel-bg-hover)', color: 'var(--brand-light-gray)' }}>
|
||||
{otpSecret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(otpSecret)}
|
||||
className="px-2 py-1 text-xs rounded"
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
>
|
||||
{t('copy', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-3 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('authStep3Title', language)}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('authStep3Desc', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('authStep3Title', language)}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('authStep3Desc', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSetupComplete}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('setupCompleteContinue', language)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'verify-otp' && (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">🔐</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('enterOTPCode', language)}<br />
|
||||
{t('completeRegistrationSubtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('setup-otp')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
onClick={handleSetupComplete}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('completeRegistration', language)}
|
||||
{t('setupCompleteContinue', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Link */}
|
||||
{step === 'register' && (
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
已有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
className="font-semibold hover:underline transition-colors"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
立即登录
|
||||
</button>
|
||||
</p>
|
||||
{step === 'verify-otp' && (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">🔐</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('enterOTPCode', language)}
|
||||
<br />
|
||||
{t('completeRegistrationSubtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) =>
|
||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||
}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('setup-otp')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('completeRegistration', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Link */}
|
||||
{step === 'register' && (
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
已有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
className="font-semibold hover:underline transition-colors"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
立即登录
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { AIModel, Exchange, CreateTraderRequest } from '../types';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AIModel, Exchange, CreateTraderRequest } from '../types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
const parts = fullName.split('_');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName;
|
||||
const parts = fullName.split('_')
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||
}
|
||||
|
||||
interface TraderConfigData {
|
||||
trader_id?: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange_id: string;
|
||||
btc_eth_leverage: number;
|
||||
altcoin_leverage: number;
|
||||
trading_symbols: string;
|
||||
custom_prompt: string;
|
||||
override_base_prompt: boolean;
|
||||
system_prompt_template: string;
|
||||
is_cross_margin: boolean;
|
||||
use_coin_pool: boolean;
|
||||
use_oi_top: boolean;
|
||||
initial_balance: number;
|
||||
scan_interval_minutes: number;
|
||||
trader_id?: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
btc_eth_leverage: number
|
||||
altcoin_leverage: number
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
system_prompt_template: string
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
initial_balance: number
|
||||
scan_interval_minutes: number
|
||||
}
|
||||
|
||||
interface TraderConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
traderData?: TraderConfigData | null;
|
||||
isEditMode?: boolean;
|
||||
availableModels?: AIModel[];
|
||||
availableExchanges?: Exchange[];
|
||||
onSave?: (data: CreateTraderRequest) => Promise<void>;
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
traderData?: TraderConfigData | null
|
||||
isEditMode?: boolean
|
||||
availableModels?: AIModel[]
|
||||
availableExchanges?: Exchange[]
|
||||
onSave?: (data: CreateTraderRequest) => Promise<void>
|
||||
}
|
||||
|
||||
export function TraderConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
traderData,
|
||||
export function TraderConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
traderData,
|
||||
isEditMode = false,
|
||||
availableModels = [],
|
||||
availableExchanges = [],
|
||||
onSave
|
||||
onSave,
|
||||
}: TraderConfigModalProps) {
|
||||
const { language } = useLanguage();
|
||||
const { language } = useLanguage()
|
||||
const [formData, setFormData] = useState<TraderConfigData>({
|
||||
trader_name: '',
|
||||
ai_model: '',
|
||||
@@ -62,20 +62,25 @@ export function TraderConfigModal({
|
||||
use_oi_top: false,
|
||||
initial_balance: 1000,
|
||||
scan_interval_minutes: 3,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [availableCoins, setAvailableCoins] = useState<string[]>([]);
|
||||
const [selectedCoins, setSelectedCoins] = useState<string[]>([]);
|
||||
const [showCoinSelector, setShowCoinSelector] = useState(false);
|
||||
const [promptTemplates, setPromptTemplates] = useState<{name: string}[]>([]);
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [availableCoins, setAvailableCoins] = useState<string[]>([])
|
||||
const [selectedCoins, setSelectedCoins] = useState<string[]>([])
|
||||
const [showCoinSelector, setShowCoinSelector] = useState(false)
|
||||
const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
|
||||
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
|
||||
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (traderData) {
|
||||
setFormData(traderData);
|
||||
setFormData(traderData)
|
||||
// 设置已选择的币种
|
||||
if (traderData.trading_symbols) {
|
||||
const coins = traderData.trading_symbols.split(',').map(s => s.trim()).filter(s => s);
|
||||
setSelectedCoins(coins);
|
||||
const coins = traderData.trading_symbols
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s)
|
||||
setSelectedCoins(coins)
|
||||
}
|
||||
} else if (!isEditMode) {
|
||||
setFormData({
|
||||
@@ -93,85 +98,135 @@ export function TraderConfigModal({
|
||||
use_oi_top: false,
|
||||
initial_balance: 1000,
|
||||
scan_interval_minutes: 3,
|
||||
});
|
||||
})
|
||||
}
|
||||
// 确保旧数据也有默认的 system_prompt_template
|
||||
if (traderData && !traderData.system_prompt_template) {
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
system_prompt_template: 'default'
|
||||
}));
|
||||
system_prompt_template: 'default',
|
||||
}))
|
||||
}
|
||||
}, [traderData, isEditMode, availableModels, availableExchanges]);
|
||||
}, [traderData, isEditMode, availableModels, availableExchanges])
|
||||
|
||||
// 获取系统配置中的币种列表
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const config = await response.json();
|
||||
const response = await fetch('/api/config')
|
||||
const config = await response.json()
|
||||
if (config.default_coins) {
|
||||
setAvailableCoins(config.default_coins);
|
||||
setAvailableCoins(config.default_coins)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
console.error('Failed to fetch config:', error)
|
||||
// 使用默认币种列表
|
||||
setAvailableCoins(['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT', 'ADAUSDT']);
|
||||
setAvailableCoins([
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
])
|
||||
}
|
||||
};
|
||||
fetchConfig();
|
||||
}, []);
|
||||
}
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
// 获取系统提示词模板列表
|
||||
useEffect(() => {
|
||||
const fetchPromptTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/prompt-templates');
|
||||
const data = await response.json();
|
||||
const response = await fetch('/api/prompt-templates')
|
||||
const data = await response.json()
|
||||
if (data.templates) {
|
||||
setPromptTemplates(data.templates);
|
||||
setPromptTemplates(data.templates)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch prompt templates:', error);
|
||||
console.error('Failed to fetch prompt templates:', error)
|
||||
// 使用默认模板列表
|
||||
setPromptTemplates([{name: 'default'}, {name: 'aggressive'}]);
|
||||
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
|
||||
}
|
||||
};
|
||||
fetchPromptTemplates();
|
||||
}, []);
|
||||
}
|
||||
fetchPromptTemplates()
|
||||
}, [])
|
||||
|
||||
// 当选择的币种改变时,更新输入框
|
||||
useEffect(() => {
|
||||
const symbolsString = selectedCoins.join(',');
|
||||
setFormData(prev => ({ ...prev, trading_symbols: symbolsString }));
|
||||
}, [selectedCoins]);
|
||||
const symbolsString = selectedCoins.join(',')
|
||||
setFormData((prev) => ({ ...prev, trading_symbols: symbolsString }))
|
||||
}, [selectedCoins])
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
|
||||
// 如果是直接编辑trading_symbols,同步更新selectedCoins
|
||||
if (field === 'trading_symbols') {
|
||||
const coins = value.split(',').map((s: string) => s.trim()).filter((s: string) => s);
|
||||
setSelectedCoins(coins);
|
||||
const coins = value
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s)
|
||||
setSelectedCoins(coins)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoinToggle = (coin: string) => {
|
||||
setSelectedCoins((prev) => {
|
||||
if (prev.includes(coin)) {
|
||||
return prev.filter((c) => c !== coin)
|
||||
} else {
|
||||
return [...prev, coin]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleFetchCurrentBalance = async () => {
|
||||
if (!isEditMode || !traderData?.trader_id) {
|
||||
setBalanceFetchError('只有在编辑模式下才能获取当前余额');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingBalance(true);
|
||||
setBalanceFetchError('');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/account?trader_id=${traderData.trader_id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取账户余额失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// total_equity = 当前账户净值(包含未实现盈亏)
|
||||
// 这应该作为新的初始余额
|
||||
const currentBalance = data.total_equity || data.balance || 0;
|
||||
|
||||
setFormData(prev => ({ ...prev, initial_balance: currentBalance }));
|
||||
|
||||
// 显示成功提示
|
||||
console.log('已获取当前余额:', currentBalance);
|
||||
} catch (error) {
|
||||
console.error('获取余额失败:', error);
|
||||
setBalanceFetchError('获取余额失败,请检查网络连接');
|
||||
} finally {
|
||||
setIsFetchingBalance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoinToggle = (coin: string) => {
|
||||
setSelectedCoins(prev => {
|
||||
if (prev.includes(coin)) {
|
||||
return prev.filter(c => c !== coin);
|
||||
} else {
|
||||
return [...prev, coin];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
if (!onSave) return
|
||||
|
||||
setIsSaving(true);
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const saveData: CreateTraderRequest = {
|
||||
name: formData.trader_name,
|
||||
@@ -188,19 +243,19 @@ export function TraderConfigModal({
|
||||
use_oi_top: formData.use_oi_top,
|
||||
initial_balance: formData.initial_balance,
|
||||
scan_interval_minutes: formData.scan_interval_minutes,
|
||||
};
|
||||
await onSave(saveData);
|
||||
onClose();
|
||||
}
|
||||
await onSave(saveData)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
console.error('保存失败:', error)
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
|
||||
<div
|
||||
<div
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -236,24 +291,32 @@ export function TraderConfigModal({
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">交易员名称</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
交易员名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.trader_name}
|
||||
onChange={(e) => handleInputChange('trader_name', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('trader_name', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
placeholder="请输入交易员名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">AI模型</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
AI模型
|
||||
</label>
|
||||
<select
|
||||
value={formData.ai_model}
|
||||
onChange={(e) => handleInputChange('ai_model', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('ai_model', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{availableModels.map(model => (
|
||||
{availableModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{getShortName(model.name || model.id).toUpperCase()}
|
||||
</option>
|
||||
@@ -261,15 +324,21 @@ export function TraderConfigModal({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">交易所</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
交易所
|
||||
</label>
|
||||
<select
|
||||
value={formData.exchange_id}
|
||||
onChange={(e) => handleInputChange('exchange_id', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('exchange_id', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{availableExchanges.map(exchange => (
|
||||
{availableExchanges.map((exchange) => (
|
||||
<option key={exchange.id} value={exchange.id}>
|
||||
{getShortName(exchange.name || exchange.id).toUpperCase()}
|
||||
{getShortName(
|
||||
exchange.name || exchange.id
|
||||
).toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -287,14 +356,16 @@ export function TraderConfigModal({
|
||||
{/* 第一行:保证金模式和初始余额 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">保证金模式</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
保证金模式
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInputChange('is_cross_margin', true)}
|
||||
className={`flex-1 px-3 py-2 rounded text-sm ${
|
||||
formData.is_cross_margin
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
formData.is_cross_margin
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
|
||||
}`}
|
||||
>
|
||||
@@ -302,10 +373,12 @@ export function TraderConfigModal({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInputChange('is_cross_margin', false)}
|
||||
onClick={() =>
|
||||
handleInputChange('is_cross_margin', false)
|
||||
}
|
||||
className={`flex-1 px-3 py-2 rounded text-sm ${
|
||||
!formData.is_cross_margin
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
!formData.is_cross_margin
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
|
||||
}`}
|
||||
>
|
||||
@@ -314,32 +387,75 @@ export function TraderConfigModal({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">初始余额 ($)</label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
初始余额 ($)
|
||||
{!isEditMode && <span className="text-[#F0B90B] ml-1">*</span>}
|
||||
</label>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchCurrentBalance}
|
||||
disabled={isFetchingBalance}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFetchingBalance ? '获取中...' : '获取当前余额'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.initial_balance}
|
||||
onChange={(e) => handleInputChange('initial_balance', Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'initial_balance',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="100"
|
||||
step="100"
|
||||
step="0.01"
|
||||
/>
|
||||
{!isEditMode && (
|
||||
<p className="text-xs text-[#F0B90B] mt-1 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
||||
请输入您交易所账户的当前实际余额。如果输入不准确,P&L统计将会错误。
|
||||
</p>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
点击"获取当前余额"按钮可自动获取您交易所账户的当前净值
|
||||
</p>
|
||||
)}
|
||||
{balanceFetchError && (
|
||||
<p className="text-xs text-red-500 mt-1">{balanceFetchError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:AI 扫描决策间隔 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">{t('aiScanInterval', language)}</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('aiScanInterval', language)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.scan_interval_minutes}
|
||||
onChange={(e) => handleInputChange('scan_interval_minutes', Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'scan_interval_minutes',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="60"
|
||||
step="1"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('scanIntervalRecommend', language)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('scanIntervalRecommend', language)}
|
||||
</p>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
@@ -347,22 +463,36 @@ export function TraderConfigModal({
|
||||
{/* 第三行:杠杆设置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">BTC/ETH 杠杆</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
BTC/ETH 杠杆
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.btc_eth_leverage}
|
||||
onChange={(e) => handleInputChange('btc_eth_leverage', Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'btc_eth_leverage',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="125"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">山寨币杠杆</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
山寨币杠杆
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.altcoin_leverage}
|
||||
onChange={(e) => handleInputChange('altcoin_leverage', Number(e.target.value))}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'altcoin_leverage',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="75"
|
||||
@@ -373,7 +503,9 @@ export function TraderConfigModal({
|
||||
{/* 第三行:交易币种 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">交易币种 (用逗号分隔,留空使用默认)</label>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
交易币种 (用逗号分隔,留空使用默认)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCoinSelector(!showCoinSelector)}
|
||||
@@ -385,17 +517,21 @@ export function TraderConfigModal({
|
||||
<input
|
||||
type="text"
|
||||
value={formData.trading_symbols}
|
||||
onChange={(e) => handleInputChange('trading_symbols', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('trading_symbols', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
|
||||
/>
|
||||
|
||||
|
||||
{/* 币种选择器 */}
|
||||
{showCoinSelector && (
|
||||
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
|
||||
<div className="text-xs text-[#848E9C] mb-2">点击选择币种:</div>
|
||||
<div className="text-xs text-[#848E9C] mb-2">
|
||||
点击选择币种:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableCoins.map(coin => (
|
||||
{availableCoins.map((coin) => (
|
||||
<button
|
||||
key={coin}
|
||||
type="button"
|
||||
@@ -426,19 +562,27 @@ export function TraderConfigModal({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_coin_pool}
|
||||
onChange={(e) => handleInputChange('use_coin_pool', e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('use_coin_pool', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">使用 Coin Pool 信号</label>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
使用 Coin Pool 信号
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_oi_top}
|
||||
onChange={(e) => handleInputChange('use_oi_top', e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('use_oi_top', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">使用 OI Top 信号</label>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
使用 OI Top 信号
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,17 +595,24 @@ export function TraderConfigModal({
|
||||
<div className="space-y-4">
|
||||
{/* 系统提示词模板选择 */}
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">系统提示词模板</label>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
系统提示词模板
|
||||
</label>
|
||||
<select
|
||||
value={formData.system_prompt_template}
|
||||
onChange={(e) => handleInputChange('system_prompt_template', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('system_prompt_template', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{promptTemplates.map(template => (
|
||||
{promptTemplates.map((template) => (
|
||||
<option key={template.name} value={template.name}>
|
||||
{template.name === 'default' ? 'Default (默认稳健)' :
|
||||
template.name === 'aggressive' ? 'Aggressive (激进)' :
|
||||
template.name.charAt(0).toUpperCase() + template.name.slice(1)}
|
||||
{template.name === 'default'
|
||||
? 'Default (默认稳健)'
|
||||
: template.name === 'aggressive'
|
||||
? 'Aggressive (激进)'
|
||||
: template.name.charAt(0).toUpperCase() +
|
||||
template.name.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -474,21 +625,47 @@ export function TraderConfigModal({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.override_base_prompt}
|
||||
onChange={(e) => handleInputChange('override_base_prompt', e.target.checked)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('override_base_prompt', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">覆盖默认提示词</label>
|
||||
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg> 启用后将完全替换默认策略</span>
|
||||
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
|
||||
<line x1="12" x2="12" y1="9" y2="13" />
|
||||
<line x1="12" x2="12.01" y1="17" y2="17" />
|
||||
</svg>{' '}
|
||||
启用后将完全替换默认策略
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{formData.override_base_prompt ? '自定义提示词' : '附加提示词'}
|
||||
{formData.override_base_prompt
|
||||
? '自定义提示词'
|
||||
: '附加提示词'}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.custom_prompt}
|
||||
onChange={(e) => handleInputChange('custom_prompt', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleInputChange('custom_prompt', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
|
||||
placeholder={formData.override_base_prompt ? "输入完整的交易策略提示词..." : "输入额外的交易策略提示..."}
|
||||
placeholder={
|
||||
formData.override_base_prompt
|
||||
? '输入完整的交易策略提示词...'
|
||||
: '输入额外的交易策略提示...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,14 +683,19 @@ export function TraderConfigModal({
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !formData.trader_name || !formData.ai_model || !formData.exchange_id}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!formData.trader_name ||
|
||||
!formData.ai_model ||
|
||||
!formData.exchange_id
|
||||
}
|
||||
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
|
||||
>
|
||||
{isSaving ? '保存中...' : (isEditMode ? '保存修改' : '创建交易员')}
|
||||
{isSaving ? '保存中...' : isEditMode ? '保存修改' : '创建交易员'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import type { TraderConfigData } from '../types';
|
||||
import { useState } from 'react'
|
||||
import type { TraderConfigData } from '../types'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
const parts = fullName.split('_');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName;
|
||||
const parts = fullName.split('_')
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||
}
|
||||
|
||||
|
||||
interface TraderConfigViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
traderData?: TraderConfigData | null;
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
traderData?: TraderConfigData | null
|
||||
}
|
||||
|
||||
export function TraderConfigViewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
traderData
|
||||
export function TraderConfigViewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
traderData,
|
||||
}: TraderConfigViewModalProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen || !traderData) return null;
|
||||
if (!isOpen || !traderData) return null
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedField(fieldName);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(fieldName)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
console.error('Failed to copy:', error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
|
||||
const CopyButton = ({
|
||||
text,
|
||||
fieldName,
|
||||
}: {
|
||||
text: string
|
||||
fieldName: string
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => copyToClipboard(text, fieldName)}
|
||||
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
|
||||
style={{
|
||||
background: copiedField === fieldName ? 'rgba(14, 203, 129, 0.1)' : 'rgba(240, 185, 11, 0.1)',
|
||||
background:
|
||||
copiedField === fieldName
|
||||
? 'rgba(14, 203, 129, 0.1)'
|
||||
: 'rgba(240, 185, 11, 0.1)',
|
||||
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
|
||||
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`
|
||||
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
|
||||
const InfoRow = ({ label, value, copyable = false, fieldName = '' }: {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
copyable?: boolean;
|
||||
fieldName?: string;
|
||||
const InfoRow = ({
|
||||
label,
|
||||
value,
|
||||
copyable = false,
|
||||
fieldName = '',
|
||||
}: {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
copyable?: boolean
|
||||
fieldName?: string
|
||||
}) => (
|
||||
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
|
||||
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
|
||||
@@ -64,11 +77,11 @@ export function TraderConfigViewModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
|
||||
<div
|
||||
<div
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -79,9 +92,7 @@ export function TraderConfigViewModal({
|
||||
<span className="text-lg">👁️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#EAECEF]">
|
||||
交易员配置
|
||||
</h2>
|
||||
<h2 className="text-xl font-bold text-[#EAECEF]">交易员配置</h2>
|
||||
<p className="text-sm text-[#848E9C] mt-1">
|
||||
{traderData.trader_name} 的配置信息
|
||||
</p>
|
||||
@@ -91,9 +102,10 @@ export function TraderConfigViewModal({
|
||||
{/* Running Status */}
|
||||
<div
|
||||
className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1"
|
||||
style={traderData.is_running
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
style={
|
||||
traderData.is_running
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
}
|
||||
>
|
||||
<span>{traderData.is_running ? '●' : '○'}</span>
|
||||
@@ -116,11 +128,30 @@ export function TraderConfigViewModal({
|
||||
🤖 基础信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="交易员ID" value={traderData.trader_id || ''} copyable fieldName="trader_id" />
|
||||
<InfoRow label="交易员名称" value={traderData.trader_name} copyable fieldName="trader_name" />
|
||||
<InfoRow label="AI模型" value={getShortName(traderData.ai_model).toUpperCase()} />
|
||||
<InfoRow label="交易所" value={getShortName(traderData.exchange_id).toUpperCase()} />
|
||||
<InfoRow label="初始余额" value={`$${traderData.initial_balance.toLocaleString()}`} />
|
||||
<InfoRow
|
||||
label="交易员ID"
|
||||
value={traderData.trader_id || ''}
|
||||
copyable
|
||||
fieldName="trader_id"
|
||||
/>
|
||||
<InfoRow
|
||||
label="交易员名称"
|
||||
value={traderData.trader_name}
|
||||
copyable
|
||||
fieldName="trader_name"
|
||||
/>
|
||||
<InfoRow
|
||||
label="AI模型"
|
||||
value={getShortName(traderData.ai_model).toUpperCase()}
|
||||
/>
|
||||
<InfoRow
|
||||
label="交易所"
|
||||
value={getShortName(traderData.exchange_id).toUpperCase()}
|
||||
/>
|
||||
<InfoRow
|
||||
label="初始余额"
|
||||
value={`$${traderData.initial_balance.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,14 +161,23 @@ export function TraderConfigViewModal({
|
||||
⚖️ 交易配置
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="保证金模式" value={traderData.is_cross_margin ? '全仓' : '逐仓'} />
|
||||
<InfoRow label="BTC/ETH 杠杆" value={`${traderData.btc_eth_leverage}x`} />
|
||||
<InfoRow label="山寨币杠杆" value={`${traderData.altcoin_leverage}x`} />
|
||||
<InfoRow
|
||||
label="交易币种"
|
||||
value={traderData.trading_symbols || '使用默认币种'}
|
||||
copyable
|
||||
fieldName="trading_symbols"
|
||||
<InfoRow
|
||||
label="保证金模式"
|
||||
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
|
||||
/>
|
||||
<InfoRow
|
||||
label="BTC/ETH 杠杆"
|
||||
value={`${traderData.btc_eth_leverage}x`}
|
||||
/>
|
||||
<InfoRow
|
||||
label="山寨币杠杆"
|
||||
value={`${traderData.altcoin_leverage}x`}
|
||||
/>
|
||||
<InfoRow
|
||||
label="交易币种"
|
||||
value={traderData.trading_symbols || '使用默认币种'}
|
||||
copyable
|
||||
fieldName="trading_symbols"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +188,10 @@ export function TraderConfigViewModal({
|
||||
📡 信号源配置
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Coin Pool 信号" value={traderData.use_coin_pool} />
|
||||
<InfoRow
|
||||
label="Coin Pool 信号"
|
||||
value={traderData.use_coin_pool}
|
||||
/>
|
||||
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,29 +203,41 @@ export function TraderConfigViewModal({
|
||||
💬 交易策略提示词
|
||||
</h3>
|
||||
{traderData.custom_prompt && (
|
||||
<CopyButton text={traderData.custom_prompt} fieldName="custom_prompt" />
|
||||
<CopyButton
|
||||
text={traderData.custom_prompt}
|
||||
fieldName="custom_prompt"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="覆盖默认提示词" value={traderData.override_base_prompt} />
|
||||
<InfoRow
|
||||
label="覆盖默认提示词"
|
||||
value={traderData.override_base_prompt}
|
||||
/>
|
||||
{traderData.custom_prompt ? (
|
||||
<div>
|
||||
<div className="text-sm text-[#848E9C] mb-2">
|
||||
{traderData.override_base_prompt ? '自定义提示词' : '附加提示词'}:
|
||||
{traderData.override_base_prompt
|
||||
? '自定义提示词'
|
||||
: '附加提示词'}
|
||||
:
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
whiteSpace: 'pre-wrap'
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{traderData.custom_prompt}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[#848E9C] italic p-3 rounded border" style={{ border: '1px solid #2B3139' }}>
|
||||
<div
|
||||
className="text-sm text-[#848E9C] italic p-3 rounded border"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
>
|
||||
未设置自定义提示词,使用系统默认策略
|
||||
</div>
|
||||
)}
|
||||
@@ -199,7 +254,12 @@ export function TraderConfigViewModal({
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(JSON.stringify(traderData, null, 2), 'full_config')}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
JSON.stringify(traderData, null, 2),
|
||||
'full_config'
|
||||
)
|
||||
}
|
||||
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
|
||||
>
|
||||
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
|
||||
@@ -207,5 +267,5 @@ export function TraderConfigViewModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,16 +21,25 @@ export default function Typewriter({
|
||||
const charIndexRef = useRef(0)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const blinkRef = useRef<number | null>(null)
|
||||
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
|
||||
const sanitizedLines = useMemo(
|
||||
() => lines.map((l) => String(l ?? '')),
|
||||
[lines]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
lineIndexRef.current = 0
|
||||
charIndexRef.current = 0
|
||||
setTypedLines([''])
|
||||
|
||||
function typeNext() {
|
||||
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
|
||||
if (charIndexRef.current < currentLine.length) {
|
||||
const ch = currentLine.charAt(charIndexRef.current)
|
||||
setTypedLines((prev) => {
|
||||
const next = [...prev]
|
||||
const ch = currentLine.charAt(charIndexRef.current)
|
||||
next[next.length - 1] = (next[next.length - 1] || '') + ch
|
||||
const lastIndex = next.length - 1
|
||||
next[lastIndex] = (next[lastIndex] ?? '') + ch
|
||||
return next
|
||||
})
|
||||
charIndexRef.current += 1
|
||||
@@ -49,7 +58,8 @@ export default function Typewriter({
|
||||
}
|
||||
}
|
||||
|
||||
typeNext()
|
||||
// 延迟一帧开始打字,确保状态已重置
|
||||
timerRef.current = window.setTimeout(typeNext, 0)
|
||||
|
||||
// 光标闪烁
|
||||
blinkRef.current = window.setInterval(() => {
|
||||
@@ -60,9 +70,12 @@ export default function Typewriter({
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
if (blinkRef.current) window.clearInterval(blinkRef.current)
|
||||
}
|
||||
}, [lines, typingSpeed, lineDelay])
|
||||
}, [sanitizedLines, typingSpeed, lineDelay])
|
||||
|
||||
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
|
||||
const displayText = useMemo(
|
||||
() => typedLines.join('\n').replace(/undefined/g, ''),
|
||||
[typedLines]
|
||||
)
|
||||
|
||||
return (
|
||||
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
|
||||
|
||||
@@ -10,18 +10,18 @@ interface AboutSectionProps {
|
||||
|
||||
export default function AboutSection({ language }: AboutSectionProps) {
|
||||
return (
|
||||
<AnimatedSection id='about' backgroundColor='var(--brand-dark-gray)'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<div className='grid lg:grid-cols-2 gap-12 items-center'>
|
||||
<AnimatedSection id="about" backgroundColor="var(--brand-dark-gray)">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
className='space-y-6'
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<motion.div
|
||||
className='inline-flex items-center gap-2 px-4 py-2 rounded-full'
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
@@ -29,11 +29,11 @@ export default function AboutSection({ language }: AboutSectionProps) {
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<Target
|
||||
className='w-4 h-4'
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
<span
|
||||
className='text-sm font-semibold'
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
{t('aboutNofx', language)}
|
||||
@@ -41,45 +41,49 @@ export default function AboutSection({ language }: AboutSectionProps) {
|
||||
</motion.div>
|
||||
|
||||
<h2
|
||||
className='text-4xl font-bold'
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('whatIsNofx', language)}
|
||||
</h2>
|
||||
<p
|
||||
className='text-lg leading-relaxed'
|
||||
className="text-lg leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('nofxNotAnotherBot', language)} {t('nofxDescription1', language)} {t('nofxDescription2', language)}
|
||||
{t('nofxNotAnotherBot', language)}{' '}
|
||||
{t('nofxDescription1', language)}{' '}
|
||||
{t('nofxDescription2', language)}
|
||||
</p>
|
||||
<p
|
||||
className='text-lg leading-relaxed'
|
||||
className="text-lg leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('nofxDescription3', language)} {t('nofxDescription4', language)} {t('nofxDescription5', language)}
|
||||
{t('nofxDescription3', language)}{' '}
|
||||
{t('nofxDescription4', language)}{' '}
|
||||
{t('nofxDescription5', language)}
|
||||
</p>
|
||||
<motion.div
|
||||
className='flex items-center gap-3 pt-4'
|
||||
className="flex items-center gap-3 pt-4"
|
||||
whileHover={{ x: 5 }}
|
||||
>
|
||||
<div
|
||||
className='w-12 h-12 rounded-full flex items-center justify-center'
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
|
||||
>
|
||||
<Shield
|
||||
className='w-6 h-6'
|
||||
className="w-6 h-6"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className='font-semibold'
|
||||
className="font-semibold"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('youFullControl', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-sm'
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('fullControlDesc', language)}
|
||||
@@ -88,9 +92,9 @@ export default function AboutSection({ language }: AboutSectionProps) {
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className='relative'>
|
||||
<div className="relative">
|
||||
<div
|
||||
className='rounded-2xl p-8'
|
||||
className="rounded-2xl p-8"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
@@ -108,7 +112,7 @@ export default function AboutSection({ language }: AboutSectionProps) {
|
||||
]}
|
||||
typingSpeed={70}
|
||||
lineDelay={900}
|
||||
className='text-sm font-mono'
|
||||
className="text-sm font-mono"
|
||||
style={{
|
||||
color: '#00FF88',
|
||||
textShadow: '0 0 8px rgba(0,255,136,0.4)',
|
||||
@@ -121,4 +125,3 @@ export default function AboutSection({ language }: AboutSectionProps) {
|
||||
</AnimatedSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AnimatedSection({
|
||||
<motion.section
|
||||
id={id}
|
||||
ref={ref}
|
||||
className='py-20 px-4'
|
||||
className="py-20 px-4"
|
||||
style={{ background: backgroundColor }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
|
||||
@@ -27,4 +27,3 @@ export default function AnimatedSection({
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,31 +2,40 @@ import { motion } from 'framer-motion'
|
||||
import AnimatedSection from './AnimatedSection'
|
||||
|
||||
interface CardProps {
|
||||
quote: string;
|
||||
authorName: string;
|
||||
handle: string;
|
||||
avatarUrl: string;
|
||||
tweetUrl: string;
|
||||
delay: number;
|
||||
quote: string
|
||||
authorName: string
|
||||
handle: string
|
||||
avatarUrl: string
|
||||
tweetUrl: string
|
||||
delay: number
|
||||
}
|
||||
|
||||
function TestimonialCard({ quote, authorName, delay }: CardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className='p-6 rounded-xl'
|
||||
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.1)' }}
|
||||
className="p-6 rounded-xl"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.1)',
|
||||
}}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<p className='text-lg mb-4' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<p className="text-lg mb-4" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
"{quote}"
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-8 h-8 rounded-full' style={{ background: 'var(--binance-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full"
|
||||
style={{ background: 'var(--binance-yellow)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{authorName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -35,7 +44,9 @@ function TestimonialCard({ quote, authorName, delay }: CardProps) {
|
||||
}
|
||||
|
||||
export default function CommunitySection() {
|
||||
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
|
||||
const staggerContainer = {
|
||||
animate: { transition: { staggerChildren: 0.1 } },
|
||||
}
|
||||
|
||||
// 推特内容整合(保持原三列布局,超出自动换行)
|
||||
const items: CardProps[] = [
|
||||
@@ -74,12 +85,12 @@ export default function CommunitySection() {
|
||||
|
||||
return (
|
||||
<AnimatedSection>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
className='grid md:grid-cols-3 gap-6'
|
||||
className="grid md:grid-cols-3 gap-6"
|
||||
variants={staggerContainer}
|
||||
initial='initial'
|
||||
whileInView='animate'
|
||||
initial="initial"
|
||||
whileInView="animate"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
|
||||
@@ -10,61 +10,78 @@ interface FeaturesSectionProps {
|
||||
|
||||
export default function FeaturesSection({ language }: FeaturesSectionProps) {
|
||||
return (
|
||||
<AnimatedSection id='features'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||
<AnimatedSection id="features">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.div
|
||||
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<Rocket className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
<Rocket
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
{t('coreFeatures', language)}
|
||||
</span>
|
||||
</motion.div>
|
||||
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<h2
|
||||
className="text-4xl font-bold mb-4"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('whyChooseNofx', language)}
|
||||
</h2>
|
||||
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('openCommunityDriven', language)}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className='grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto'>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||
<CryptoFeatureCard
|
||||
icon={<Code className='w-8 h-8' />}
|
||||
icon={<Code className="w-8 h-8" />}
|
||||
title={t('openSourceSelfHosted', language)}
|
||||
description={t('openSourceDesc', language)}
|
||||
features={[
|
||||
t('openSourceFeatures1', language),
|
||||
t('openSourceFeatures2', language),
|
||||
t('openSourceFeatures3', language),
|
||||
t('openSourceFeatures4', language)
|
||||
t('openSourceFeatures4', language),
|
||||
]}
|
||||
delay={0}
|
||||
/>
|
||||
<CryptoFeatureCard
|
||||
icon={<Cpu className='w-8 h-8' />}
|
||||
icon={<Cpu className="w-8 h-8" />}
|
||||
title={t('multiAgentCompetition', language)}
|
||||
description={t('multiAgentDesc', language)}
|
||||
features={[
|
||||
t('multiAgentFeatures1', language),
|
||||
t('multiAgentFeatures2', language),
|
||||
t('multiAgentFeatures3', language),
|
||||
t('multiAgentFeatures4', language)
|
||||
t('multiAgentFeatures4', language),
|
||||
]}
|
||||
delay={0.1}
|
||||
/>
|
||||
<CryptoFeatureCard
|
||||
icon={<Lock className='w-8 h-8' />}
|
||||
icon={<Lock className="w-8 h-8" />}
|
||||
title={t('secureReliableTrading', language)}
|
||||
description={t('secureDesc', language)}
|
||||
features={[
|
||||
t('secureFeatures1', language),
|
||||
t('secureFeatures2', language),
|
||||
t('secureFeatures3', language),
|
||||
t('secureFeatures4', language)
|
||||
t('secureFeatures4', language),
|
||||
]}
|
||||
delay={0.2}
|
||||
/>
|
||||
@@ -73,4 +90,3 @@ export default function FeaturesSection({ language }: FeaturesSectionProps) {
|
||||
</AnimatedSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,57 +6,62 @@ interface FooterSectionProps {
|
||||
|
||||
export default function FooterSection({ language }: FooterSectionProps) {
|
||||
return (
|
||||
<footer style={{ borderTop: '1px solid var(--panel-border)', background: 'var(--brand-dark-gray)' }}>
|
||||
<div className='max-w-[1200px] mx-auto px-6 py-10'>
|
||||
<footer
|
||||
style={{
|
||||
borderTop: '1px solid var(--panel-border)',
|
||||
background: 'var(--brand-dark-gray)',
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1200px] mx-auto px-6 py-10">
|
||||
{/* Brand */}
|
||||
<div className='flex items-center gap-3 mb-8'>
|
||||
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' />
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
<div>
|
||||
<div className='text-lg font-bold' style={{ color: '#EAECEF' }}>
|
||||
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
NOFX
|
||||
</div>
|
||||
<div className='text-xs' style={{ color: '#848E9C' }}>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('futureStandardAI', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-link columns */}
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8'>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3
|
||||
className='text-sm font-semibold mb-3'
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('links', language)}
|
||||
</h3>
|
||||
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
|
||||
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://github.com/tinkle-community/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://t.me/nofx_dev_community'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://t.me/nofx_dev_community"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Telegram
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://x.com/nofx_ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://x.com/nofx_ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
@@ -66,38 +71,38 @@ export default function FooterSection({ language }: FooterSectionProps) {
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className='text-sm font-semibold mb-3'
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('resources', language)}
|
||||
</h3>
|
||||
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
|
||||
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx/blob/main/README.md'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://github.com/tinkle-community/nofx/blob/main/README.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('documentation', language)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx/issues'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://github.com/tinkle-community/nofx/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Issues
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx/pulls'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://github.com/tinkle-community/nofx/pulls"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Pull Requests
|
||||
</a>
|
||||
@@ -107,50 +112,53 @@ export default function FooterSection({ language }: FooterSectionProps) {
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className='text-sm font-semibold mb-3'
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('supporters', language)}
|
||||
</h3>
|
||||
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
|
||||
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://asterdex.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://asterdex.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Aster DEX
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://www.binance.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://www.binance.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Binance
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://hyperliquid.xyz/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://hyperliquid.xyz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Hyperliquid
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://amber.ac/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className="hover:text-[#F0B90B]"
|
||||
href="https://amber.ac/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Amber.ac <span className='opacity-70'>{t('strategicInvestment', language)}</span>
|
||||
Amber.ac{' '}
|
||||
<span className="opacity-70">
|
||||
{t('strategicInvestment', language)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -159,11 +167,14 @@ export default function FooterSection({ language }: FooterSectionProps) {
|
||||
|
||||
{/* Bottom note (kept subtle) */}
|
||||
<div
|
||||
className='pt-6 mt-8 text-center text-xs'
|
||||
style={{ color: 'var(--text-tertiary)', borderTop: '1px solid var(--panel-border)' }}
|
||||
className="pt-6 mt-8 text-center text-xs"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
borderTop: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className='mt-1'>{t('footerWarning', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -16,7 +16,17 @@ interface HeaderBarProps {
|
||||
onPageChange?: (page: string) => void
|
||||
}
|
||||
|
||||
export default function HeaderBar({ isLoggedIn = false, isHomePage = false, currentPage, language = 'zh' as Language, onLanguageChange, user, onLogout, isAdminMode = false, onPageChange }: HeaderBarProps) {
|
||||
export default function HeaderBar({
|
||||
isLoggedIn = false,
|
||||
isHomePage = false,
|
||||
currentPage,
|
||||
language = 'zh' as Language,
|
||||
onLanguageChange,
|
||||
user,
|
||||
onLogout,
|
||||
isAdminMode = false,
|
||||
onPageChange,
|
||||
}: HeaderBarProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
@@ -26,10 +36,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setLanguageDropdownOpen(false)
|
||||
}
|
||||
if (userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
userDropdownRef.current &&
|
||||
!userDropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setUserDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
@@ -41,231 +57,311 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav className='fixed top-0 w-full z-50 header-bar'>
|
||||
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<nav className="fixed top-0 w-full z-50 header-bar">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<a href='/' className='flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer'>
|
||||
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' />
|
||||
<span className='text-xl font-bold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
NOFX
|
||||
</span>
|
||||
<span className='text-sm hidden sm:block' style={{ color: 'var(--text-secondary)' }}>
|
||||
<span
|
||||
className="text-sm hidden sm:block"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Agentic Trading OS
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className='hidden md:flex items-center justify-between flex-1 ml-8'>
|
||||
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
||||
{/* Left Side - Navigation Tabs */}
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className="flex items-center gap-4">
|
||||
{isLoggedIn ? (
|
||||
// Main app navigation when logged in
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('实时 button clicked, onPageChange:', onPageChange);
|
||||
onPageChange?.('competition');
|
||||
console.log(
|
||||
'实时 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('competition')
|
||||
}}
|
||||
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)';
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)';
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('配置 button clicked, onPageChange:', onPageChange);
|
||||
onPageChange?.('traders');
|
||||
console.log(
|
||||
'配置 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('traders')
|
||||
}}
|
||||
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'traders'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'traders') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)';
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'traders') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)';
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'traders' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('configNav', language)}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('看板 button clicked, onPageChange:', onPageChange);
|
||||
onPageChange?.('trader');
|
||||
console.log(
|
||||
'看板 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('trader')
|
||||
}}
|
||||
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'trader'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'trader') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)';
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'trader') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)';
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'trader' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Landing page navigation when not logged in
|
||||
<a
|
||||
href='/competition'
|
||||
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
href="/competition"
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)';
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)';
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Right Side - Original Navigation Items and Login */}
|
||||
<div className='flex items-center gap-6'>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Only show original navigation items on home page */}
|
||||
{isHomePage && [
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) }
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={item.key === 'GitHub' || item.key === 'community' ? '_blank' : undefined}
|
||||
rel={item.key === 'GitHub' || item.key === 'community' ? 'noopener noreferrer' : undefined}
|
||||
className='text-sm transition-colors relative group'
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
className='absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300'
|
||||
style={{ background: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? '_blank'
|
||||
: undefined
|
||||
}
|
||||
rel={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
}
|
||||
className="text-sm transition-colors relative group"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
|
||||
style={{ background: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* User Info and Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* User Info with Dropdown */}
|
||||
<div className='relative' ref={userDropdownRef}>
|
||||
<div className="relative" ref={userDropdownRef}>
|
||||
<button
|
||||
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
||||
className='flex items-center gap-2 px-3 py-2 rounded transition-colors'
|
||||
style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--panel-bg)'}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'var(--panel-bg)')
|
||||
}
|
||||
>
|
||||
<div className='w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<span className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</span>
|
||||
<ChevronDown className='w-4 h-4' style={{ color: 'var(--brand-light-gray)' }} />
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
{userDropdownOpen && (
|
||||
<div className='absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50' style={{ background: 'var(--brand-dark-gray)', border: '1px solid var(--panel-border)' }}>
|
||||
<div className='px-3 py-2 border-b' style={{ borderColor: 'var(--panel-border)' }}>
|
||||
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div>
|
||||
<div className='text-sm font-medium' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div>
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 border-b"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
{!isAdminMode && onLogout && (
|
||||
<button
|
||||
@@ -273,10 +369,13 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
onLogout()
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className='w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center'
|
||||
style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}
|
||||
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,43 +384,58 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
</div>
|
||||
) : (
|
||||
/* Show login/register buttons when not logged in and not on login/register pages */
|
||||
currentPage !== 'login' && currentPage !== 'register' && (
|
||||
<div className='flex items-center gap-3'>
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href='/login'
|
||||
className='px-3 py-2 text-sm font-medium transition-colors rounded'
|
||||
href="/login"
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href='/register'
|
||||
className='px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90'
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
href="/register"
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
{/* Language Toggle - Always at the rightmost */}
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||
className='flex items-center gap-2 px-3 py-2 rounded transition-colors'
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'transparent')
|
||||
}
|
||||
>
|
||||
<span className='text-lg'>
|
||||
<span className="text-lg">
|
||||
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
|
||||
</span>
|
||||
<ChevronDown className='w-4 h-4' />
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
|
||||
{languageDropdownOpen && (
|
||||
<div className='absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50' style={{ background: 'var(--brand-dark-gray)', border: '1px solid var(--panel-border)' }}>
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
@@ -330,13 +444,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'zh' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background: language === 'zh' ? 'rgba(240, 185, 11, 0.1)' : 'transparent'
|
||||
background:
|
||||
language === 'zh'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className='text-base'>🇨🇳</span>
|
||||
<span className='text-sm'>中文</span>
|
||||
<span className="text-base">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -346,13 +463,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'en' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background: language === 'en' ? 'rgba(240, 185, 11, 0.1)' : 'transparent'
|
||||
background:
|
||||
language === 'en'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className='text-base'>🇺🇸</span>
|
||||
<span className='text-sm'>English</span>
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -363,11 +483,15 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className='md:hidden'
|
||||
className="md:hidden"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mobileMenuOpen ? <X className='w-6 h-6' /> : <Menu className='w-6 h-6' />}
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,65 +499,81 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={mobileMenuOpen ? { height: 'auto', opacity: 1 } : { height: 0, opacity: 0 }}
|
||||
animate={
|
||||
mobileMenuOpen
|
||||
? { height: 'auto', opacity: 1 }
|
||||
: { height: 0, opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3 }}
|
||||
className='md:hidden overflow-hidden'
|
||||
style={{ background: 'var(--brand-dark-gray)', borderTop: '1px solid rgba(240, 185, 11, 0.1)' }}
|
||||
className="md:hidden overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div className='px-4 py-4 space-y-3'>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{/* New Navigation Tabs */}
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('移动端 实时 button clicked, onPageChange:', onPageChange);
|
||||
console.log(
|
||||
'移动端 实时 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('competition')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left'
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href='/competition'
|
||||
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
<a
|
||||
href="/competition"
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</a>
|
||||
)}
|
||||
@@ -442,107 +582,135 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('移动端 配置 button clicked, onPageChange:', onPageChange);
|
||||
console.log(
|
||||
'移动端 配置 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('traders')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500'
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'traders'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left'
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'traders' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('configNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('移动端 看板 button clicked, onPageChange:', onPageChange);
|
||||
console.log(
|
||||
'移动端 看板 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('trader')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500'
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
|
||||
color:
|
||||
currentPage === 'trader'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left'
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'trader' && (
|
||||
<span
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Original Navigation Items - Only on home page */}
|
||||
{isHomePage && [
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) }
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={item.key === 'GitHub' || item.key === 'community' ? '_blank' : undefined}
|
||||
rel={item.key === 'GitHub' || item.key === 'community' ? 'noopener noreferrer' : undefined}
|
||||
className='block text-sm py-2'
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? '_blank'
|
||||
: undefined
|
||||
}
|
||||
rel={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
}
|
||||
className="block text-sm py-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className='py-2'>
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<span className='text-xs' style={{ color: 'var(--brand-light-gray)' }}>{t('language', language)}:</span>
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('language', language)}:
|
||||
</span>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'zh' ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
|
||||
language === 'zh'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className='text-lg'>🇨🇳</span>
|
||||
<span className='text-sm'>中文</span>
|
||||
<span className="text-lg">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -550,25 +718,49 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'en' ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
|
||||
language === 'en'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className='text-lg'>🇺🇸</span>
|
||||
<span className='text-sm'>English</span>
|
||||
<span className="text-lg">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User info and logout for mobile when logged in */}
|
||||
{isLoggedIn && user && (
|
||||
<div className='mt-4 pt-4' style={{ borderTop: '1px solid var(--panel-border)' }}>
|
||||
<div className='flex items-center gap-2 px-3 py-2 mb-2 rounded' style={{ background: 'var(--panel-bg)' }}>
|
||||
<div className='w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}>
|
||||
<div
|
||||
className="mt-4 pt-4"
|
||||
style={{ borderTop: '1px solid var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
|
||||
style={{ background: 'var(--panel-bg)' }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div>
|
||||
<div className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isAdminMode && onLogout && (
|
||||
@@ -577,8 +769,11 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
onLogout()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className='w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center'
|
||||
style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
@@ -587,29 +782,36 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
)}
|
||||
|
||||
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
||||
{!isLoggedIn && currentPage !== 'login' && currentPage !== 'register' && (
|
||||
<div className='space-y-2 mt-2'>
|
||||
<a
|
||||
href='/login'
|
||||
className='block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors'
|
||||
style={{ color: 'var(--brand-light-gray)', border: '1px solid var(--brand-light-gray)' }}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href='/register'
|
||||
className='block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors'
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!isLoggedIn &&
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '1px solid var(--brand-light-gray)',
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { motion, useScroll, useTransform, useAnimation } from 'framer-motion'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
import { useGitHubStats } from '../../hooks/useGitHubStats'
|
||||
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
|
||||
|
||||
interface HeroSectionProps {
|
||||
language: Language
|
||||
@@ -11,75 +13,151 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
|
||||
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
|
||||
const handControls = useAnimation()
|
||||
const { stars, daysOld, isLoading } = useGitHubStats('NoFxAiOS', 'nofx')
|
||||
|
||||
// 动画数字 - 仅对 stars 添加动画
|
||||
const animatedStars = useCounterAnimation({
|
||||
start: 0,
|
||||
end: stars,
|
||||
duration: 2000,
|
||||
})
|
||||
|
||||
const fadeInUp = {
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] },
|
||||
}
|
||||
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
|
||||
const staggerContainer = {
|
||||
animate: { transition: { staggerChildren: 0.1 } },
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='relative pt-32 pb-20 px-4'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<div className='grid lg:grid-cols-2 gap-12 items-center'>
|
||||
<section className="relative pt-32 pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
{/* Left Content */}
|
||||
<motion.div className='space-y-6 relative z-10' style={{ opacity, scale }} initial='initial' animate='animate' variants={staggerContainer}>
|
||||
<motion.div
|
||||
className="space-y-6 relative z-10"
|
||||
style={{ opacity, scale }}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<motion.div
|
||||
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)' }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
{t('githubStarsInDays', language)}
|
||||
<Sparkles
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
t('githubStarsInDays', language)
|
||||
) : language === 'zh' ? (
|
||||
<>
|
||||
{daysOld} 天内{' '}
|
||||
<span className="inline-block tabular-nums">
|
||||
{(animatedStars / 1000).toFixed(1)}
|
||||
</span>
|
||||
K+ GitHub Stars
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="inline-block tabular-nums">
|
||||
{(animatedStars / 1000).toFixed(1)}
|
||||
</span>
|
||||
K+ GitHub Stars in {daysOld} days
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<h1 className='text-5xl lg:text-7xl font-bold leading-tight' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<h1
|
||||
className="text-5xl lg:text-7xl font-bold leading-tight"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('heroTitle1', language)}
|
||||
<br />
|
||||
<span style={{ color: 'var(--brand-yellow)' }}>{t('heroTitle2', language)}</span>
|
||||
<span style={{ color: 'var(--brand-yellow)' }}>
|
||||
{t('heroTitle2', language)}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<motion.p className='text-xl leading-relaxed' style={{ color: 'var(--text-secondary)' }} variants={fadeInUp}>
|
||||
<motion.p
|
||||
className="text-xl leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
variants={fadeInUp}
|
||||
>
|
||||
{t('heroDescription', language)}
|
||||
</motion.p>
|
||||
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<motion.a
|
||||
href="https://github.com/tinkle-community/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: 'spring', stiffness: 400 }}
|
||||
>
|
||||
<img
|
||||
src='https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
|
||||
alt='GitHub Stars'
|
||||
className='h-7'
|
||||
src="https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
|
||||
alt="GitHub Stars"
|
||||
className="h-7"
|
||||
/>
|
||||
</motion.a>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx/network/members' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
|
||||
<motion.a
|
||||
href="https://github.com/tinkle-community/nofx/network/members"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: 'spring', stiffness: 400 }}
|
||||
>
|
||||
<img
|
||||
src='https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
|
||||
alt='GitHub Forks'
|
||||
className='h-7'
|
||||
src="https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
|
||||
alt="GitHub Forks"
|
||||
className="h-7"
|
||||
/>
|
||||
</motion.a>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx/graphs/contributors' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
|
||||
<motion.a
|
||||
href="https://github.com/tinkle-community/nofx/graphs/contributors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: 'spring', stiffness: 400 }}
|
||||
>
|
||||
<img
|
||||
src='https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
|
||||
alt='GitHub Contributors'
|
||||
className='h-7'
|
||||
src="https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
|
||||
alt="GitHub Contributors"
|
||||
className="h-7"
|
||||
/>
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<motion.p className='text-xs pt-4' style={{ color: 'var(--text-tertiary)' }} variants={fadeInUp}>
|
||||
{t('poweredBy', language)}
|
||||
<motion.p
|
||||
className="text-xs pt-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
variants={fadeInUp}
|
||||
>
|
||||
{t('poweredBy', language)}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Right Visual - Interactive Robot */}
|
||||
<div
|
||||
className='relative w-full cursor-pointer'
|
||||
<div
|
||||
className="relative w-full cursor-pointer"
|
||||
onMouseEnter={() => {
|
||||
handControls.start({
|
||||
y: [-8, 8, -8],
|
||||
@@ -88,9 +166,9 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
times: [0, 0.5, 1]
|
||||
}
|
||||
ease: 'easeInOut',
|
||||
times: [0, 0.5, 1],
|
||||
},
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
@@ -100,32 +178,32 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut"
|
||||
}
|
||||
ease: 'easeOut',
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* Background Layer */}
|
||||
<motion.img
|
||||
src='/images/hand-bg.png'
|
||||
alt='NOFX Platform Background'
|
||||
className='w-full opacity-90'
|
||||
<motion.img
|
||||
src="/images/hand-bg.png"
|
||||
alt="NOFX Platform Background"
|
||||
className="w-full opacity-90"
|
||||
style={{ opacity, scale }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
/>
|
||||
|
||||
|
||||
{/* Hand Layer - Animated */}
|
||||
<motion.img
|
||||
src='/images/hand.png'
|
||||
alt='Robot Hand'
|
||||
className='absolute top-0 left-0 w-full'
|
||||
<motion.img
|
||||
src="/images/hand.png"
|
||||
alt="Robot Hand"
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{ opacity }}
|
||||
animate={handControls}
|
||||
initial={{ y: 0, rotate: 0, x: 0 }}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { type: 'spring', stiffness: 400 }
|
||||
transition: { type: 'spring', stiffness: 400 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -134,4 +212,3 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,36 @@ import { t, Language } from '../../i18n/translations'
|
||||
|
||||
function StepCard({ number, title, description, delay }: any) {
|
||||
return (
|
||||
<motion.div className='flex gap-6 items-start' initial={{ opacity: 0, x: -50 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay }} whileHover={{ x: 10 }}>
|
||||
<motion.div
|
||||
className="flex gap-6 items-start"
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay }}
|
||||
whileHover={{ x: 10 }}
|
||||
>
|
||||
<motion.div
|
||||
className='flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl'
|
||||
style={{ background: 'var(--binance-yellow)', color: 'var(--brand-black)' }}
|
||||
className="flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl"
|
||||
style={{
|
||||
background: 'var(--binance-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
whileHover={{ scale: 1.2, rotate: 360 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||
>
|
||||
{number}
|
||||
</motion.div>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold mb-2' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<h3
|
||||
className="text-2xl font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className='text-lg leading-relaxed' style={{ color: 'var(--text-secondary)' }}>
|
||||
<p
|
||||
className="text-lg leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -29,46 +45,91 @@ interface HowItWorksSectionProps {
|
||||
language: Language
|
||||
}
|
||||
|
||||
export default function HowItWorksSection({ language }: HowItWorksSectionProps) {
|
||||
export default function HowItWorksSection({
|
||||
language,
|
||||
}: HowItWorksSectionProps) {
|
||||
return (
|
||||
<AnimatedSection id='how-it-works' backgroundColor='var(--brand-dark-gray)'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<AnimatedSection id="how-it-works" backgroundColor="var(--brand-dark-gray)">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2
|
||||
className="text-4xl font-bold mb-4"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('howToStart', language)}
|
||||
</h2>
|
||||
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('fourSimpleSteps', language)}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className='space-y-8'>
|
||||
<div className="space-y-8">
|
||||
{[
|
||||
{ number: 1, title: t('step1Title', language), description: t('step1Desc', language) },
|
||||
{ number: 2, title: t('step2Title', language), description: t('step2Desc', language) },
|
||||
{ number: 3, title: t('step3Title', language), description: t('step3Desc', language) },
|
||||
{ number: 4, title: t('step4Title', language), description: t('step4Desc', language) },
|
||||
{
|
||||
number: 1,
|
||||
title: t('step1Title', language),
|
||||
description: t('step1Desc', language),
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: t('step2Title', language),
|
||||
description: t('step2Desc', language),
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: t('step3Title', language),
|
||||
description: t('step3Desc', language),
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: t('step4Title', language),
|
||||
description: t('step4Desc', language),
|
||||
},
|
||||
].map((step, index) => (
|
||||
<StepCard key={step.number} {...step} delay={index * 0.1} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className='mt-12 p-6 rounded-xl flex items-start gap-4'
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}
|
||||
className="mt-12 p-6 rounded-xl flex items-start gap-4"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<div className='w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0' style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><path d='M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z'/><line x1='12' x2='12' y1='9' y2='13'/><line x1='12' x2='12.01' y1='17' y2='17'/></svg>
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
|
||||
<line x1="12" x2="12" y1="9" y2="13" />
|
||||
<line x1="12" x2="12.01" y1="17" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className='font-semibold mb-2' style={{ color: '#F6465D' }}>
|
||||
<div className="font-semibold mb-2" style={{ color: '#F6465D' }}>
|
||||
{t('importantRiskWarning', language)}
|
||||
</div>
|
||||
<p className='text-sm' style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('riskWarningText', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface LoginModalProps {
|
||||
export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className='fixed inset-0 z-50 flex items-center justify-center p-4'
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -18,32 +18,50 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className='relative max-w-md w-full rounded-2xl p-8'
|
||||
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
className="relative max-w-md w-full rounded-2xl p-8"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
initial={{ scale: 0.9, y: 50 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 50 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<motion.button onClick={onClose} className='absolute top-4 right-4' style={{ color: 'var(--text-secondary)' }} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
|
||||
<X className='w-6 h-6' />
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</motion.button>
|
||||
<h2 className='text-2xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<h2
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('accessNofxPlatform', language)}
|
||||
</h2>
|
||||
<p className='text-sm mb-6' style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('loginRegisterPrompt', language)}
|
||||
</p>
|
||||
<div className='space-y-3'>
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClose()
|
||||
}}
|
||||
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
whileHover={{ scale: 1.05, boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)' }}
|
||||
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
@@ -54,8 +72,12 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClose()
|
||||
}}
|
||||
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
|
||||
style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
@@ -66,4 +88,3 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +1,86 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getSystemConfig } from '../lib/config';
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>;
|
||||
register: (email: string, password: string, betaCode?: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>;
|
||||
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
|
||||
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
user: User | null
|
||||
token: string | null
|
||||
login: (
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
userID?: string
|
||||
requiresOTP?: boolean
|
||||
}>
|
||||
register: (
|
||||
email: string,
|
||||
password: string,
|
||||
betaCode?: string
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
userID?: string
|
||||
otpSecret?: string
|
||||
qrCodeURL?: string
|
||||
}>
|
||||
verifyOTP: (
|
||||
userID: string,
|
||||
otpCode: string
|
||||
) => Promise<{ success: boolean; message?: string }>
|
||||
completeRegistration: (
|
||||
userID: string,
|
||||
otpCode: string
|
||||
) => Promise<{ success: boolean; message?: string }>
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
|
||||
getSystemConfig()
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
if (data.admin_mode) {
|
||||
// 管理员模式下,模拟admin用户
|
||||
setUser({ id: 'admin', email: 'admin@localhost' });
|
||||
setToken('admin-mode');
|
||||
setUser({ id: 'admin', email: 'admin@localhost' })
|
||||
setToken('admin-mode')
|
||||
} else {
|
||||
// 非管理员模式,检查本地存储中是否有token
|
||||
const savedToken = localStorage.getItem('auth_token');
|
||||
const savedUser = localStorage.getItem('auth_user');
|
||||
|
||||
const savedToken = localStorage.getItem('auth_token')
|
||||
const savedUser = localStorage.getItem('auth_user')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
setToken(savedToken)
|
||||
setUser(JSON.parse(savedUser))
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch system config:', err);
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch system config:', err)
|
||||
// 发生错误时,继续检查本地存储
|
||||
const savedToken = localStorage.getItem('auth_token');
|
||||
const savedUser = localStorage.getItem('auth_user');
|
||||
|
||||
const savedToken = localStorage.getItem('auth_token')
|
||||
const savedUser = localStorage.getItem('auth_user')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
setToken(savedToken)
|
||||
setUser(JSON.parse(savedUser))
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
@@ -66,9 +90,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
})
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
if (data.requires_otp) {
|
||||
@@ -77,23 +101,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
userID: data.user_id,
|
||||
requiresOTP: true,
|
||||
message: data.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '登录失败,请重试' };
|
||||
return { success: false, message: '登录失败,请重试' }
|
||||
}
|
||||
|
||||
return { success: false, message: '未知错误' };
|
||||
};
|
||||
return { success: false, message: '未知错误' }
|
||||
}
|
||||
|
||||
const register = async (email: string, password: string, betaCode?: string) => {
|
||||
const register = async (
|
||||
email: string,
|
||||
password: string,
|
||||
betaCode?: string
|
||||
) => {
|
||||
try {
|
||||
const requestBody: { email: string; password: string; beta_code?: string } = { email, password };
|
||||
const requestBody: {
|
||||
email: string
|
||||
password: string
|
||||
beta_code?: string
|
||||
} = { email, password }
|
||||
if (betaCode) {
|
||||
requestBody.beta_code = betaCode;
|
||||
requestBody.beta_code = betaCode
|
||||
}
|
||||
|
||||
const response = await fetch('/api/register', {
|
||||
@@ -102,9 +134,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
})
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
@@ -113,14 +145,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
otpSecret: data.otp_secret,
|
||||
qrCodeURL: data.qr_code_url,
|
||||
message: data.message,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '注册失败,请重试' };
|
||||
return { success: false, message: '注册失败,请重试' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const verifyOTP = async (userID: string, otpCode: string) => {
|
||||
try {
|
||||
@@ -130,30 +162,30 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
|
||||
});
|
||||
})
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// 登录成功,保存token和用户信息
|
||||
const userInfo = { id: data.user_id, email: data.email };
|
||||
setToken(data.token);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo));
|
||||
|
||||
// 跳转到首页
|
||||
window.history.pushState({}, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
|
||||
return { success: true, message: data.message };
|
||||
const userInfo = { id: data.user_id, email: data.email }
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
|
||||
return { success: true, message: data.message }
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: 'OTP验证失败,请重试' };
|
||||
return { success: false, message: 'OTP验证失败,请重试' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const completeRegistration = async (userID: string, otpCode: string) => {
|
||||
try {
|
||||
@@ -163,37 +195,37 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
|
||||
});
|
||||
})
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// 注册完成,自动登录
|
||||
const userInfo = { id: data.user_id, email: data.email };
|
||||
setToken(data.token);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo));
|
||||
|
||||
// 跳转到首页
|
||||
window.history.pushState({}, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
|
||||
return { success: true, message: data.message };
|
||||
const userInfo = { id: data.user_id, email: data.email }
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
|
||||
return { success: true, message: data.message }
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '注册完成失败,请重试' };
|
||||
return { success: false, message: '注册完成失败,请重试' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
};
|
||||
setUser(null)
|
||||
setToken(null)
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -210,13 +242,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context;
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import type { Language } from '../i18n/translations';
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import type { Language } from '../i18n/translations'
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
language: Language
|
||||
setLanguage: (lang: Language) => void
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
// Initialize language from localStorage or default to English
|
||||
const [language, setLanguage] = useState<Language>(() => {
|
||||
const saved = localStorage.getItem('language');
|
||||
return (saved === 'en' || saved === 'zh') ? saved : 'en';
|
||||
});
|
||||
const saved = localStorage.getItem('language')
|
||||
return saved === 'en' || saved === 'zh' ? saved : 'en'
|
||||
})
|
||||
|
||||
// Save language to localStorage whenever it changes
|
||||
const handleSetLanguage = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
localStorage.setItem('language', lang);
|
||||
};
|
||||
setLanguage(lang)
|
||||
localStorage.setItem('language', lang)
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage }}>
|
||||
<LanguageContext.Provider
|
||||
value={{ language, setLanguage: handleSetLanguage }}
|
||||
>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext);
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within LanguageProvider');
|
||||
throw new Error('useLanguage must be used within LanguageProvider')
|
||||
}
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface UseCounterAnimationOptions {
|
||||
start?: number
|
||||
end: number
|
||||
duration?: number
|
||||
decimals?: number
|
||||
}
|
||||
|
||||
export function useCounterAnimation({
|
||||
start = 0,
|
||||
end,
|
||||
duration = 2000,
|
||||
decimals = 0,
|
||||
}: UseCounterAnimationOptions): number {
|
||||
const [count, setCount] = useState(start)
|
||||
|
||||
useEffect(() => {
|
||||
if (end === 0) return
|
||||
|
||||
let startTime: number | null = null
|
||||
let animationFrame: number
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (startTime === null) startTime = currentTime
|
||||
const progress = Math.min((currentTime - startTime) / duration, 1)
|
||||
|
||||
// 使用 easeOutExpo 缓动函数,让数字快速启动后缓慢停止
|
||||
const easeOutExpo = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)
|
||||
|
||||
const currentCount = start + (end - start) * easeOutExpo
|
||||
setCount(currentCount)
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
} else {
|
||||
setCount(end)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
}
|
||||
}, [start, end, duration])
|
||||
|
||||
return decimals > 0 ? parseFloat(count.toFixed(decimals)) : Math.floor(count)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface GitHubStats {
|
||||
stars: number
|
||||
forks: number
|
||||
createdAt: string
|
||||
daysOld: number
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useGitHubStats(owner: string, repo: string): GitHubStats {
|
||||
const [stats, setStats] = useState<GitHubStats>({
|
||||
stars: 0,
|
||||
forks: 0,
|
||||
createdAt: '',
|
||||
daysOld: 0,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGitHubStats = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch GitHub stats')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Calculate days since creation
|
||||
const createdDate = new Date(data.created_at)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - createdDate.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
setStats({
|
||||
stars: data.stargazers_count,
|
||||
forks: data.forks_count,
|
||||
createdAt: data.created_at,
|
||||
daysOld: diffDays,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stats:', error)
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fetchGitHubStats()
|
||||
}, [owner, repo])
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSystemConfig, type SystemConfig } from '../lib/config';
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getSystemConfig, type SystemConfig } from '../lib/config'
|
||||
|
||||
export function useSystemConfig() {
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let mounted = true
|
||||
getSystemConfig()
|
||||
.then((data) => {
|
||||
if (!mounted) return;
|
||||
setConfig(data);
|
||||
setLoading(false);
|
||||
if (!mounted) return
|
||||
setConfig(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!mounted) return;
|
||||
console.error('Failed to fetch system config:', err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
if (!mounted) return
|
||||
console.error('Failed to fetch system config:', err)
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { config, loading, error };
|
||||
}
|
||||
return { config, loading, error }
|
||||
}
|
||||
|
||||
+174
-96
@@ -1,4 +1,4 @@
|
||||
export type Language = 'en' | 'zh';
|
||||
export type Language = 'en' | 'zh'
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
@@ -15,7 +15,7 @@ export const translations = {
|
||||
logout: 'Logout',
|
||||
switchTrader: 'Switch Trader:',
|
||||
view: 'View',
|
||||
|
||||
|
||||
// Navigation
|
||||
realtimeNav: 'Live',
|
||||
configNav: 'Config',
|
||||
@@ -74,7 +74,7 @@ export const translations = {
|
||||
recent: 'Recent',
|
||||
allData: 'All Data',
|
||||
cycles: 'Cycles',
|
||||
|
||||
|
||||
// Comparison Chart
|
||||
comparisonMode: 'Comparison Mode',
|
||||
dataPoints: 'Data Points',
|
||||
@@ -147,7 +147,8 @@ export const translations = {
|
||||
createFirstTrader: 'Create your first AI trader to get started',
|
||||
configureModelsFirst: 'Please configure AI models first',
|
||||
configureExchangesFirst: 'Please configure exchanges first',
|
||||
configureModelsAndExchangesFirst: 'Please configure AI models and exchanges first',
|
||||
configureModelsAndExchangesFirst:
|
||||
'Please configure AI models and exchanges first',
|
||||
modelNotConfigured: 'Selected model is not configured',
|
||||
exchangeNotConfigured: 'Selected exchange is not configured',
|
||||
confirmDeleteTrader: 'Are you sure you want to delete this trader?',
|
||||
@@ -168,7 +169,7 @@ export const translations = {
|
||||
useTestnet: 'Use Testnet',
|
||||
enabled: 'Enabled',
|
||||
save: 'Save',
|
||||
|
||||
|
||||
// AI Model Configuration
|
||||
officialAPI: 'Official API',
|
||||
customAPI: 'Custom API',
|
||||
@@ -192,9 +193,20 @@ export const translations = {
|
||||
enterSigner: 'Enter Signer Address',
|
||||
enterSecretKey: 'Enter Secret Key',
|
||||
enterPassphrase: 'Enter Passphrase (Required for OKX)',
|
||||
hyperliquidPrivateKeyDesc: 'Hyperliquid uses private key for trading authentication',
|
||||
hyperliquidWalletAddressDesc: 'Wallet address corresponding to the private key',
|
||||
testnetDescription: 'Enable to connect to exchange test environment for simulated trading',
|
||||
hyperliquidPrivateKeyDesc:
|
||||
'Hyperliquid uses private key for trading authentication',
|
||||
hyperliquidWalletAddressDesc:
|
||||
'Wallet address corresponding to the private key',
|
||||
asterUserDesc:
|
||||
'Main wallet address - The EVM wallet address you use to log in to Aster',
|
||||
asterSignerDesc:
|
||||
'API wallet address - Generate from https://www.asterdex.com/en/api-wallet',
|
||||
asterPrivateKeyDesc:
|
||||
'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
|
||||
asterUsdtWarning:
|
||||
'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)',
|
||||
testnetDescription:
|
||||
'Enable to connect to exchange test environment for simulated trading',
|
||||
securityWarning: 'Security Warning',
|
||||
saveConfiguration: 'Save Configuration',
|
||||
|
||||
@@ -202,20 +214,25 @@ export const translations = {
|
||||
positionMode: 'Position Mode',
|
||||
crossMarginMode: 'Cross Margin',
|
||||
isolatedMarginMode: 'Isolated Margin',
|
||||
crossMarginDescription: 'Cross margin: All positions share account balance as collateral',
|
||||
isolatedMarginDescription: 'Isolated margin: Each position manages collateral independently, risk isolation',
|
||||
crossMarginDescription:
|
||||
'Cross margin: All positions share account balance as collateral',
|
||||
isolatedMarginDescription:
|
||||
'Isolated margin: Each position manages collateral independently, risk isolation',
|
||||
leverageConfiguration: 'Leverage Configuration',
|
||||
btcEthLeverage: 'BTC/ETH Leverage',
|
||||
altcoinLeverage: 'Altcoin Leverage',
|
||||
leverageRecommendation: 'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
|
||||
leverageRecommendation:
|
||||
'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
|
||||
tradingSymbols: 'Trading Symbols',
|
||||
tradingSymbolsPlaceholder: 'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
|
||||
tradingSymbolsPlaceholder:
|
||||
'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
|
||||
selectSymbols: 'Select Symbols',
|
||||
selectTradingSymbols: 'Select Trading Symbols',
|
||||
selectedSymbolsCount: 'Selected {count} symbols',
|
||||
clearSelection: 'Clear All',
|
||||
confirmSelection: 'Confirm',
|
||||
tradingSymbolsDescription: 'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
|
||||
tradingSymbolsDescription:
|
||||
'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
|
||||
btcEthLeverageValidation: 'BTC/ETH leverage must be between 1-50x',
|
||||
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
|
||||
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
|
||||
@@ -223,7 +240,8 @@ export const translations = {
|
||||
// Loading & Error
|
||||
loading: 'Loading...',
|
||||
loadingError: '⚠️ Failed to load AI learning data',
|
||||
noCompleteData: 'No complete trading data (needs to complete open → close cycle)',
|
||||
noCompleteData:
|
||||
'No complete trading data (needs to complete open → close cycle)',
|
||||
|
||||
// AI Traders Page - Additional
|
||||
inUse: 'In Use',
|
||||
@@ -231,31 +249,44 @@ export const translations = {
|
||||
noExchangesConfigured: 'No configured exchanges',
|
||||
signalSource: 'Signal Source',
|
||||
signalSourceConfig: 'Signal Source Configuration',
|
||||
coinPoolDescription: 'API endpoint for coin pool data, leave blank to disable this signal source',
|
||||
oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source',
|
||||
coinPoolDescription:
|
||||
'API endpoint for coin pool data, leave blank to disable this signal source',
|
||||
oiTopDescription:
|
||||
'API endpoint for open interest rankings, leave blank to disable this signal source',
|
||||
information: 'Information',
|
||||
signalSourceInfo1: '• Signal source configuration is per-user, each user can set their own URLs',
|
||||
signalSourceInfo2: '• When creating traders, you can choose whether to use these signal sources',
|
||||
signalSourceInfo3: '• Configured URLs will be used to fetch market data and trading signals',
|
||||
signalSourceInfo1:
|
||||
'• Signal source configuration is per-user, each user can set their own URLs',
|
||||
signalSourceInfo2:
|
||||
'• When creating traders, you can choose whether to use these signal sources',
|
||||
signalSourceInfo3:
|
||||
'• Configured URLs will be used to fetch market data and trading signals',
|
||||
editAIModel: 'Edit AI Model',
|
||||
addAIModel: 'Add AI Model',
|
||||
confirmDeleteModel: 'Are you sure you want to delete this AI model configuration?',
|
||||
confirmDeleteModel:
|
||||
'Are you sure you want to delete this AI model configuration?',
|
||||
selectModel: 'Select AI Model',
|
||||
pleaseSelectModel: 'Please select a model',
|
||||
customBaseURL: 'Base URL (Optional)',
|
||||
customBaseURLPlaceholder: 'Custom API base URL, e.g.: https://api.openai.com/v1',
|
||||
customBaseURLPlaceholder:
|
||||
'Custom API base URL, e.g.: https://api.openai.com/v1',
|
||||
leaveBlankForDefault: 'Leave blank to use default API address',
|
||||
modelConfigInfo1: '• API Key will be encrypted and stored, please ensure it is valid',
|
||||
modelConfigInfo1:
|
||||
'• API Key will be encrypted and stored, please ensure it is valid',
|
||||
modelConfigInfo2: '• Base URL is used for custom API server address',
|
||||
modelConfigInfo3: '• After deleting configuration, traders using this model will not work properly',
|
||||
modelConfigInfo3:
|
||||
'• After deleting configuration, traders using this model will not work properly',
|
||||
saveConfig: 'Save Configuration',
|
||||
editExchange: 'Edit Exchange',
|
||||
addExchange: 'Add Exchange',
|
||||
confirmDeleteExchange: 'Are you sure you want to delete this exchange configuration?',
|
||||
confirmDeleteExchange:
|
||||
'Are you sure you want to delete this exchange configuration?',
|
||||
pleaseSelectExchange: 'Please select an exchange',
|
||||
exchangeConfigWarning1: '• API keys will be encrypted, recommend using read-only or futures trading permissions',
|
||||
exchangeConfigWarning2: '• Do not grant withdrawal permissions to ensure fund security',
|
||||
exchangeConfigWarning3: '• After deleting configuration, related traders will not be able to trade',
|
||||
exchangeConfigWarning1:
|
||||
'• API keys will be encrypted, recommend using read-only or futures trading permissions',
|
||||
exchangeConfigWarning2:
|
||||
'• Do not grant withdrawal permissions to ensure fund security',
|
||||
exchangeConfigWarning3:
|
||||
'• After deleting configuration, related traders will not be able to trade',
|
||||
edit: 'Edit',
|
||||
viewGuide: 'View Guide',
|
||||
binanceSetupGuide: 'Binance Setup Guide',
|
||||
@@ -265,7 +296,8 @@ export const translations = {
|
||||
createTraderFailed: 'Failed to create trader',
|
||||
getTraderConfigFailed: 'Failed to get trader configuration',
|
||||
modelConfigNotExist: 'Model configuration does not exist or is not enabled',
|
||||
exchangeConfigNotExist: 'Exchange configuration does not exist or is not enabled',
|
||||
exchangeConfigNotExist:
|
||||
'Exchange configuration does not exist or is not enabled',
|
||||
updateTraderFailed: 'Failed to update trader',
|
||||
deleteTraderFailed: 'Failed to delete trader',
|
||||
operationFailed: 'Operation failed',
|
||||
@@ -275,7 +307,7 @@ export const translations = {
|
||||
exchangeNotExist: 'Exchange does not exist',
|
||||
deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
|
||||
saveSignalSourceFailed: 'Failed to save signal source configuration',
|
||||
|
||||
|
||||
// Login & Register
|
||||
login: 'Sign In',
|
||||
register: 'Sign Up',
|
||||
@@ -302,12 +334,15 @@ export const translations = {
|
||||
enterOTPCode: 'Enter 6-digit OTP code',
|
||||
verifyOTP: 'Verify OTP',
|
||||
setupTwoFactor: 'Set up two-factor authentication',
|
||||
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator',
|
||||
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy',
|
||||
setupTwoFactorDesc:
|
||||
'Follow the steps below to secure your account with Google Authenticator',
|
||||
scanQRCodeInstructions:
|
||||
'Scan this QR code with Google Authenticator or Authy',
|
||||
otpSecret: 'Or enter this secret manually:',
|
||||
qrCodeHint: 'QR code (if scanning fails, use the secret below):',
|
||||
authStep1Title: 'Step 1: Install Google Authenticator',
|
||||
authStep1Desc: 'Download and install Google Authenticator from your app store',
|
||||
authStep1Desc:
|
||||
'Download and install Google Authenticator from your app store',
|
||||
authStep2Title: 'Step 2: Add account',
|
||||
authStep2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
|
||||
authStep3Title: 'Step 3: Verify setup',
|
||||
@@ -337,74 +372,93 @@ export const translations = {
|
||||
exitLogin: 'Sign Out',
|
||||
signIn: 'Sign In',
|
||||
signUp: 'Sign Up',
|
||||
|
||||
|
||||
// Hero Section
|
||||
githubStarsInDays: '2.5K+ GitHub Stars in 3 days',
|
||||
heroTitle1: 'Read the Market.',
|
||||
heroTitle2: 'Write the Trade.',
|
||||
heroDescription: 'NOFX is the future standard for AI trading — an open, community-driven agentic trading OS. Supporting Binance, Aster DEX and other exchanges, self-hosted, multi-agent competition, let AI automatically make decisions, execute and optimize trades for you.',
|
||||
poweredBy: 'Powered by Aster DEX and Binance, strategically invested by Amber.ac.',
|
||||
|
||||
heroDescription:
|
||||
'NOFX is the future standard for AI trading — an open, community-driven agentic trading OS. Supporting Binance, Aster DEX and other exchanges, self-hosted, multi-agent competition, let AI automatically make decisions, execute and optimize trades for you.',
|
||||
poweredBy:
|
||||
'Powered by Aster DEX and Binance, strategically invested by Amber.ac.',
|
||||
|
||||
// Landing Page CTA
|
||||
readyToDefine: 'Ready to define the future of AI trading?',
|
||||
startWithCrypto: 'Starting with crypto markets, expanding to TradFi. NOFX is the infrastructure of AgentFi.',
|
||||
startWithCrypto:
|
||||
'Starting with crypto markets, expanding to TradFi. NOFX is the infrastructure of AgentFi.',
|
||||
getStartedNow: 'Get Started Now',
|
||||
viewSourceCode: 'View Source Code',
|
||||
|
||||
|
||||
// Features Section
|
||||
coreFeatures: 'Core Features',
|
||||
whyChooseNofx: 'Why Choose NOFX?',
|
||||
openCommunityDriven: 'Open source, transparent, community-driven AI trading OS',
|
||||
openCommunityDriven:
|
||||
'Open source, transparent, community-driven AI trading OS',
|
||||
openSourceSelfHosted: '100% Open Source & Self-Hosted',
|
||||
openSourceDesc: 'Your framework, your rules. Non-black box, supports custom prompts and multi-models.',
|
||||
openSourceDesc:
|
||||
'Your framework, your rules. Non-black box, supports custom prompts and multi-models.',
|
||||
openSourceFeatures1: 'Fully open source code',
|
||||
openSourceFeatures2: 'Self-hosting deployment support',
|
||||
openSourceFeatures3: 'Custom AI prompts',
|
||||
openSourceFeatures4: 'Multi-model support (DeepSeek, Qwen)',
|
||||
multiAgentCompetition: 'Multi-Agent Intelligent Competition',
|
||||
multiAgentDesc: 'AI strategies battle at high speed in sandbox, survival of the fittest, achieving strategy evolution.',
|
||||
multiAgentDesc:
|
||||
'AI strategies battle at high speed in sandbox, survival of the fittest, achieving strategy evolution.',
|
||||
multiAgentFeatures1: 'Multiple AI agents running in parallel',
|
||||
multiAgentFeatures2: 'Automatic strategy optimization',
|
||||
multiAgentFeatures3: 'Sandbox security testing',
|
||||
multiAgentFeatures4: 'Cross-market strategy porting',
|
||||
secureReliableTrading: 'Secure and Reliable Trading',
|
||||
secureDesc: 'Enterprise-grade security, complete control over your funds and trading strategies.',
|
||||
secureDesc:
|
||||
'Enterprise-grade security, complete control over your funds and trading strategies.',
|
||||
secureFeatures1: 'Local private key management',
|
||||
secureFeatures2: 'Fine-grained API permission control',
|
||||
secureFeatures3: 'Real-time risk monitoring',
|
||||
secureFeatures4: 'Trading log auditing',
|
||||
|
||||
|
||||
// About Section
|
||||
aboutNofx: 'About NOFX',
|
||||
whatIsNofx: 'What is NOFX?',
|
||||
nofxNotAnotherBot: "NOFX is not another trading bot, but the 'Linux' of AI trading —",
|
||||
nofxDescription1: 'a transparent, trustworthy open source OS that provides a unified',
|
||||
nofxDescription2: "'decision-risk-execution' layer, supporting all asset classes.",
|
||||
nofxDescription3: 'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI',
|
||||
nofxDescription4: 'Darwinism (multi-agent self-competition, strategy evolution), CodeFi',
|
||||
nofxDescription5: 'flywheel (developers get point rewards for PR contributions).',
|
||||
nofxNotAnotherBot:
|
||||
"NOFX is not another trading bot, but the 'Linux' of AI trading —",
|
||||
nofxDescription1:
|
||||
'a transparent, trustworthy open source OS that provides a unified',
|
||||
nofxDescription2:
|
||||
"'decision-risk-execution' layer, supporting all asset classes.",
|
||||
nofxDescription3:
|
||||
'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI',
|
||||
nofxDescription4:
|
||||
'Darwinism (multi-agent self-competition, strategy evolution), CodeFi',
|
||||
nofxDescription5:
|
||||
'flywheel (developers get point rewards for PR contributions).',
|
||||
youFullControl: 'You 100% Control',
|
||||
fullControlDesc: 'Complete control over AI prompts and funds',
|
||||
startupMessages1: 'Starting automated trading system...',
|
||||
startupMessages2: 'API server started on port 8080',
|
||||
startupMessages3: 'Web console http://localhost:3000',
|
||||
|
||||
|
||||
// How It Works Section
|
||||
howToStart: 'How to Get Started with NOFX',
|
||||
fourSimpleSteps: 'Four simple steps to start your AI automated trading journey',
|
||||
fourSimpleSteps:
|
||||
'Four simple steps to start your AI automated trading journey',
|
||||
step1Title: 'Clone GitHub Repository',
|
||||
step1Desc: 'git clone https://github.com/tinkle-community/nofx and switch to dev branch to test new features.',
|
||||
step1Desc:
|
||||
'git clone https://github.com/tinkle-community/nofx and switch to dev branch to test new features.',
|
||||
step2Title: 'Configure Environment',
|
||||
step2Desc: 'Frontend setup for exchange APIs (like Binance, Hyperliquid), AI models and custom prompts.',
|
||||
step2Desc:
|
||||
'Frontend setup for exchange APIs (like Binance, Hyperliquid), AI models and custom prompts.',
|
||||
step3Title: 'Deploy & Run',
|
||||
step3Desc: 'One-click Docker deployment, start AI agents. Note: High-risk market, only test with money you can afford to lose.',
|
||||
step3Desc:
|
||||
'One-click Docker deployment, start AI agents. Note: High-risk market, only test with money you can afford to lose.',
|
||||
step4Title: 'Optimize & Contribute',
|
||||
step4Desc: 'Monitor trading, submit PRs to improve framework. Join Telegram to share strategies.',
|
||||
step4Desc:
|
||||
'Monitor trading, submit PRs to improve framework. Join Telegram to share strategies.',
|
||||
importantRiskWarning: 'Important Risk Warning',
|
||||
riskWarningText: 'Dev branch is unstable, do not use funds you cannot afford to lose. NOFX is non-custodial, no official strategies. Trading involves risks, invest carefully.',
|
||||
|
||||
riskWarningText:
|
||||
'Dev branch is unstable, do not use funds you cannot afford to lose. NOFX is non-custodial, no official strategies. Trading involves risks, invest carefully.',
|
||||
|
||||
// Community Section (testimonials are kept as-is since they are quotes)
|
||||
|
||||
|
||||
// Footer Section
|
||||
futureStandardAI: 'The future standard of AI trading',
|
||||
links: 'Links',
|
||||
@@ -412,10 +466,11 @@ export const translations = {
|
||||
documentation: 'Documentation',
|
||||
supporters: 'Supporters',
|
||||
strategicInvestment: '(Strategic Investment)',
|
||||
|
||||
|
||||
// Login Modal
|
||||
accessNofxPlatform: 'Access NOFX Platform',
|
||||
loginRegisterPrompt: 'Please login or register to access the full AI trading platform',
|
||||
loginRegisterPrompt:
|
||||
'Please login or register to access the full AI trading platform',
|
||||
registerNewAccount: 'Register New Account',
|
||||
},
|
||||
zh: {
|
||||
@@ -432,7 +487,7 @@ export const translations = {
|
||||
logout: '退出',
|
||||
switchTrader: '切换交易员:',
|
||||
view: '查看',
|
||||
|
||||
|
||||
// Navigation
|
||||
realtimeNav: '实时',
|
||||
configNav: '配置',
|
||||
@@ -491,7 +546,7 @@ export const translations = {
|
||||
recent: '最近',
|
||||
allData: '全部数据',
|
||||
cycles: '个',
|
||||
|
||||
|
||||
// Comparison Chart
|
||||
comparisonMode: '对比模式',
|
||||
dataPoints: '数据点数',
|
||||
@@ -585,7 +640,7 @@ export const translations = {
|
||||
useTestnet: '使用测试网',
|
||||
enabled: '启用',
|
||||
save: '保存',
|
||||
|
||||
|
||||
// AI Model Configuration
|
||||
officialAPI: '官方API',
|
||||
customAPI: '自定义API',
|
||||
@@ -611,7 +666,15 @@ export const translations = {
|
||||
enterPassphrase: '输入Passphrase (OKX必填)',
|
||||
hyperliquidPrivateKeyDesc: 'Hyperliquid 使用私钥进行交易认证',
|
||||
hyperliquidWalletAddressDesc: '与私钥对应的钱包地址',
|
||||
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
|
||||
asterUserDesc:
|
||||
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址',
|
||||
asterSignerDesc:
|
||||
'API 钱包地址 - 从 https://www.asterdex.com/zh-CN/api-wallet 生成',
|
||||
asterPrivateKeyDesc:
|
||||
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
||||
asterUsdtWarning:
|
||||
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
|
||||
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
|
||||
securityWarning: '安全提示',
|
||||
saveConfiguration: '保存配置',
|
||||
|
||||
@@ -626,13 +689,15 @@ export const translations = {
|
||||
altcoinLeverage: '山寨币杠杆',
|
||||
leverageRecommendation: '推荐:BTC/ETH 5-10倍,山寨币 3-5倍,控制风险',
|
||||
tradingSymbols: '交易币种',
|
||||
tradingSymbolsPlaceholder: '输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT)',
|
||||
tradingSymbolsPlaceholder:
|
||||
'输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT)',
|
||||
selectSymbols: '选择币种',
|
||||
selectTradingSymbols: '选择交易币种',
|
||||
selectedSymbolsCount: '已选择 {count} 个币种',
|
||||
clearSelection: '清空选择',
|
||||
confirmSelection: '确认选择',
|
||||
tradingSymbolsDescription: '留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT)',
|
||||
tradingSymbolsDescription:
|
||||
'留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT)',
|
||||
btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间',
|
||||
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
|
||||
invalidSymbolFormat: '无效的币种格式:{symbol},必须以USDT结尾',
|
||||
@@ -651,7 +716,8 @@ export const translations = {
|
||||
coinPoolDescription: '用于获取币种池数据的API地址,留空则不使用此信号源',
|
||||
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
|
||||
information: '说明',
|
||||
signalSourceInfo1: '• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
|
||||
signalSourceInfo1:
|
||||
'• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
|
||||
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
|
||||
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
|
||||
editAIModel: '编辑AI模型',
|
||||
@@ -692,7 +758,7 @@ export const translations = {
|
||||
exchangeNotExist: '交易所不存在',
|
||||
deleteExchangeConfigFailed: '删除交易所配置失败',
|
||||
saveSignalSourceFailed: '保存信号源配置失败',
|
||||
|
||||
|
||||
// Login & Register
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
@@ -754,20 +820,22 @@ export const translations = {
|
||||
exitLogin: '退出登录',
|
||||
signIn: '登录',
|
||||
signUp: '注册',
|
||||
|
||||
|
||||
// Hero Section
|
||||
githubStarsInDays: '3 天内 2.5K+ GitHub Stars',
|
||||
heroTitle1: 'Read the Market.',
|
||||
heroTitle2: 'Write the Trade.',
|
||||
heroDescription: 'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
|
||||
heroDescription:
|
||||
'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
|
||||
poweredBy: '由 Aster DEX 和 Binance 提供支持,Amber.ac 战略投资。',
|
||||
|
||||
|
||||
// Landing Page CTA
|
||||
readyToDefine: '准备好定义 AI 交易的未来吗?',
|
||||
startWithCrypto: '从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
|
||||
startWithCrypto:
|
||||
'从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
|
||||
getStartedNow: '立即开始',
|
||||
viewSourceCode: '查看源码',
|
||||
|
||||
|
||||
// Features Section
|
||||
coreFeatures: '核心功能',
|
||||
whyChooseNofx: '为什么选择 NOFX?',
|
||||
@@ -790,38 +858,44 @@ export const translations = {
|
||||
secureFeatures2: 'API 权限精细控制',
|
||||
secureFeatures3: '实时风险监控',
|
||||
secureFeatures4: '交易日志审计',
|
||||
|
||||
|
||||
// About Section
|
||||
aboutNofx: '关于 NOFX',
|
||||
whatIsNofx: '什么是 NOFX?',
|
||||
nofxNotAnotherBot: 'NOFX 不是另一个交易机器人,而是 AI 交易的 \'Linux\' ——',
|
||||
nofxDescription1: '一个透明、可信任的开源 OS,提供统一的 \'决策-风险-执行\'',
|
||||
nofxNotAnotherBot: "NOFX 不是另一个交易机器人,而是 AI 交易的 'Linux' ——",
|
||||
nofxDescription1: "一个透明、可信任的开源 OS,提供统一的 '决策-风险-执行'",
|
||||
nofxDescription2: '层,支持所有资产类别。',
|
||||
nofxDescription3: '从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI',
|
||||
nofxDescription4: '达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR',
|
||||
nofxDescription3:
|
||||
'从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI',
|
||||
nofxDescription4:
|
||||
'达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR',
|
||||
nofxDescription5: '贡献获积分奖励)。',
|
||||
youFullControl: '你 100% 掌控',
|
||||
fullControlDesc: '完全掌控 AI 提示词和资金',
|
||||
startupMessages1: ' 启动自动交易系统...',
|
||||
startupMessages2: ' API服务器启动在端口 8080',
|
||||
startupMessages3: ' Web 控制台 http://localhost:3000',
|
||||
|
||||
startupMessages1: '启动自动交易系统...',
|
||||
startupMessages2: 'API服务器启动在端口 8080',
|
||||
startupMessages3: 'Web 控制台 http://localhost:3000',
|
||||
|
||||
// How It Works Section
|
||||
howToStart: '如何开始使用 NOFX',
|
||||
fourSimpleSteps: '四个简单步骤,开启 AI 自动交易之旅',
|
||||
step1Title: '拉取 GitHub 仓库',
|
||||
step1Desc: 'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
|
||||
step1Desc:
|
||||
'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
|
||||
step2Title: '配置环境',
|
||||
step2Desc: '前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。',
|
||||
step2Desc:
|
||||
'前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。',
|
||||
step3Title: '部署与运行',
|
||||
step3Desc: '一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
|
||||
step3Desc:
|
||||
'一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
|
||||
step4Title: '优化与贡献',
|
||||
step4Desc: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。',
|
||||
importantRiskWarning: '重要风险提示',
|
||||
riskWarningText: 'dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
|
||||
|
||||
riskWarningText:
|
||||
'dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
|
||||
|
||||
// Community Section (testimonials are kept as-is since they are quotes)
|
||||
|
||||
|
||||
// Footer Section
|
||||
futureStandardAI: 'AI 交易的未来标准',
|
||||
links: '链接',
|
||||
@@ -829,23 +903,27 @@ export const translations = {
|
||||
documentation: '文档',
|
||||
supporters: '支持方',
|
||||
strategicInvestment: '(战略投资)',
|
||||
|
||||
|
||||
// Login Modal
|
||||
accessNofxPlatform: '访问 NOFX 平台',
|
||||
loginRegisterPrompt: '请选择登录或注册以访问完整的 AI 交易平台',
|
||||
registerNewAccount: '注册新账号',
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export function t(key: string, lang: Language, params?: Record<string, string | number>): string {
|
||||
let text = translations[lang][key as keyof typeof translations['en']] || key;
|
||||
export function t(
|
||||
key: string,
|
||||
lang: Language,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
let text = translations[lang][key as keyof (typeof translations)['en']] || key
|
||||
|
||||
// Replace parameters like {count}, {gap}, etc.
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([param, value]) => {
|
||||
text = text.replace(`{${param}}`, String(value));
|
||||
});
|
||||
text = text.replace(`{${param}}`, String(value))
|
||||
})
|
||||
}
|
||||
|
||||
return text;
|
||||
return text
|
||||
}
|
||||
|
||||
+55
-32
@@ -12,54 +12,62 @@ html {
|
||||
|
||||
:root {
|
||||
/* Binance Brand Colors */
|
||||
--brand-yellow: #F0B90B;
|
||||
--brand-yellow: #f0b90b;
|
||||
--brand-black: #000000;
|
||||
--brand-dark-gray: #0A0A0A;
|
||||
--brand-light-gray: #EAECEF;
|
||||
--brand-almost-white: #FAFAFA;
|
||||
--brand-white: #FFFFFF;
|
||||
--brand-dark-gray: #0a0a0a;
|
||||
--brand-light-gray: #eaecef;
|
||||
--brand-almost-white: #fafafa;
|
||||
--brand-white: #ffffff;
|
||||
|
||||
/* Binance Theme Colors */
|
||||
--binance-yellow: #F0B90B;
|
||||
--binance-yellow-dark: #C99400;
|
||||
--binance-yellow-light: #FCD535;
|
||||
--binance-yellow: #f0b90b;
|
||||
--binance-yellow-dark: #c99400;
|
||||
--binance-yellow-light: #fcd535;
|
||||
--binance-yellow-glow: rgba(240, 185, 11, 0.2);
|
||||
|
||||
--background: #000000; /* Binance body bg */
|
||||
--header-bg: #000000; /* Binance header bg */
|
||||
--header-bg: #000000; /* Binance header bg */
|
||||
--background-elevated: #000000;
|
||||
--foreground: #EAECEF;
|
||||
--panel-bg: #0A0A0A;
|
||||
--foreground: #eaecef;
|
||||
--panel-bg: #0a0a0a;
|
||||
--panel-bg-hover: #111111;
|
||||
--panel-border: #1A1A1A;
|
||||
--panel-border-hover: #2A2A2A;
|
||||
--panel-border: #1a1a1a;
|
||||
--panel-border-hover: #2a2a2a;
|
||||
|
||||
/* Binance Signature Colors */
|
||||
--binance-green: #0ECB81;
|
||||
--binance-green: #0ecb81;
|
||||
--binance-green-bg: rgba(14, 203, 129, 0.1);
|
||||
--binance-green-border: rgba(14, 203, 129, 0.2);
|
||||
--binance-red: #F6465D;
|
||||
--binance-red: #f6465d;
|
||||
--binance-red-bg: rgba(246, 70, 93, 0.1);
|
||||
--binance-red-border: rgba(246, 70, 93, 0.2);
|
||||
|
||||
/* UI Colors */
|
||||
--text-primary: #EAECEF;
|
||||
--text-secondary: #848E9C;
|
||||
--text-tertiary: #5E6673;
|
||||
--text-disabled: #474D57;
|
||||
--text-primary: #eaecef;
|
||||
--text-secondary: #848e9c;
|
||||
--text-tertiary: #5e6673;
|
||||
--text-disabled: #474d57;
|
||||
|
||||
/* Chart Colors */
|
||||
--grid-stroke: #1A1A1A;
|
||||
--axis-tick: #5E6673;
|
||||
--ref-line: #474D57;
|
||||
--grid-stroke: #1a1a1a;
|
||||
--axis-tick: #5e6673;
|
||||
--ref-line: #474d57;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
|
||||
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
color-scheme: dark;
|
||||
@@ -69,7 +77,7 @@ html {
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: "tnum";
|
||||
font-feature-settings: 'tnum';
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -140,7 +148,8 @@ body {
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
@@ -160,7 +169,8 @@ body {
|
||||
}
|
||||
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
@@ -240,11 +250,19 @@ button:disabled {
|
||||
|
||||
/* Binance gradient backgrounds */
|
||||
.binance-gradient {
|
||||
background: linear-gradient(135deg, var(--binance-yellow) 0%, var(--binance-yellow-light) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--binance-yellow) 0%,
|
||||
var(--binance-yellow-light) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.binance-gradient-subtle {
|
||||
background: linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(240, 185, 11, 0.15) 0%,
|
||||
rgba(252, 213, 53, 0.05) 100%
|
||||
);
|
||||
border: 1px solid rgba(240, 185, 11, 0.2);
|
||||
}
|
||||
|
||||
@@ -456,7 +474,12 @@ tr:hover {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--binance-yellow), transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--binance-yellow),
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
+119
-105
@@ -11,22 +11,22 @@ import type {
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
} from '../types';
|
||||
} from '../types'
|
||||
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = '/api'
|
||||
|
||||
// Helper function to get auth headers
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export const api = {
|
||||
@@ -34,16 +34,16 @@ export const api = {
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/my-traders`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取trader列表失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取trader列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取公开的交易员列表(无需认证)
|
||||
async getPublicTraders(): Promise<any[]> {
|
||||
const res = await fetch(`${API_BASE}/traders`);
|
||||
if (!res.ok) throw new Error('获取公开trader列表失败');
|
||||
return res.json();
|
||||
const res = await fetch(`${API_BASE}/traders`)
|
||||
if (!res.ok) throw new Error('获取公开trader列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
@@ -51,76 +51,82 @@ export const api = {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('创建交易员失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('创建交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async deleteTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('删除交易员失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('删除交易员失败')
|
||||
},
|
||||
|
||||
async startTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('启动交易员失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('启动交易员失败')
|
||||
},
|
||||
|
||||
async stopTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('停止交易员失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('停止交易员失败')
|
||||
},
|
||||
|
||||
async updateTraderPrompt(traderId: string, customPrompt: string): Promise<void> {
|
||||
async updateTraderPrompt(
|
||||
traderId: string,
|
||||
customPrompt: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ custom_prompt: customPrompt }),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新自定义策略失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('更新自定义策略失败')
|
||||
},
|
||||
|
||||
async getTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取交易员配置失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取交易员配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async updateTrader(traderId: string, request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
async updateTrader(
|
||||
traderId: string,
|
||||
request: CreateTraderRequest
|
||||
): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新交易员失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('更新交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// AI模型配置接口
|
||||
async getModelConfigs(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取模型配置失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取模型配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取系统支持的AI模型列表(无需认证)
|
||||
async getSupportedModels(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-models`);
|
||||
if (!res.ok) throw new Error('获取支持的模型失败');
|
||||
return res.json();
|
||||
const res = await fetch(`${API_BASE}/supported-models`)
|
||||
if (!res.ok) throw new Error('获取支持的模型失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
|
||||
@@ -128,123 +134,125 @@ export const api = {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新模型配置失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('更新模型配置失败')
|
||||
},
|
||||
|
||||
// 交易所配置接口
|
||||
async getExchangeConfigs(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取交易所配置失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取交易所配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取系统支持的交易所列表(无需认证)
|
||||
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-exchanges`);
|
||||
if (!res.ok) throw new Error('获取支持的交易所失败');
|
||||
return res.json();
|
||||
const res = await fetch(`${API_BASE}/supported-exchanges`)
|
||||
if (!res.ok) throw new Error('获取支持的交易所失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> {
|
||||
async updateExchangeConfigs(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新交易所配置失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 获取系统状态(支持trader_id)
|
||||
async getStatus(traderId?: string): Promise<SystemStatus> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
: `${API_BASE}/status`;
|
||||
: `${API_BASE}/status`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取系统状态失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取系统状态失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取账户信息(支持trader_id)
|
||||
async getAccount(traderId?: string): Promise<AccountInfo> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/account?trader_id=${traderId}`
|
||||
: `${API_BASE}/account`;
|
||||
: `${API_BASE}/account`
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error('获取账户信息失败');
|
||||
const data = await res.json();
|
||||
console.log('Account data fetched:', data);
|
||||
return data;
|
||||
})
|
||||
if (!res.ok) throw new Error('获取账户信息失败')
|
||||
const data = await res.json()
|
||||
console.log('Account data fetched:', data)
|
||||
return data
|
||||
},
|
||||
|
||||
// 获取持仓列表(支持trader_id)
|
||||
async getPositions(traderId?: string): Promise<Position[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`;
|
||||
: `${API_BASE}/positions`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取持仓列表失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取持仓列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取决策日志(支持trader_id)
|
||||
async getDecisions(traderId?: string): Promise<DecisionRecord[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions`;
|
||||
: `${API_BASE}/decisions`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取决策日志失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取决策日志失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取最新决策(支持trader_id)
|
||||
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions/latest`;
|
||||
: `${API_BASE}/decisions/latest`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取最新决策失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取最新决策失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取统计信息(支持trader_id)
|
||||
async getStatistics(traderId?: string): Promise<Statistics> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`;
|
||||
: `${API_BASE}/statistics`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取统计信息失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取统计信息失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取收益率历史数据(支持trader_id)
|
||||
async getEquityHistory(traderId?: string): Promise<any[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`;
|
||||
: `${API_BASE}/equity-history`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取历史数据失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取历史数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 批量获取多个交易员的历史数据(无需认证)
|
||||
@@ -255,54 +263,60 @@ export const api = {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ trader_ids: traderIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取批量历史数据失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取批量历史数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取前5名交易员数据(无需认证)
|
||||
async getTopTraders(): Promise<any[]> {
|
||||
const res = await fetch(`${API_BASE}/top-traders`);
|
||||
if (!res.ok) throw new Error('获取前5名交易员失败');
|
||||
return res.json();
|
||||
const res = await fetch(`${API_BASE}/top-traders`)
|
||||
if (!res.ok) throw new Error('获取前5名交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取公开交易员配置(无需认证)
|
||||
async getPublicTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/trader/${traderId}/config`);
|
||||
if (!res.ok) throw new Error('获取公开交易员配置失败');
|
||||
return res.json();
|
||||
const res = await fetch(`${API_BASE}/trader/${traderId}/config`)
|
||||
if (!res.ok) throw new Error('获取公开交易员配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取AI学习表现分析(支持trader_id)
|
||||
async getPerformance(traderId?: string): Promise<any> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/performance?trader_id=${traderId}`
|
||||
: `${API_BASE}/performance`;
|
||||
: `${API_BASE}/performance`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取AI学习数据失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取AI学习数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取竞赛数据(无需认证)
|
||||
async getCompetition(): Promise<CompetitionData> {
|
||||
const res = await fetch(`${API_BASE}/competition`);
|
||||
if (!res.ok) throw new Error('获取竞赛数据失败');
|
||||
return res.json();
|
||||
const res = await fetch(`${API_BASE}/competition`)
|
||||
if (!res.ok) throw new Error('获取竞赛数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 用户信号源配置接口
|
||||
async getUserSignalSource(): Promise<{coin_pool_url: string, oi_top_url: string}> {
|
||||
async getUserSignalSource(): Promise<{
|
||||
coin_pool_url: string
|
||||
oi_top_url: string
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取用户信号源配置失败');
|
||||
return res.json();
|
||||
})
|
||||
if (!res.ok) throw new Error('获取用户信号源配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async saveUserSignalSource(coinPoolUrl: string, oiTopUrl: string): Promise<void> {
|
||||
async saveUserSignalSource(
|
||||
coinPoolUrl: string,
|
||||
oiTopUrl: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
@@ -310,7 +324,7 @@ export const api = {
|
||||
coin_pool_url: coinPoolUrl,
|
||||
oi_top_url: oiTopUrl,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败');
|
||||
})
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败')
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+10
-12
@@ -1,28 +1,26 @@
|
||||
export interface SystemConfig {
|
||||
admin_mode: boolean;
|
||||
beta_mode: boolean;
|
||||
admin_mode: boolean
|
||||
beta_mode: boolean
|
||||
}
|
||||
|
||||
let configPromise: Promise<SystemConfig> | null = null;
|
||||
let cachedConfig: SystemConfig | null = null;
|
||||
let configPromise: Promise<SystemConfig> | null = null
|
||||
let cachedConfig: SystemConfig | null = null
|
||||
|
||||
export function getSystemConfig(): Promise<SystemConfig> {
|
||||
if (cachedConfig) {
|
||||
return Promise.resolve(cachedConfig);
|
||||
return Promise.resolve(cachedConfig)
|
||||
}
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
return configPromise
|
||||
}
|
||||
configPromise = fetch('/api/config')
|
||||
.then((res) => res.json())
|
||||
.then((data: SystemConfig) => {
|
||||
cachedConfig = data;
|
||||
return data;
|
||||
cachedConfig = data
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
|
||||
});
|
||||
return configPromise;
|
||||
})
|
||||
return configPromise
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@ import './index.css'
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -19,61 +19,112 @@ export function LandingPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const isLoggedIn = !!user
|
||||
|
||||
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn);
|
||||
|
||||
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn)
|
||||
return (
|
||||
<>
|
||||
<HeaderBar
|
||||
onLoginClick={() => setShowLoginModal(true)}
|
||||
isLoggedIn={isLoggedIn}
|
||||
<HeaderBar
|
||||
onLoginClick={() => setShowLoginModal(true)}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isHomePage={true}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onPageChange={(page) => {
|
||||
console.log('LandingPage onPageChange called with:', page);
|
||||
console.log('LandingPage onPageChange called with:', page)
|
||||
if (page === 'competition') {
|
||||
window.location.href = '/competition';
|
||||
window.location.href = '/competition'
|
||||
} else if (page === 'traders') {
|
||||
window.location.href = '/traders';
|
||||
window.location.href = '/traders'
|
||||
} else if (page === 'trader') {
|
||||
window.location.href = '/dashboard';
|
||||
window.location.href = '/dashboard'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='min-h-screen px-4 sm:px-6 lg:px-8' style={{ background: 'var(--brand-black)', color: 'var(--brand-light-gray)' }}>
|
||||
<HeroSection language={language} />
|
||||
<AboutSection language={language} />
|
||||
<FeaturesSection language={language} />
|
||||
<HowItWorksSection language={language} />
|
||||
<CommunitySection />
|
||||
<div
|
||||
className="min-h-screen px-4 sm:px-6 lg:px-8"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
>
|
||||
<HeroSection language={language} />
|
||||
<AboutSection language={language} />
|
||||
<FeaturesSection language={language} />
|
||||
<HowItWorksSection language={language} />
|
||||
<CommunitySection />
|
||||
|
||||
{/* CTA */}
|
||||
<AnimatedSection backgroundColor='var(--panel-bg)'>
|
||||
<div className='max-w-4xl mx-auto text-center'>
|
||||
<motion.h2 className='text-5xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||
{t('readyToDefine', language)}
|
||||
</motion.h2>
|
||||
<motion.p className='text-xl mb-12' style={{ color: 'var(--text-secondary)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.1 }}>
|
||||
{t('startWithCrypto', language)}
|
||||
</motion.p>
|
||||
<div className='flex flex-wrap justify-center gap-4'>
|
||||
<motion.button onClick={() => setShowLoginModal(true)} className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
{t('getStartedNow', language)}
|
||||
<motion.div animate={{ x: [0, 5, 0] }} transition={{ duration: 1.5, repeat: Infinity }}>
|
||||
<ArrowRight className='w-5 h-5' />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx/tree/dev' target='_blank' rel='noopener noreferrer' className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'transparent', color: 'var(--brand-light-gray)', border: '2px solid var(--brand-yellow)' }} whileHover={{ scale: 1.05, backgroundColor: 'rgba(240, 185, 11, 0.1)' }} whileTap={{ scale: 0.95 }}>
|
||||
{t('viewSourceCode', language)}
|
||||
</motion.a>
|
||||
{/* CTA */}
|
||||
<AnimatedSection backgroundColor="var(--panel-bg)">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<motion.h2
|
||||
className="text-5xl font-bold mb-6"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{t('readyToDefine', language)}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className="text-xl mb-12"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{t('startWithCrypto', language)}
|
||||
</motion.p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<motion.button
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{t('getStartedNow', language)}
|
||||
<motion.div
|
||||
animate={{ x: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
<motion.a
|
||||
href="https://github.com/tinkle-community/nofx/tree/dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '2px solid var(--brand-yellow)',
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
backgroundColor: 'rgba(240, 185, 11, 0.1)',
|
||||
}}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{t('viewSourceCode', language)}
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
</AnimatedSection>
|
||||
|
||||
{showLoginModal && <LoginModal onClose={() => setShowLoginModal(false)} language={language} />}
|
||||
<FooterSection language={language} />
|
||||
{showLoginModal && (
|
||||
<LoginModal
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
<FooterSection language={language} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
+146
-146
@@ -1,204 +1,204 @@
|
||||
export interface SystemStatus {
|
||||
trader_id: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
is_running: boolean;
|
||||
start_time: string;
|
||||
runtime_minutes: number;
|
||||
call_count: number;
|
||||
initial_balance: number;
|
||||
scan_interval: string;
|
||||
stop_until: string;
|
||||
last_reset_time: string;
|
||||
ai_provider: string;
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
is_running: boolean
|
||||
start_time: string
|
||||
runtime_minutes: number
|
||||
call_count: number
|
||||
initial_balance: number
|
||||
scan_interval: string
|
||||
stop_until: string
|
||||
last_reset_time: string
|
||||
ai_provider: string
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
total_equity: number;
|
||||
wallet_balance: number;
|
||||
unrealized_profit: number;
|
||||
available_balance: number;
|
||||
total_pnl: number;
|
||||
total_pnl_pct: number;
|
||||
total_unrealized_pnl: number;
|
||||
initial_balance: number;
|
||||
daily_pnl: number;
|
||||
position_count: number;
|
||||
margin_used: number;
|
||||
margin_used_pct: number;
|
||||
total_equity: number
|
||||
wallet_balance: number
|
||||
unrealized_profit: number
|
||||
available_balance: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
total_unrealized_pnl: number
|
||||
initial_balance: number
|
||||
daily_pnl: number
|
||||
position_count: number
|
||||
margin_used: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
side: string;
|
||||
entry_price: number;
|
||||
mark_price: number;
|
||||
quantity: number;
|
||||
leverage: number;
|
||||
unrealized_pnl: number;
|
||||
unrealized_pnl_pct: number;
|
||||
liquidation_price: number;
|
||||
margin_used: number;
|
||||
symbol: string
|
||||
side: string
|
||||
entry_price: number
|
||||
mark_price: number
|
||||
quantity: number
|
||||
leverage: number
|
||||
unrealized_pnl: number
|
||||
unrealized_pnl_pct: number
|
||||
liquidation_price: number
|
||||
margin_used: number
|
||||
}
|
||||
|
||||
export interface DecisionAction {
|
||||
action: string;
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
leverage: number;
|
||||
price: number;
|
||||
order_id: number;
|
||||
timestamp: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
action: string
|
||||
symbol: string
|
||||
quantity: number
|
||||
leverage: number
|
||||
price: number
|
||||
order_id: number
|
||||
timestamp: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AccountSnapshot {
|
||||
total_balance: number;
|
||||
available_balance: number;
|
||||
total_unrealized_profit: number;
|
||||
position_count: number;
|
||||
margin_used_pct: number;
|
||||
total_balance: number
|
||||
available_balance: number
|
||||
total_unrealized_profit: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
|
||||
export interface DecisionRecord {
|
||||
timestamp: string;
|
||||
cycle_number: number;
|
||||
input_prompt: string;
|
||||
cot_trace: string;
|
||||
decision_json: string;
|
||||
account_state: AccountSnapshot;
|
||||
positions: any[];
|
||||
candidate_coins: string[];
|
||||
decisions: DecisionAction[];
|
||||
execution_log: string[];
|
||||
success: boolean;
|
||||
error_message?: string;
|
||||
timestamp: string
|
||||
cycle_number: number
|
||||
input_prompt: string
|
||||
cot_trace: string
|
||||
decision_json: string
|
||||
account_state: AccountSnapshot
|
||||
positions: any[]
|
||||
candidate_coins: string[]
|
||||
decisions: DecisionAction[]
|
||||
execution_log: string[]
|
||||
success: boolean
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface Statistics {
|
||||
total_cycles: number;
|
||||
successful_cycles: number;
|
||||
failed_cycles: number;
|
||||
total_open_positions: number;
|
||||
total_close_positions: number;
|
||||
total_cycles: number
|
||||
successful_cycles: number
|
||||
failed_cycles: number
|
||||
total_open_positions: number
|
||||
total_close_positions: number
|
||||
}
|
||||
|
||||
// AI Trading相关类型
|
||||
export interface TraderInfo {
|
||||
trader_id: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange_id?: string;
|
||||
is_running?: boolean;
|
||||
custom_prompt?: string;
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id?: string
|
||||
is_running?: boolean
|
||||
custom_prompt?: string
|
||||
}
|
||||
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
customApiUrl?: string;
|
||||
customModelName?: string;
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
customApiUrl?: string
|
||||
customModelName?: string
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'cex' | 'dex';
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
secretKey?: string;
|
||||
testnet?: boolean;
|
||||
id: string
|
||||
name: string
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
testnet?: boolean
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquidWalletAddr?: string;
|
||||
hyperliquidWalletAddr?: string
|
||||
// Aster 特定字段
|
||||
asterUser?: string;
|
||||
asterSigner?: string;
|
||||
asterPrivateKey?: string;
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
asterPrivateKey?: string
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
name: string;
|
||||
ai_model_id: string;
|
||||
exchange_id: string;
|
||||
initial_balance: number;
|
||||
scan_interval_minutes?: number;
|
||||
btc_eth_leverage?: number;
|
||||
altcoin_leverage?: number;
|
||||
trading_symbols?: string;
|
||||
custom_prompt?: string;
|
||||
override_base_prompt?: boolean;
|
||||
system_prompt_template?: string;
|
||||
is_cross_margin?: boolean;
|
||||
use_coin_pool?: boolean;
|
||||
use_oi_top?: boolean;
|
||||
name: string
|
||||
ai_model_id: string
|
||||
exchange_id: string
|
||||
initial_balance: number
|
||||
scan_interval_minutes?: number
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
is_cross_margin?: boolean
|
||||
use_coin_pool?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateModelConfigRequest {
|
||||
models: {
|
||||
[key: string]: {
|
||||
enabled: boolean;
|
||||
api_key: string;
|
||||
custom_api_url?: string;
|
||||
custom_model_name?: string;
|
||||
};
|
||||
};
|
||||
enabled: boolean
|
||||
api_key: string
|
||||
custom_api_url?: string
|
||||
custom_model_name?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateExchangeConfigRequest {
|
||||
exchanges: {
|
||||
[key: string]: {
|
||||
enabled: boolean;
|
||||
api_key: string;
|
||||
secret_key: string;
|
||||
testnet?: boolean;
|
||||
enabled: boolean
|
||||
api_key: string
|
||||
secret_key: string
|
||||
testnet?: boolean
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquid_wallet_addr?: string;
|
||||
hyperliquid_wallet_addr?: string
|
||||
// Aster 特定字段
|
||||
aster_user?: string;
|
||||
aster_signer?: string;
|
||||
aster_private_key?: string;
|
||||
};
|
||||
};
|
||||
aster_user?: string
|
||||
aster_signer?: string
|
||||
aster_private_key?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Competition related types
|
||||
export interface CompetitionTraderData {
|
||||
trader_id: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange: string;
|
||||
total_equity: number;
|
||||
total_pnl: number;
|
||||
total_pnl_pct: number;
|
||||
position_count: number;
|
||||
margin_used_pct: number;
|
||||
is_running: boolean;
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange: string
|
||||
total_equity: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
is_running: boolean
|
||||
}
|
||||
|
||||
export interface CompetitionData {
|
||||
traders: CompetitionTraderData[];
|
||||
count: number;
|
||||
traders: CompetitionTraderData[]
|
||||
count: number
|
||||
}
|
||||
|
||||
// Trader Configuration Data for View Modal
|
||||
export interface TraderConfigData {
|
||||
trader_id?: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange_id: string;
|
||||
btc_eth_leverage: number;
|
||||
altcoin_leverage: number;
|
||||
trading_symbols: string;
|
||||
custom_prompt: string;
|
||||
override_base_prompt: boolean;
|
||||
is_cross_margin: boolean;
|
||||
use_coin_pool: boolean;
|
||||
use_oi_top: boolean;
|
||||
initial_balance: number;
|
||||
scan_interval_minutes: number;
|
||||
is_running: boolean;
|
||||
trader_id?: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
btc_eth_leverage: number
|
||||
altcoin_leverage: number
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
initial_balance: number
|
||||
scan_interval_minutes: number
|
||||
is_running: boolean
|
||||
}
|
||||
|
||||
+68
-68
@@ -1,93 +1,93 @@
|
||||
// 系统状态
|
||||
export interface SystemStatus {
|
||||
is_running: boolean;
|
||||
start_time: string;
|
||||
runtime_minutes: number;
|
||||
call_count: number;
|
||||
initial_balance: number;
|
||||
scan_interval: string;
|
||||
stop_until: string;
|
||||
last_reset_time: string;
|
||||
ai_provider: string;
|
||||
is_running: boolean
|
||||
start_time: string
|
||||
runtime_minutes: number
|
||||
call_count: number
|
||||
initial_balance: number
|
||||
scan_interval: string
|
||||
stop_until: string
|
||||
last_reset_time: string
|
||||
ai_provider: string
|
||||
}
|
||||
|
||||
// 账户信息
|
||||
export interface AccountInfo {
|
||||
total_equity: number;
|
||||
available_balance: number;
|
||||
total_pnl: number;
|
||||
total_pnl_pct: number;
|
||||
total_unrealized_pnl: number;
|
||||
margin_used: number;
|
||||
margin_used_pct: number;
|
||||
position_count: number;
|
||||
initial_balance: number;
|
||||
daily_pnl: number;
|
||||
total_equity: number
|
||||
available_balance: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
total_unrealized_pnl: number
|
||||
margin_used: number
|
||||
margin_used_pct: number
|
||||
position_count: number
|
||||
initial_balance: number
|
||||
daily_pnl: number
|
||||
}
|
||||
|
||||
// 持仓信息
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
side: string;
|
||||
entry_price: number;
|
||||
mark_price: number;
|
||||
quantity: number;
|
||||
leverage: number;
|
||||
unrealized_pnl: number;
|
||||
unrealized_pnl_pct: number;
|
||||
liquidation_price: number;
|
||||
margin_used: number;
|
||||
symbol: string
|
||||
side: string
|
||||
entry_price: number
|
||||
mark_price: number
|
||||
quantity: number
|
||||
leverage: number
|
||||
unrealized_pnl: number
|
||||
unrealized_pnl_pct: number
|
||||
liquidation_price: number
|
||||
margin_used: number
|
||||
}
|
||||
|
||||
// 决策动作
|
||||
export interface DecisionAction {
|
||||
action: string;
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
leverage: number;
|
||||
price: number;
|
||||
order_id: number;
|
||||
timestamp: string;
|
||||
success: boolean;
|
||||
error: string;
|
||||
action: string
|
||||
symbol: string
|
||||
quantity: number
|
||||
leverage: number
|
||||
price: number
|
||||
order_id: number
|
||||
timestamp: string
|
||||
success: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
// 决策记录
|
||||
export interface DecisionRecord {
|
||||
timestamp: string;
|
||||
cycle_number: number;
|
||||
input_prompt: string;
|
||||
cot_trace: string;
|
||||
decision_json: string;
|
||||
timestamp: string
|
||||
cycle_number: number
|
||||
input_prompt: string
|
||||
cot_trace: string
|
||||
decision_json: string
|
||||
account_state: {
|
||||
total_balance: number;
|
||||
available_balance: number;
|
||||
total_unrealized_profit: number;
|
||||
position_count: number;
|
||||
margin_used_pct: number;
|
||||
};
|
||||
total_balance: number
|
||||
available_balance: number
|
||||
total_unrealized_profit: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
positions: Array<{
|
||||
symbol: string;
|
||||
side: string;
|
||||
position_amt: number;
|
||||
entry_price: number;
|
||||
mark_price: number;
|
||||
unrealized_profit: number;
|
||||
leverage: number;
|
||||
liquidation_price: number;
|
||||
}>;
|
||||
candidate_coins: string[];
|
||||
decisions: DecisionAction[];
|
||||
execution_log: string[];
|
||||
success: boolean;
|
||||
error_message: string;
|
||||
symbol: string
|
||||
side: string
|
||||
position_amt: number
|
||||
entry_price: number
|
||||
mark_price: number
|
||||
unrealized_profit: number
|
||||
leverage: number
|
||||
liquidation_price: number
|
||||
}>
|
||||
candidate_coins: string[]
|
||||
decisions: DecisionAction[]
|
||||
execution_log: string[]
|
||||
success: boolean
|
||||
error_message: string
|
||||
}
|
||||
|
||||
// 统计信息
|
||||
export interface Statistics {
|
||||
total_cycles: number;
|
||||
successful_cycles: number;
|
||||
failed_cycles: number;
|
||||
total_open_positions: number;
|
||||
total_close_positions: number;
|
||||
total_cycles: number
|
||||
successful_cycles: number
|
||||
failed_cycles: number
|
||||
total_open_positions: number
|
||||
total_close_positions: number
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const TRADER_COLORS = [
|
||||
'#a78bfa', // violet-400
|
||||
'#4ade80', // green-400
|
||||
'#fb7185', // rose-400
|
||||
];
|
||||
]
|
||||
|
||||
/**
|
||||
* 根据trader的索引位置获取颜色
|
||||
@@ -24,8 +24,8 @@ export function getTraderColor(
|
||||
traders: Array<{ trader_id: string }>,
|
||||
traderId: string
|
||||
): string {
|
||||
const traderIndex = traders.findIndex((t) => t.trader_id === traderId);
|
||||
if (traderIndex === -1) return TRADER_COLORS[0]; // 默认返回第一个颜色
|
||||
const traderIndex = traders.findIndex((t) => t.trader_id === traderId)
|
||||
if (traderIndex === -1) return TRADER_COLORS[0] // 默认返回第一个颜色
|
||||
// 如果超出颜色池大小,循环使用
|
||||
return TRADER_COLORS[traderIndex % TRADER_COLORS.length];
|
||||
return TRADER_COLORS[traderIndex % TRADER_COLORS.length]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user