mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
feat: implement hybrid database architecture and frontend encryption
- Add PostgreSQL + SQLite hybrid database support with automatic switching - Implement frontend AES-GCM + RSA-OAEP encryption for sensitive data - Add comprehensive DatabaseInterface with all required methods - Fix compilation issues with interface consistency - Update all database method signatures to use DatabaseInterface - Add missing UpdateTraderInitialBalance method to PostgreSQL implementation - Integrate RSA public key distribution via /api/config endpoint - Add frontend crypto service with proper error handling - Support graceful degradation between encrypted and plaintext transmission - Add directory creation for RSA keys and PEM parsing fixes - Test both SQLite and PostgreSQL modes successfully 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# CODEOWNERS
|
||||
#
|
||||
# This file defines code ownership and automatic reviewer assignment.
|
||||
# When a PR touches files matching these patterns, the listed users/teams
|
||||
# will be automatically requested for review.
|
||||
#
|
||||
# 此文件定义代码所有权和自动 reviewer 分配。
|
||||
# 当 PR 涉及匹配这些模式的文件时,列出的用户/团队将自动被请求审查。
|
||||
#
|
||||
# Syntax | 语法:
|
||||
# pattern @username @org/team-name
|
||||
#
|
||||
# More specific patterns override less specific ones
|
||||
# 更具体的模式会覆盖不太具体的模式
|
||||
#
|
||||
# Documentation: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
# =============================================================================
|
||||
# Global Owners | 全局所有者
|
||||
# These users will be requested for review on ALL pull requests
|
||||
# 这些用户将被请求审查所有 PR
|
||||
# =============================================================================
|
||||
|
||||
* @hzb1115 @Icyoung @tangmengqiu @xqliu @SkywalkerJi
|
||||
|
||||
# =============================================================================
|
||||
# Specific Component Owners | 特定组件所有者
|
||||
# Additional reviewers based on file paths (in addition to global owners)
|
||||
# 基于文件路径的额外 reviewers(在全局 owners 之外)
|
||||
# =============================================================================
|
||||
|
||||
# Backend / Go Code | 后端 / Go 代码
|
||||
# Go files and backend logic
|
||||
*.go @xqliu @SkywalkerJi @hzb1115 @Icyoung @tangmengqiu
|
||||
go.mod @xqliu @SkywalkerJi @hzb1115 @Icyoung @tangmengqiu
|
||||
go.sum @xqliu @SkywalkerJi @hzb1115 @Icyoung @tangmengqiu
|
||||
|
||||
|
||||
# Frontend / Web | 前端 / Web
|
||||
# React/TypeScript frontend code
|
||||
/web/ @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
/web/src/ @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.tsx @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.ts @0xEmberZz @hzb1115 @xqliu @tangmengqiu (frontend TypeScript only)
|
||||
*.jsx @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.css @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.scss @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
|
||||
# Configuration Files | 配置文件
|
||||
*.json @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.yaml @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.yml @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.toml @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
*.ini @0xEmberZz @hzb1115 @xqliu @tangmengqiu
|
||||
|
||||
# Documentation | 文档
|
||||
# Markdown and documentation files
|
||||
*.md @hzb1115 @tangmengqiu
|
||||
/docs/ @hzb1115 @tangmengqiu
|
||||
README.md @hzb1115 @tangmengqiu
|
||||
|
||||
# GitHub Workflows & Actions | GitHub 工作流和 Actions
|
||||
# CI/CD configuration and automation
|
||||
/.github/ @hzb1115
|
||||
/.github/workflows/ @hzb1115
|
||||
/.github/workflows/*.yml @hzb1115
|
||||
|
||||
# Docker | Docker 配置
|
||||
Dockerfile @tangmengqiu
|
||||
docker-compose.yml @tangmengqiu
|
||||
.dockerignore @tangmengqiu
|
||||
|
||||
# Database | 数据库
|
||||
# Database migrations and schemas
|
||||
/migrations/ @SkywalkerJi @hzb1115 @Icyoung @tangmengqiu
|
||||
/db/ @SkywalkerJi @hzb1115 @Icyoung @tangmengqiu
|
||||
*.sql @SkywalkerJi @hzb1115 @Icyoung @tangmengqiu
|
||||
|
||||
# Scripts | 脚本
|
||||
/scripts/ @hzb1115 @xqliu @tangmengqiu
|
||||
*.sh @hzb1115 @xqliu @tangmengqiu
|
||||
*.bash @hzb1115 @tangmengqiu
|
||||
*.py @hzb1115 @tangmengqiu (if Python scripts exist)
|
||||
|
||||
# Tests | 测试
|
||||
# Test files require review from component owners
|
||||
*_test.go @xqliu @SkywalkerJi @heronsbillC
|
||||
/tests/ @xqliu @SkywalkerJi @Icyoung @heronsbillC
|
||||
/web/tests/ @Icyoung @hzb1115 @heronsbillC
|
||||
|
||||
# Security & Dependencies | 安全和依赖
|
||||
# Security-sensitive files require extra attention
|
||||
.env.example @hzb1115 @tangmengqiu
|
||||
.gitignore @hzb1115 @tangmengqiu
|
||||
go.sum @xqliu @hzb1115 @tangmengqiu
|
||||
package-lock.json @Icyoung @hzb1115 @tangmengqiu
|
||||
yarn.lock @Icyoung @hzb1115 @tangmengqiu
|
||||
|
||||
# Build Configuration | 构建配置
|
||||
Makefile @hzb1115 @xqliu @tangmengqiu
|
||||
/build/ @hzb1115 @xqliu @tangmengqiu
|
||||
/dist/ @hzb1115 @tangmengqiu
|
||||
|
||||
# License & Legal | 许可证和法律文件
|
||||
LICENSE @hzb1115
|
||||
COPYING @hzb1115
|
||||
|
||||
# =============================================================================
|
||||
# Notes | 注意事项
|
||||
# =============================================================================
|
||||
#
|
||||
# 1. All PRs will be assigned to the 5 global owners
|
||||
# 所有 PR 都会分配给这 5 个全局 owners
|
||||
#
|
||||
# 2. Specific paths may add additional reviewers
|
||||
# 特定路径可能会添加额外的 reviewers
|
||||
#
|
||||
# 3. PR author will NOT be requested for review (GitHub handles this)
|
||||
# PR 作者不会被请求审查(GitHub 自动处理)
|
||||
#
|
||||
# 4. You can adjust patterns and owners as needed
|
||||
# 你可以根据需要调整模式和 owners
|
||||
#
|
||||
# 5. To require multiple approvals, configure branch protection rules
|
||||
# 要求多个批准,请配置分支保护规则
|
||||
#
|
||||
# ⚠️ IMPORTANT - Permission Requirements | 重要 - 权限要求:
|
||||
# - Users listed here will ONLY be auto-requested if they have Write+ permission
|
||||
# 这里列出的用户只有在拥有 Write 或以上权限时才会被自动请求
|
||||
# - GitHub will silently skip users without proper permissions
|
||||
# GitHub 会静默跳过没有适当权限的用户
|
||||
# - See CODEOWNERS_PERMISSIONS.md for details
|
||||
# 详见 CODEOWNERS_PERMISSIONS.md
|
||||
#
|
||||
# =============================================================================
|
||||
@@ -82,7 +82,7 @@ By claiming this bounty, I acknowledge that:
|
||||
- [ ] I have read the [Contributing Guide](../../CONTRIBUTING.md)
|
||||
- [ ] I will follow the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] I understand the acceptance criteria
|
||||
- [ ] My contribution will be licensed under MIT License
|
||||
- [ ] My contribution will be licensed under AGPL-3.0 License
|
||||
- [ ] Payment is subject to successful PR merge
|
||||
|
||||
---
|
||||
|
||||
@@ -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 MIT License | 我的贡献遵循 MIT 许可证
|
||||
- [ ] 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,95 @@
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY_GHCR: ghcr.io
|
||||
IMAGE_NAME_BACKEND: ${{ github.repository }}/nofx-backend
|
||||
IMAGE_NAME_FRONTEND: ${{ github.repository }}/nofx-frontend
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: backend
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
image_suffix: backend
|
||||
- name: frontend
|
||||
dockerfile: ./docker/Dockerfile.frontend
|
||||
image_suffix: frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_GHCR }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY_GHCR }}/${{ github.repository }}/nofx-${{ matrix.image_suffix }}
|
||||
${{ secrets.DOCKERHUB_USERNAME && format('{0}/nofx-{1}', secrets.DOCKERHUB_USERNAME, matrix.image_suffix) || '' }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push ${{ matrix.name }} image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
VERSION=${{ github.ref_name }}
|
||||
|
||||
- name: Image digest
|
||||
run: echo "Image digest for ${{ matrix.name }} - ${{ steps.meta.outputs.digest }}"
|
||||
@@ -104,6 +104,53 @@ jobs:
|
||||
echo "⚠️ Frontend results artifact not found"
|
||||
fi
|
||||
|
||||
- name: Get PR information
|
||||
id: pr-info
|
||||
if: steps.backend.outputs.pr_number != '0'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = ${{ steps.backend.outputs.pr_number }};
|
||||
|
||||
// Get PR details
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Check PR title format (Conventional Commits)
|
||||
const prTitle = pr.title;
|
||||
const conventionalCommitPattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|security|build)(\(.+\))?: .+/;
|
||||
const titleValid = conventionalCommitPattern.test(prTitle);
|
||||
|
||||
core.setOutput('pr_title', prTitle);
|
||||
core.setOutput('title_valid', titleValid);
|
||||
|
||||
// Calculate PR size
|
||||
const additions = pr.additions;
|
||||
const deletions = pr.deletions;
|
||||
const total = additions + deletions;
|
||||
|
||||
let size = '';
|
||||
let sizeEmoji = '';
|
||||
if (total < 300) {
|
||||
size = 'Small';
|
||||
sizeEmoji = '🟢';
|
||||
} else if (total < 1000) {
|
||||
size = 'Medium';
|
||||
sizeEmoji = '🟡';
|
||||
} else {
|
||||
size = 'Large';
|
||||
sizeEmoji = '🔴';
|
||||
}
|
||||
|
||||
core.setOutput('pr_size', size);
|
||||
core.setOutput('size_emoji', sizeEmoji);
|
||||
core.setOutput('total_lines', total);
|
||||
core.setOutput('additions', additions);
|
||||
core.setOutput('deletions', deletions);
|
||||
|
||||
- name: Post advisory results comment
|
||||
if: steps.backend.outputs.pr_number != '0'
|
||||
uses: actions/github-script@v7
|
||||
@@ -113,7 +160,40 @@ jobs:
|
||||
|
||||
let comment = '## 🤖 Advisory Check Results\n\n';
|
||||
comment += 'These are **advisory** checks to help improve code quality. They won\'t block your PR from being merged.\n\n';
|
||||
comment += '> **Note:** PR title and size checks are handled by the main workflow and may appear in a separate comment.\n\n';
|
||||
|
||||
// PR Information section
|
||||
const prTitle = '${{ steps.pr-info.outputs.pr_title }}';
|
||||
const titleValid = '${{ steps.pr-info.outputs.title_valid }}' === 'true';
|
||||
const prSize = '${{ steps.pr-info.outputs.pr_size }}';
|
||||
const sizeEmoji = '${{ steps.pr-info.outputs.size_emoji }}';
|
||||
const totalLines = '${{ steps.pr-info.outputs.total_lines }}';
|
||||
const additions = '${{ steps.pr-info.outputs.additions }}';
|
||||
const deletions = '${{ steps.pr-info.outputs.deletions }}';
|
||||
|
||||
comment += '### 📋 PR Information\n\n';
|
||||
|
||||
// Title check
|
||||
if (titleValid) {
|
||||
comment += '**Title Format:** ✅ Good - Follows Conventional Commits\n';
|
||||
} else {
|
||||
comment += '**Title Format:** ⚠️ Suggestion - Consider using `type(scope): description`\n';
|
||||
comment += '<details><summary>Recommended format</summary>\n\n';
|
||||
comment += '**Valid types:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `security`, `build`\n\n';
|
||||
comment += '**Examples:**\n';
|
||||
comment += '- `feat(trader): add new trading strategy`\n';
|
||||
comment += '- `fix(api): resolve authentication issue`\n';
|
||||
comment += '- `docs: update README`\n';
|
||||
comment += '</details>\n\n';
|
||||
}
|
||||
|
||||
// Size check
|
||||
comment += `**PR Size:** ${sizeEmoji} ${prSize} (${totalLines} lines: +${additions} -${deletions})\n`;
|
||||
|
||||
if (prSize === 'Large') {
|
||||
comment += '\n💡 **Suggestion:** This is a large PR. Consider breaking it into smaller, focused PRs for easier review.\n';
|
||||
}
|
||||
|
||||
comment += '\n';
|
||||
|
||||
// Backend checks
|
||||
const fmtStatus = '${{ steps.backend.outputs.fmt_status }}';
|
||||
@@ -208,37 +288,71 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = pulls.data[0].number;
|
||||
const pr = pulls.data[0];
|
||||
const prNumber = pr.number;
|
||||
|
||||
const comment = [
|
||||
'## ⚠️ Advisory Checks - Results Unavailable',
|
||||
'',
|
||||
'The advisory checks workflow completed, but results could not be retrieved.',
|
||||
'',
|
||||
'### Possible reasons:',
|
||||
'- Artifacts were not uploaded successfully',
|
||||
'- Artifacts expired (retention: 1 day)',
|
||||
'- Permission issues',
|
||||
'',
|
||||
'### What to do:',
|
||||
'1. Check the [PR Checks - Run workflow](${{ github.event.workflow_run.html_url }}) logs',
|
||||
'2. Ensure your code passes local checks:',
|
||||
'```bash',
|
||||
'# Backend',
|
||||
'go fmt ./...',
|
||||
'go vet ./...',
|
||||
'go build',
|
||||
'go test ./...',
|
||||
'',
|
||||
'# Frontend (if applicable)',
|
||||
'cd web',
|
||||
'npm run build',
|
||||
'```',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'*This is an automated fallback message. The advisory checks ran but results are not available.*'
|
||||
].join('\n');
|
||||
// Get PR information for fallback comment
|
||||
const prTitle = pr.title;
|
||||
const conventionalCommitPattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|security|build)(\(.+\))?: .+/;
|
||||
const titleValid = conventionalCommitPattern.test(prTitle);
|
||||
|
||||
const additions = pr.additions || 0;
|
||||
const deletions = pr.deletions || 0;
|
||||
const total = additions + deletions;
|
||||
|
||||
let size = '';
|
||||
let sizeEmoji = '';
|
||||
if (total < 300) {
|
||||
size = 'Small';
|
||||
sizeEmoji = '🟢';
|
||||
} else if (total < 1000) {
|
||||
size = 'Medium';
|
||||
sizeEmoji = '🟡';
|
||||
} else {
|
||||
size = 'Large';
|
||||
sizeEmoji = '🔴';
|
||||
}
|
||||
|
||||
let comment = '## ⚠️ Advisory Checks - Results Unavailable\n\n';
|
||||
comment += 'The advisory checks workflow completed, but results could not be retrieved.\n\n';
|
||||
|
||||
// Add PR Information
|
||||
comment += '### 📋 PR Information\n\n';
|
||||
|
||||
if (titleValid) {
|
||||
comment += '**Title Format:** ✅ Good - Follows Conventional Commits\n';
|
||||
} else {
|
||||
comment += '**Title Format:** ⚠️ Suggestion - Consider using `type(scope): description`\n';
|
||||
}
|
||||
|
||||
comment += `**PR Size:** ${sizeEmoji} ${size} (${total} lines: +${additions} -${deletions})\n\n`;
|
||||
|
||||
if (size === 'Large') {
|
||||
comment += '💡 **Suggestion:** This is a large PR. Consider breaking it into smaller, focused PRs for easier review.\n\n';
|
||||
}
|
||||
|
||||
comment += '---\n\n';
|
||||
comment += '### ⚠️ Backend/Frontend Check Results\n\n';
|
||||
comment += 'Results could not be retrieved.\n\n';
|
||||
comment += '**Possible reasons:**\n';
|
||||
comment += '- Artifacts were not uploaded successfully\n';
|
||||
comment += '- Artifacts expired (retention: 1 day)\n';
|
||||
comment += '- Permission issues\n\n';
|
||||
comment += '**What to do:**\n';
|
||||
comment += `1. Check the [PR Checks - Run workflow](${context.payload.workflow_run?.html_url || 'logs'}) logs\n`;
|
||||
comment += '2. Ensure your code passes local checks:\n';
|
||||
comment += '```bash\n';
|
||||
comment += '# Backend\n';
|
||||
comment += 'go fmt ./...\n';
|
||||
comment += 'go vet ./...\n';
|
||||
comment += 'go build\n';
|
||||
comment += 'go test ./...\n\n';
|
||||
comment += '# Frontend (if applicable)\n';
|
||||
comment += 'cd web\n';
|
||||
comment += 'npm run build\n';
|
||||
comment += '```\n\n';
|
||||
comment += '---\n\n';
|
||||
comment += '*This is an automated fallback message. The advisory checks ran but results are not available.*';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: prNumber,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,472 @@
|
||||
# 🐳 Dockerワンクリックデプロイガイド
|
||||
|
||||
このガイドは、Dockerを使用してNOFX AIトレーディング競争システムを迅速にデプロイする方法を説明します。
|
||||
|
||||
## 📋 前提条件
|
||||
|
||||
開始する前に、システムに以下が必要です:
|
||||
|
||||
- **Docker**: バージョン20.10以上
|
||||
- **Docker Compose**: バージョン2.0以上
|
||||
|
||||
### Dockerのインストール
|
||||
|
||||
#### macOS / Windows
|
||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/)をダウンロードしてインストール
|
||||
|
||||
#### Linux (Ubuntu/Debian)
|
||||
|
||||
> #### Docker Composeバージョンに関する注意
|
||||
>
|
||||
> **新規ユーザー推奨:**
|
||||
> - **Docker Desktopを使用**: 最新のDocker Composeが自動的に含まれ、別途インストールは不要
|
||||
> - シンプルなインストール、ワンクリックセットアップ、GUI管理を提供
|
||||
> - macOS、Windows、一部のLinuxディストリビューションをサポート
|
||||
>
|
||||
> **既存ユーザー向け注意:**
|
||||
> - **スタンドアロンdocker-composeの非推奨**: 独立したDocker Composeバイナリのダウンロードは推奨されません
|
||||
> - **組み込みバージョンを使用**: Docker 20.10+には`docker compose`コマンド(スペース付き)が含まれています
|
||||
> - 古い`docker-compose`をまだ使用している場合は、新しい構文にアップグレードしてください
|
||||
|
||||
*推奨:Docker Desktop(利用可能な場合)またはCompose組み込みのDocker CEを使用*
|
||||
|
||||
```bash
|
||||
# Dockerをインストール(composeを含む)
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# dockerグループにユーザーを追加
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# インストールを確認(新しいコマンド)
|
||||
docker --version
|
||||
docker compose --version # Docker 24+にはこれが含まれており、別途インストール不要
|
||||
```
|
||||
|
||||
## 🚀 クイックスタート(3ステップ)
|
||||
|
||||
### ステップ1:設定ファイルを準備
|
||||
|
||||
```bash
|
||||
# 設定テンプレートをコピー
|
||||
cp config.json.example config.json
|
||||
|
||||
# APIキーで設定ファイルを編集
|
||||
nano config.json # または他のエディタを使用
|
||||
```
|
||||
|
||||
**必須フィールド:**
|
||||
```json
|
||||
{
|
||||
"traders": [
|
||||
{
|
||||
"id": "my_trader",
|
||||
"name": "My AI Trader",
|
||||
"ai_model": "deepseek",
|
||||
"binance_api_key": "YOUR_BINANCE_API_KEY", // ← BinanceのAPIキー
|
||||
"binance_secret_key": "YOUR_BINANCE_SECRET_KEY", // ← Binanceのシークレットキー
|
||||
"deepseek_key": "YOUR_DEEPSEEK_API_KEY", // ← DeepSeekのAPIキー
|
||||
"initial_balance": 1000.0,
|
||||
"scan_interval_minutes": 3
|
||||
}
|
||||
],
|
||||
"use_default_coins": true,
|
||||
"api_server_port": 8080
|
||||
}
|
||||
```
|
||||
|
||||
### ステップ2:ワンクリック起動
|
||||
|
||||
```bash
|
||||
# すべてのサービスをビルドして起動(初回実行)
|
||||
docker compose up -d --build
|
||||
|
||||
# 以降の起動(リビルドなし)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**起動オプション:**
|
||||
- `--build`: Dockerイメージをビルド(初回実行またはコード更新後に使用)
|
||||
- `-d`: デタッチモードで実行(バックグラウンド)
|
||||
|
||||
### ステップ3:システムにアクセス
|
||||
|
||||
デプロイが完了したら、ブラウザを開いて以下にアクセス:
|
||||
|
||||
- **Webインターフェース**: http://localhost:3000
|
||||
- **APIヘルスチェック**: http://localhost:8080/health
|
||||
|
||||
## 📊 サービス管理
|
||||
|
||||
### 実行状態を表示
|
||||
|
||||
```bash
|
||||
# すべてのコンテナステータスを表示
|
||||
docker compose ps
|
||||
|
||||
# サービスヘルスステータスを表示
|
||||
docker compose ps --format json | jq
|
||||
```
|
||||
|
||||
### ログを表示
|
||||
|
||||
```bash
|
||||
# すべてのサービスログを表示
|
||||
docker compose logs -f
|
||||
|
||||
# バックエンドログのみを表示
|
||||
docker compose logs -f backend
|
||||
|
||||
# フロントエンドログのみを表示
|
||||
docker compose logs -f frontend
|
||||
|
||||
# 最後の100行を表示
|
||||
docker compose logs --tail=100
|
||||
```
|
||||
|
||||
### サービスを停止
|
||||
|
||||
```bash
|
||||
# すべてのサービスを停止(データを保持)
|
||||
docker compose stop
|
||||
|
||||
# コンテナを停止して削除(データを保持)
|
||||
docker compose down
|
||||
|
||||
# コンテナとボリュームを停止して削除(すべてのデータをクリア)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### サービスを再起動
|
||||
|
||||
```bash
|
||||
# すべてのサービスを再起動
|
||||
docker compose restart
|
||||
|
||||
# バックエンドのみを再起動
|
||||
docker compose restart backend
|
||||
|
||||
# フロントエンドのみを再起動
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
### サービスを更新
|
||||
|
||||
```bash
|
||||
# 最新のコードをプル
|
||||
git pull
|
||||
|
||||
# リビルドして再起動
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## 🔧 高度な設定
|
||||
|
||||
### ポートを変更
|
||||
|
||||
`docker-compose.yml`を編集してポートマッピングを変更:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "8080:8080" # "your_port:8080"に変更
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "3000:80" # "your_port:80"に変更
|
||||
```
|
||||
|
||||
### リソース制限
|
||||
|
||||
`docker-compose.yml`にリソース制限を追加:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
```
|
||||
|
||||
### 環境変数
|
||||
|
||||
`.env`ファイルを作成して環境変数を管理:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
TZ=Asia/Tokyo
|
||||
BACKEND_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
```
|
||||
|
||||
次に`docker-compose.yml`で使用:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8080"
|
||||
```
|
||||
|
||||
## 📁 データの永続化
|
||||
|
||||
システムは自動的にデータをローカルディレクトリに永続化します:
|
||||
|
||||
- `./decision_logs/`: AI判断ログ
|
||||
- `./coin_pool_cache/`: コインプールキャッシュ
|
||||
- `./config.json`: 設定ファイル(マウント済み)
|
||||
|
||||
**データの場所:**
|
||||
```bash
|
||||
# データディレクトリを表示
|
||||
ls -la decision_logs/
|
||||
ls -la coin_pool_cache/
|
||||
|
||||
# データをバックアップ
|
||||
tar -czf backup_$(date +%Y%m%d).tar.gz decision_logs/ coin_pool_cache/ config.json
|
||||
|
||||
# データを復元
|
||||
tar -xzf backup_20241029.tar.gz
|
||||
```
|
||||
|
||||
## 🐛 トラブルシューティング
|
||||
|
||||
### コンテナが起動しない
|
||||
|
||||
```bash
|
||||
# 詳細なエラーメッセージを表示
|
||||
docker compose logs backend
|
||||
docker compose logs frontend
|
||||
|
||||
# コンテナステータスを確認
|
||||
docker compose ps -a
|
||||
|
||||
# リビルド(キャッシュをクリア)
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
### ポートが既に使用中
|
||||
|
||||
```bash
|
||||
# ポートを使用しているプロセスを検索
|
||||
lsof -i :8080 # バックエンドポート
|
||||
lsof -i :3000 # フロントエンドポート
|
||||
|
||||
# プロセスを強制終了
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### 設定ファイルが見つからない
|
||||
|
||||
```bash
|
||||
# config.jsonが存在することを確認
|
||||
ls -la config.json
|
||||
|
||||
# 存在しない場合、テンプレートをコピー
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
### ヘルスチェックが失敗
|
||||
|
||||
```bash
|
||||
# ヘルスステータスを確認
|
||||
docker inspect nofx-backend | jq '.[0].State.Health'
|
||||
docker inspect nofx-frontend | jq '.[0].State.Health'
|
||||
|
||||
# ヘルスエンドポイントを手動でテスト
|
||||
curl http://localhost:8080/health
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
### フロントエンドがバックエンドに接続できない
|
||||
|
||||
```bash
|
||||
# ネットワーク接続を確認
|
||||
docker compose exec frontend ping backend
|
||||
|
||||
# バックエンドサービスが実行中か確認
|
||||
docker compose exec frontend wget -O- http://backend:8080/health
|
||||
```
|
||||
|
||||
### Dockerリソースをクリーン
|
||||
|
||||
```bash
|
||||
# 未使用のイメージをクリーン
|
||||
docker image prune -a
|
||||
|
||||
# 未使用のボリュームをクリーン
|
||||
docker volume prune
|
||||
|
||||
# すべての未使用リソースをクリーン(注意して使用)
|
||||
docker system prune -a --volumes
|
||||
```
|
||||
|
||||
## 🔐 セキュリティ推奨事項
|
||||
|
||||
1. **config.jsonをGitにコミットしない**
|
||||
```bash
|
||||
# config.jsonが.gitignoreに含まれていることを確認
|
||||
echo "config.json" >> .gitignore
|
||||
```
|
||||
|
||||
2. **機密データには環境変数を使用**
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- BINANCE_API_KEY=${BINANCE_API_KEY}
|
||||
- BINANCE_SECRET_KEY=${BINANCE_SECRET_KEY}
|
||||
```
|
||||
|
||||
3. **APIアクセスを制限**
|
||||
```yaml
|
||||
# ローカルアクセスのみを許可
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
```
|
||||
|
||||
4. **イメージを定期的に更新**
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🌐 本番環境デプロイ
|
||||
|
||||
### Nginxリバースプロキシの使用
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/nofx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTPSの設定(Let's Encrypt)
|
||||
|
||||
```bash
|
||||
# Certbotをインストール
|
||||
sudo apt-get install certbot python3-certbot-nginx
|
||||
|
||||
# SSL証明書を取得
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
|
||||
# 自動更新
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### Docker Swarmの使用(クラスタデプロイ)
|
||||
|
||||
```bash
|
||||
# Swarmを初期化
|
||||
docker swarm init
|
||||
|
||||
# スタックをデプロイ
|
||||
docker stack deploy -c docker-compose.yml nofx
|
||||
|
||||
# サービスステータスを表示
|
||||
docker stack services nofx
|
||||
|
||||
# サービスをスケール
|
||||
docker service scale nofx_backend=3
|
||||
```
|
||||
|
||||
## 📈 監視&ロギング
|
||||
|
||||
### ログ管理
|
||||
|
||||
```bash
|
||||
# ログローテーションを設定(docker-compose.ymlで既に設定済み)
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ログ統計を表示
|
||||
docker compose logs --timestamps | wc -l
|
||||
```
|
||||
|
||||
### 監視ツール統合
|
||||
|
||||
Prometheus + Grafanaで監視を統合:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml(監視サービスを追加)
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
ports:
|
||||
- "3001:3000"
|
||||
```
|
||||
|
||||
## 🆘 ヘルプを取得
|
||||
|
||||
- **GitHub Issues**: [Issueを提出](https://github.com/yourusername/open-nofx/issues)
|
||||
- **ドキュメント**: [README.md](README.md)を確認
|
||||
- **コミュニティ**: Discord/Telegramグループに参加
|
||||
|
||||
## 📝 コマンドチートシート
|
||||
|
||||
```bash
|
||||
# 起動
|
||||
docker compose up -d --build # ビルドして起動
|
||||
docker compose up -d # 起動(リビルドなし)
|
||||
|
||||
# 停止
|
||||
docker compose stop # サービスを停止
|
||||
docker compose down # コンテナを停止して削除
|
||||
docker compose down -v # コンテナとデータを停止して削除
|
||||
|
||||
# 表示
|
||||
docker compose ps # ステータスを表示
|
||||
docker compose logs -f # ログを表示
|
||||
docker compose top # プロセスを表示
|
||||
|
||||
# 再起動
|
||||
docker compose restart # すべてのサービスを再起動
|
||||
docker compose restart backend # バックエンドを再起動
|
||||
|
||||
# 更新
|
||||
git pull && docker compose up -d --build
|
||||
|
||||
# クリーン
|
||||
docker compose down -v # すべてのデータをクリア
|
||||
docker system prune -a # Dockerリソースをクリーン
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
🎉 おめでとうございます!NOFX AIトレーディング競争システムのデプロイに成功しました!
|
||||
|
||||
問題が発生した場合は、[トラブルシューティング](#-トラブルシューティング)セクションを確認するか、Issueを提出してください。
|
||||
+1343
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](https://amber.ac)
|
||||
|
||||
**Languages:** [English](README.md) | [中文](docs/i18n/zh-CN/README.md) | [Українська](docs/i18n/uk/README.md) | [Русский](docs/i18n/ru/README.md)
|
||||
@@ -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
|
||||
@@ -1240,7 +1240,15 @@ sudo apt-get install libta-lib0-dev
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - See [LICENSE](LICENSE) file for details
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)** - See [LICENSE](LICENSE) file for details.
|
||||
|
||||
**What this means:**
|
||||
- ✅ You can use, modify, and distribute this software
|
||||
- ✅ You must disclose source code of your modifications
|
||||
- ✅ If you run a modified version on a server, you must make the source code available to users
|
||||
- ✅ All derivatives must also be licensed under AGPL-3.0
|
||||
|
||||
For commercial licensing or questions, please contact the maintainers.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+316
-16
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/decision"
|
||||
"nofx/manager"
|
||||
"nofx/trader"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -100,6 +102,9 @@ func (s *Server) setupRoutes() {
|
||||
// 需要认证的路由
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
{
|
||||
// 服务器IP查询(需要认证,用于白名单配置)
|
||||
protected.GET("/server-ip", s.handleGetServerIP)
|
||||
|
||||
// AI交易员管理
|
||||
protected.GET("/my-traders", s.handleTraderList)
|
||||
protected.GET("/traders/:id/config", s.handleGetTraderConfig)
|
||||
@@ -109,6 +114,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.POST("/traders/:id/start", s.handleStartTrader)
|
||||
protected.POST("/traders/:id/stop", s.handleStopTrader)
|
||||
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
|
||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||
|
||||
// AI模型配置
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
@@ -182,6 +188,133 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetServerIP 获取服务器IP地址(用于白名单配置)
|
||||
func (s *Server) handleGetServerIP(c *gin.Context) {
|
||||
// 尝试通过第三方API获取公网IP
|
||||
publicIP := getPublicIPFromAPI()
|
||||
|
||||
// 如果第三方API失败,从网络接口获取第一个公网IP
|
||||
if publicIP == "" {
|
||||
publicIP = getPublicIPFromInterface()
|
||||
}
|
||||
|
||||
// 如果还是没有获取到,返回错误
|
||||
if publicIP == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取公网IP地址"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"public_ip": publicIP,
|
||||
"message": "请将此IP地址添加到白名单中",
|
||||
})
|
||||
}
|
||||
|
||||
// getPublicIPFromAPI 通过第三方API获取公网IP
|
||||
func getPublicIPFromAPI() string {
|
||||
// 尝试多个公网IP查询服务
|
||||
services := []string{
|
||||
"https://api.ipify.org?format=text",
|
||||
"https://icanhazip.com",
|
||||
"https://ifconfig.me",
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
resp, err := client.Get(service)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
body := make([]byte, 128)
|
||||
n, err := resp.Body.Read(body)
|
||||
if err != nil && err.Error() != "EOF" {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body[:n]))
|
||||
// 验证是否为有效的IP地址
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getPublicIPFromInterface 从网络接口获取第一个公网IP
|
||||
func getPublicIPFromInterface() string {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
// 跳过未启用的接口和回环接口
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip == nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 只考虑IPv4地址
|
||||
if ip.To4() != nil {
|
||||
ipStr := ip.String()
|
||||
// 排除私有IP地址范围
|
||||
if !isPrivateIP(ip) {
|
||||
return ipStr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isPrivateIP 判断是否为私有IP地址
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
// 私有IP地址范围:
|
||||
// 10.0.0.0/8
|
||||
// 172.16.0.0/12
|
||||
// 192.168.0.0/16
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
for _, cidr := range privateRanges {
|
||||
_, subnet, _ := net.ParseCIDR(cidr)
|
||||
if subnet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getTraderFromQuery 从query参数获取trader
|
||||
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -343,8 +476,75 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
// 设置扫描间隔默认值
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 3 // 默认3分钟
|
||||
if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // 默认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("⚠️ 无法从余额信息中提取可用余额,使用用户输入的初始资金")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建交易员配置(数据库实体)
|
||||
@@ -354,7 +554,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 +569,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
|
||||
@@ -458,6 +658,8 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // 保持原值
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3
|
||||
}
|
||||
|
||||
// 更新交易员配置
|
||||
@@ -641,6 +843,113 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"})
|
||||
}
|
||||
|
||||
// handleSyncBalance 同步交易所余额到initial_balance(选项B:手动同步 + 选项C:智能检测)
|
||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
log.Printf("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID)
|
||||
|
||||
// 从数据库获取交易员配置(包含交易所信息)
|
||||
traderConfig, _, exchangeCfg, err := s.database.GetTraderConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "交易所未配置或未启用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建临时 trader 查询余额
|
||||
var tempTrader trader.Trader
|
||||
var createErr error
|
||||
|
||||
switch traderConfig.ExchangeID {
|
||||
case "binance":
|
||||
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||||
exchangeCfg.APIKey,
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = trader.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
exchangeCfg.AsterPrivateKey,
|
||||
)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的交易所类型"})
|
||||
return
|
||||
}
|
||||
|
||||
if createErr != nil {
|
||||
log.Printf("⚠️ 创建临时 trader 失败: %v", createErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询实际余额
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
log.Printf("⚠️ 查询交易所余额失败: %v", balanceErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("查询余额失败: %v", balanceErr)})
|
||||
return
|
||||
}
|
||||
|
||||
// 提取可用余额
|
||||
var actualBalance float64
|
||||
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
actualBalance = totalBalance
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取可用余额"})
|
||||
return
|
||||
}
|
||||
|
||||
oldBalance := traderConfig.InitialBalance
|
||||
|
||||
// ✅ 选项C:智能检测余额变化
|
||||
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
|
||||
changeType := "增加"
|
||||
if changePercent < 0 {
|
||||
changeType = "减少"
|
||||
}
|
||||
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)",
|
||||
actualBalance, oldBalance, changePercent)
|
||||
|
||||
// 更新数据库中的 initial_balance
|
||||
err = s.database.UpdateTraderInitialBalance(userID, traderID, actualBalance)
|
||||
if err != nil {
|
||||
log.Printf("❌ 更新initial_balance失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新余额失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重新加载交易员到内存
|
||||
err = s.traderManager.LoadUserTraders(s.database, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "余额同步成功",
|
||||
"old_balance": oldBalance,
|
||||
"new_balance": actualBalance,
|
||||
"change_percent": changePercent,
|
||||
"change_type": changeType,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetModelConfigs 获取AI模型配置
|
||||
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -791,19 +1100,12 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// AIModelID 应该已经是 provider(如 "deepseek"),直接使用
|
||||
// 如果是旧数据格式(如 "admin_deepseek"),提取 provider 部分
|
||||
aiModelID := trader.AIModelID
|
||||
// 兼容旧数据:如果包含下划线,提取最后一部分作为 provider
|
||||
if strings.Contains(aiModelID, "_") {
|
||||
parts := strings.Split(aiModelID, "_")
|
||||
aiModelID = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// 返回完整的 AIModelID(如 "admin_deepseek"),不要截断
|
||||
// 前端需要完整 ID 来验证模型是否存在(与 handleGetTraderConfig 保持一致)
|
||||
result = append(result, map[string]interface{}{
|
||||
"trader_id": trader.ID,
|
||||
"trader_name": trader.Name,
|
||||
"ai_model": aiModelID,
|
||||
"ai_model": trader.AIModelID, // 使用完整 ID
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"is_running": isRunning,
|
||||
"initial_balance": trader.InitialBalance,
|
||||
@@ -1363,7 +1665,6 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
|
||||
// 验证密码
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
log.Printf("DEBUG: 密码验证失败")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
@@ -1734,4 +2035,3 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
|
||||
+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 从文件加载配置
|
||||
|
||||
@@ -40,6 +40,7 @@ type DatabaseInterface interface {
|
||||
GetTraders(userID string) ([]*TraderRecord, error)
|
||||
UpdateTraderStatus(userID, id string, isRunning bool) error
|
||||
UpdateTrader(trader *TraderRecord) error
|
||||
UpdateTraderInitialBalance(userID, id string, newBalance float64) error
|
||||
UpdateTraderCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error
|
||||
DeleteTrader(userID, id string) error
|
||||
GetTraderConfig(userID, traderID string) (*TraderRecord, *AIModelConfig, *ExchangeConfig, error)
|
||||
@@ -899,6 +900,12 @@ func (d *Database) UpdateTraderCustomPrompt(userID, id string, customPrompt stri
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTraderInitialBalance 更新交易员初始余额(用于自动同步交易所实际余额)
|
||||
func (d *Database) UpdateTraderInitialBalance(userID, id string, newBalance float64) error {
|
||||
_, err := d.db.Exec(`UPDATE traders SET initial_balance = ? WHERE id = ? AND user_id = ?`, newBalance, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTrader 删除交易员
|
||||
func (d *Database) DeleteTrader(userID, id string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID)
|
||||
@@ -928,8 +935,13 @@ func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIM
|
||||
`, traderID, userID).Scan(
|
||||
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
|
||||
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
|
||||
&trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols,
|
||||
&trader.UseCoinPool, &trader.UseOITop,
|
||||
&trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.SystemPromptTemplate,
|
||||
&trader.IsCrossMargin,
|
||||
&trader.CreatedAt, &trader.UpdatedAt,
|
||||
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
|
||||
&aiModel.CustomAPIURL, &aiModel.CustomModelName,
|
||||
&aiModel.CreatedAt, &aiModel.UpdatedAt,
|
||||
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
|
||||
&exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
|
||||
|
||||
@@ -464,6 +464,12 @@ func (d *PostgreSQLDatabase) UpdateTraderCustomPrompt(userID, id string, customP
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTraderInitialBalance 更新交易员初始余额(用于自动同步交易所实际余额)
|
||||
func (d *PostgreSQLDatabase) UpdateTraderInitialBalance(userID, id string, newBalance float64) error {
|
||||
_, err := d.db.Exec(`UPDATE traders SET initial_balance = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`, newBalance, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTrader 删除交易员
|
||||
func (d *PostgreSQLDatabase) DeleteTrader(userID, id string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM traders WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExample(t *testing.T) {
|
||||
if 1+1 != 2 {
|
||||
t.Error("Math is broken")
|
||||
}
|
||||
}
|
||||
+204
-27
@@ -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
|
||||
@@ -264,9 +309,11 @@ func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage in
|
||||
sb.WriteString("# 硬约束(风险控制)\n\n")
|
||||
sb.WriteString("1. 风险回报比: 必须 ≥ 1:3(冒1%风险,赚3%+收益)\n")
|
||||
sb.WriteString("2. 最多持仓: 3个币种(质量>数量)\n")
|
||||
sb.WriteString(fmt.Sprintf("3. 单币仓位: 山寨%.0f-%.0f U(%dx杠杆) | BTC/ETH %.0f-%.0f U(%dx杠杆)\n",
|
||||
accountEquity*0.8, accountEquity*1.5, altcoinLeverage, accountEquity*5, accountEquity*10, btcEthLeverage))
|
||||
sb.WriteString("4. 保证金: 总使用率 ≤ 90%\n\n")
|
||||
sb.WriteString(fmt.Sprintf("3. 单币仓位: 山寨%.0f-%.0f U | BTC/ETH %.0f-%.0f U\n",
|
||||
accountEquity*0.8, accountEquity*1.5, accountEquity*5, accountEquity*10))
|
||||
sb.WriteString(fmt.Sprintf("4. 杠杆限制: **山寨币最大%dx杠杆** | **BTC/ETH最大%dx杠杆** (⚠️ 严格执行,不可超过)\n", altcoinLeverage, btcEthLeverage))
|
||||
sb.WriteString("5. 保证金: 总使用率 ≤ 90%\n")
|
||||
sb.WriteString("6. 开仓金额: 建议 **≥12 USDT** (交易所最小名义价值 10 USDT + 安全边际)\n\n")
|
||||
|
||||
// 3. 输出格式 - 动态生成
|
||||
sb.WriteString("#输出格式\n\n")
|
||||
@@ -430,25 +477,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 +525,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 {
|
||||
@@ -508,6 +645,9 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
"update_stop_loss": true,
|
||||
"update_take_profit": true,
|
||||
"partial_close": true,
|
||||
"hold": true,
|
||||
"wait": true,
|
||||
}
|
||||
@@ -532,6 +672,22 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
if d.PositionSizeUSD <= 0 {
|
||||
return fmt.Errorf("仓位大小必须大于0: %.2f", d.PositionSizeUSD)
|
||||
}
|
||||
|
||||
// ✅ 验证最小开仓金额(防止数量格式化为 0 的错误)
|
||||
// Binance 最小名义价值 10 USDT + 安全边际
|
||||
const minPositionSizeGeneral = 12.0 // 10 + 20% 安全边际
|
||||
const minPositionSizeBTCETH = 60.0 // BTC/ETH 因价格高和精度限制需要更大金额(更灵活)
|
||||
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
if d.PositionSizeUSD < minPositionSizeBTCETH {
|
||||
return fmt.Errorf("%s 开仓金额过小(%.2f USDT),必须≥%.2f USDT(因价格高且精度限制,避免数量四舍五入为0)", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH)
|
||||
}
|
||||
} else {
|
||||
if d.PositionSizeUSD < minPositionSizeGeneral {
|
||||
return fmt.Errorf("开仓金额过小(%.2f USDT),必须≥%.2f USDT(Binance 最小名义价值要求)", d.PositionSizeUSD, minPositionSizeGeneral)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证仓位价值上限(加1%容差以避免浮点数精度问题)
|
||||
tolerance := maxPositionValue * 0.01 // 1%容差
|
||||
if d.PositionSizeUSD > maxPositionValue+tolerance {
|
||||
@@ -589,5 +745,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
|
||||
}
|
||||
|
||||
@@ -56,6 +56,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)
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_DB=${POSTGRES_DB:-nofx}
|
||||
|
||||
@@ -47,7 +47,9 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -trimpath -ldflags="-s -w" -o nofx .
|
||||
RUN CGO_ENABLED=1 GOOS=linux \
|
||||
CGO_CFLAGS="-D_LARGEFILE64_SOURCE" \
|
||||
go build -trimpath -ldflags="-s -w" -o nofx .
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Runtime Stage (Minimal Executable Environment)
|
||||
|
||||
@@ -197,7 +197,7 @@ Details: [详情链接]
|
||||
|
||||
### 法律 & 合规
|
||||
- ✅ 明确说明这是开源贡献,不是雇佣关系
|
||||
- ✅ 确保贡献者同意 MIT License
|
||||
- ✅ 确保贡献者同意 AGPL-3.0 License
|
||||
- ✅ 保留最终合并决定权
|
||||
|
||||
### 资金管理
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 健康检查失败
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](https://amber.ac)
|
||||
|
||||
**Языки / Languages:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md)
|
||||
@@ -285,7 +285,7 @@ Docker автоматически обрабатывает все зависим
|
||||
#### Шаг 1: Подготовьте конфигурацию
|
||||
```bash
|
||||
# Скопируйте шаблон конфигурации
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# Отредактируйте и заполните ваши API ключи
|
||||
nano config.json # или используйте любой редактор
|
||||
@@ -320,6 +320,7 @@ docker compose up -d --build
|
||||
- **Русский**: См. документацию Docker (скоро будет доступно)
|
||||
- **English**: See [DOCKER_DEPLOY.en.md](DOCKER_DEPLOY.en.md)
|
||||
- **中文**: 查看 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)
|
||||
- **日本語**: [DOCKER_DEPLOY.ja.md](DOCKER_DEPLOY.ja.md)を参照
|
||||
|
||||
---
|
||||
|
||||
@@ -423,7 +424,7 @@ cd ..
|
||||
**Шаг 1**: Скопируйте и переименуйте файл примера конфигурации
|
||||
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
**Шаг 2**: Отредактируйте `config.json` и заполните ваши API ключи
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](https://amber.ac)
|
||||
|
||||
**Мови / Languages:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md)
|
||||
@@ -288,7 +288,7 @@ Docker автоматично обробляє всі залежності (Go,
|
||||
#### Крок 1: Підготуйте конфігурацію
|
||||
```bash
|
||||
# Скопіюйте шаблон конфігурації
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
|
||||
# Відредагуйте та заповніть ваші API ключі
|
||||
nano config.json # або використайте будь-який редактор
|
||||
@@ -323,6 +323,7 @@ docker compose up -d --build
|
||||
- **Українська**: Дивіться документацію Docker (скоро буде доступно)
|
||||
- **English**: See [DOCKER_DEPLOY.en.md](DOCKER_DEPLOY.en.md)
|
||||
- **中文**: 查看 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)
|
||||
- **日本語**: [DOCKER_DEPLOY.ja.md](DOCKER_DEPLOY.ja.md)を参照
|
||||
|
||||
---
|
||||
|
||||
@@ -426,7 +427,7 @@ cd ..
|
||||
**Крок 1**: Скопіюйте та перейменуйте файл прикладу конфігурації
|
||||
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
**Крок 2**: Відредагуйте `config.json` та заповніть ваші API ключі
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](https://amber.ac)
|
||||
|
||||
**语言 / Languages:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](../uk/README.md) | [Русский](../ru/README.md)
|
||||
@@ -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 # 或使用其他编辑器
|
||||
@@ -319,6 +319,7 @@ docker compose up -d --build
|
||||
**📖 详细的Docker部署教程、故障排查和高级配置:**
|
||||
- **中文**: 查看 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)
|
||||
- **English**: See [DOCKER_DEPLOY.en.md](DOCKER_DEPLOY.en.md)
|
||||
- **日本語**: [DOCKER_DEPLOY.ja.md](DOCKER_DEPLOY.ja.md)を参照
|
||||
|
||||
---
|
||||
|
||||
@@ -422,7 +423,7 @@ cd ..
|
||||
~~**步骤1**:复制并重命名示例配置文件~~
|
||||
|
||||
```bash
|
||||
cp config.example.jsonc config.json
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
~~**步骤2**:编辑`config.json`填入您的API密钥~~
|
||||
@@ -1262,7 +1263,15 @@ sudo apt-get install libta-lib0-dev
|
||||
|
||||
## 📄 开源协议
|
||||
|
||||
MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||
本项目采用 **GNU Affero 通用公共许可证 v3.0 (AGPL-3.0)** - 详见 [LICENSE](LICENSE) 文件
|
||||
|
||||
**这意味着什么:**
|
||||
- ✅ 你可以使用、修改和分发此软件
|
||||
- ✅ 你必须公开你修改版本的源代码
|
||||
- ✅ 如果你在服务器上运行修改版本,必须向用户提供源代码
|
||||
- ✅ 所有衍生作品也必须使用 AGPL-3.0 许可证
|
||||
|
||||
如需商业许可或有疑问,请联系维护者。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ 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/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
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
|
||||
modernc.org/sqlite v1.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +31,7 @@ require (
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
@@ -51,11 +55,13 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.9.0 // indirect
|
||||
@@ -71,6 +77,7 @@ require (
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
@@ -79,4 +86,7 @@ require (
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -32,6 +32,8 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q=
|
||||
github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
|
||||
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
|
||||
@@ -62,6 +64,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=
|
||||
@@ -80,6 +84,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -130,6 +136,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
@@ -146,6 +154,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -157,6 +167,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=
|
||||
@@ -169,6 +181,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=
|
||||
@@ -202,12 +215,15 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
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=
|
||||
@@ -230,3 +246,29 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -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级别及以上的日志"
|
||||
}
|
||||
+153
-34
@@ -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,11 @@ 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", "auto_close_long", "auto_close_short":
|
||||
stats.TotalClosePositions++
|
||||
// 🔧 BUG FIX:partial_close 不計入 TotalClosePositions,避免重複計數
|
||||
// case "partial_close": // 不計數,因為只有完全平倉才算一次
|
||||
// update_stop_loss 和 update_take_profit 不計入統計
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,11 +351,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" || action.Action == "auto_close_long" {
|
||||
side = "long"
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" {
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_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 {
|
||||
@@ -365,9 +379,10 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
"quantity": action.Quantity,
|
||||
"leverage": action.Leverage,
|
||||
}
|
||||
case "close_long", "close_short":
|
||||
case "close_long", "close_short", "auto_close_long", "auto_close_short":
|
||||
// 移除已平仓记录
|
||||
delete(openPositions, posKey)
|
||||
// partial_close 不處理,保留持倉記錄
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,11 +397,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" || action.Action == "auto_close_long" {
|
||||
side = "long"
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" {
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_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 {
|
||||
@@ -398,9 +425,13 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
"openTime": action.Timestamp,
|
||||
"quantity": action.Quantity,
|
||||
"leverage": action.Leverage,
|
||||
"remainingQuantity": action.Quantity, // 🔧 BUG FIX:追蹤剩餘數量
|
||||
"accumulatedPnL": 0.0, // 🔧 BUG FIX:累積部分平倉盈虧
|
||||
"partialCloseCount": 0, // 🔧 BUG FIX:部分平倉次數
|
||||
"partialCloseVolume": 0.0, // 🔧 BUG FIX:部分平倉總量
|
||||
}
|
||||
|
||||
case "close_long", "close_short":
|
||||
case "close_long", "close_short", "partial_close", "auto_close_long", "auto_close_short":
|
||||
// 查找对应的开仓记录(可能来自预填充或当前窗口)
|
||||
if openPos, exists := openPositions[posKey]; exists {
|
||||
openPrice := openPos["openPrice"].(float64)
|
||||
@@ -409,35 +440,63 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
quantity := openPos["quantity"].(float64)
|
||||
leverage := openPos["leverage"].(int)
|
||||
|
||||
// 计算实际盈亏(USDT)
|
||||
// 合约交易 PnL 计算:quantity × 价格差
|
||||
// 注意:杠杆不影响绝对盈亏,只影响保证金需求
|
||||
var pnl float64
|
||||
if side == "long" {
|
||||
pnl = quantity * (action.Price - openPrice)
|
||||
} else {
|
||||
pnl = quantity * (openPrice - action.Price)
|
||||
// 🔧 BUG FIX:取得追蹤字段(若不存在則初始化)
|
||||
remainingQty, _ := openPos["remainingQuantity"].(float64)
|
||||
if remainingQty == 0 {
|
||||
remainingQty = quantity // 兼容舊數據(沒有 remainingQuantity 字段)
|
||||
}
|
||||
accumulatedPnL, _ := openPos["accumulatedPnL"].(float64)
|
||||
partialCloseCount, _ := openPos["partialCloseCount"].(int)
|
||||
partialCloseVolume, _ := openPos["partialCloseVolume"].(float64)
|
||||
|
||||
// 对于 partial_close,使用实际平仓数量;否则使用剩余仓位数量
|
||||
actualQuantity := remainingQty
|
||||
if action.Action == "partial_close" {
|
||||
actualQuantity = action.Quantity
|
||||
}
|
||||
|
||||
// 计算盈亏百分比(相对保证金)
|
||||
// 计算本次平仓的盈亏(USDT)
|
||||
var pnl float64
|
||||
if side == "long" {
|
||||
pnl = actualQuantity * (action.Price - openPrice)
|
||||
} else {
|
||||
pnl = actualQuantity * (openPrice - action.Price)
|
||||
}
|
||||
|
||||
// 🔧 BUG FIX:處理 partial_close 聚合邏輯
|
||||
if action.Action == "partial_close" {
|
||||
// 累積盈虧和數量
|
||||
accumulatedPnL += pnl
|
||||
remainingQty -= actualQuantity
|
||||
partialCloseCount++
|
||||
partialCloseVolume += actualQuantity
|
||||
|
||||
// 更新 openPositions(保留持倉記錄,但更新追蹤數據)
|
||||
openPos["remainingQuantity"] = remainingQty
|
||||
openPos["accumulatedPnL"] = accumulatedPnL
|
||||
openPos["partialCloseCount"] = partialCloseCount
|
||||
openPos["partialCloseVolume"] = partialCloseVolume
|
||||
|
||||
// 判斷是否已完全平倉
|
||||
if remainingQty <= 0.0001 { // 使用小閾值避免浮點誤差
|
||||
// ✅ 完全平倉:記錄為一筆完整交易
|
||||
positionValue := quantity * openPrice
|
||||
marginUsed := positionValue / float64(leverage)
|
||||
pnlPct := 0.0
|
||||
if marginUsed > 0 {
|
||||
pnlPct = (pnl / marginUsed) * 100
|
||||
pnlPct = (accumulatedPnL / marginUsed) * 100
|
||||
}
|
||||
|
||||
// 记录交易结果
|
||||
outcome := TradeOutcome{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity,
|
||||
Quantity: quantity, // 使用原始總量
|
||||
Leverage: leverage,
|
||||
OpenPrice: openPrice,
|
||||
ClosePrice: action.Price,
|
||||
ClosePrice: action.Price, // 最後一次平倉價格
|
||||
PositionValue: positionValue,
|
||||
MarginUsed: marginUsed,
|
||||
PnL: pnl,
|
||||
PnL: accumulatedPnL, // 🔧 使用累積盈虧
|
||||
PnLPct: pnlPct,
|
||||
Duration: action.Timestamp.Sub(openTime).String(),
|
||||
OpenTime: openTime,
|
||||
@@ -445,17 +504,16 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
}
|
||||
|
||||
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
|
||||
analysis.TotalTrades++
|
||||
analysis.TotalTrades++ // 🔧 只在完全平倉時計數
|
||||
|
||||
// 分类交易:盈利、亏损、持平(避免将pnl=0算入亏损)
|
||||
if pnl > 0 {
|
||||
// 分类交易
|
||||
if accumulatedPnL > 0 {
|
||||
analysis.WinningTrades++
|
||||
analysis.AvgWin += pnl
|
||||
} else if pnl < 0 {
|
||||
analysis.AvgWin += accumulatedPnL
|
||||
} else if accumulatedPnL < 0 {
|
||||
analysis.LosingTrades++
|
||||
analysis.AvgLoss += pnl
|
||||
analysis.AvgLoss += accumulatedPnL
|
||||
}
|
||||
// pnl == 0 的交易不计入盈利也不计入亏损,但计入总交易数
|
||||
|
||||
// 更新币种统计
|
||||
if _, exists := analysis.SymbolStats[symbol]; !exists {
|
||||
@@ -465,16 +523,77 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
|
||||
}
|
||||
stats := analysis.SymbolStats[symbol]
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += pnl
|
||||
if pnl > 0 {
|
||||
stats.TotalPnL += accumulatedPnL
|
||||
if accumulatedPnL > 0 {
|
||||
stats.WinningTrades++
|
||||
} else if pnl < 0 {
|
||||
} else if accumulatedPnL < 0 {
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
// 移除已平仓记录
|
||||
// 刪除持倉記錄
|
||||
delete(openPositions, posKey)
|
||||
}
|
||||
// ⚠️ 否則不做任何操作(等待後續 partial_close 或 full close)
|
||||
|
||||
} else {
|
||||
// 🔧 完全平倉(close_long/close_short/auto_close)
|
||||
// 如果之前有部分平倉,需要加上累積的 PnL
|
||||
totalPnL := accumulatedPnL + pnl
|
||||
|
||||
positionValue := quantity * openPrice
|
||||
marginUsed := positionValue / float64(leverage)
|
||||
pnlPct := 0.0
|
||||
if marginUsed > 0 {
|
||||
pnlPct = (totalPnL / marginUsed) * 100
|
||||
}
|
||||
|
||||
outcome := TradeOutcome{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity, // 使用原始總量
|
||||
Leverage: leverage,
|
||||
OpenPrice: openPrice,
|
||||
ClosePrice: action.Price,
|
||||
PositionValue: positionValue,
|
||||
MarginUsed: marginUsed,
|
||||
PnL: totalPnL, // 🔧 包含之前部分平倉的 PnL
|
||||
PnLPct: pnlPct,
|
||||
Duration: action.Timestamp.Sub(openTime).String(),
|
||||
OpenTime: openTime,
|
||||
CloseTime: action.Timestamp,
|
||||
}
|
||||
|
||||
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
|
||||
analysis.TotalTrades++
|
||||
|
||||
// 分类交易
|
||||
if totalPnL > 0 {
|
||||
analysis.WinningTrades++
|
||||
analysis.AvgWin += totalPnL
|
||||
} else if totalPnL < 0 {
|
||||
analysis.LosingTrades++
|
||||
analysis.AvgLoss += totalPnL
|
||||
}
|
||||
|
||||
// 更新币种统计
|
||||
if _, exists := analysis.SymbolStats[symbol]; !exists {
|
||||
analysis.SymbolStats[symbol] = &SymbolPerformance{
|
||||
Symbol: symbol,
|
||||
}
|
||||
}
|
||||
stats := analysis.SymbolStats[symbol]
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += totalPnL
|
||||
if totalPnL > 0 {
|
||||
stats.WinningTrades++
|
||||
} else if totalPnL < 0 {
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
// 刪除持倉記錄
|
||||
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()
|
||||
})
|
||||
}
|
||||
@@ -38,26 +38,36 @@ type ConfigFile struct {
|
||||
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.DatabaseInterface) 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.DatabaseInterface, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database config.DatabaseInterfa
|
||||
}
|
||||
|
||||
// 添加到TraderManager
|
||||
err = tm.addTraderFromDB(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins)
|
||||
err = tm.addTraderFromDB(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, database, traderCfg.UserID)
|
||||
if err != nil {
|
||||
log.Printf("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err)
|
||||
continue
|
||||
@@ -182,7 +182,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database config.DatabaseInterfa
|
||||
}
|
||||
|
||||
// addTraderFromConfig 内部方法:从配置添加交易员(不加锁,因为调用方已加锁)
|
||||
func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string) error {
|
||||
func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database config.DatabaseInterface, userID string) error {
|
||||
if _, exists := tm.traders[traderCfg.ID]; exists {
|
||||
return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID)
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModel
|
||||
}
|
||||
|
||||
// 创建trader实例
|
||||
at, err := trader.NewAutoTrader(traderConfig)
|
||||
at, err := trader.NewAutoTrader(traderConfig, database, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建trader失败: %w", err)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModel
|
||||
// AddTrader 从数据库配置添加trader (移除旧版兼容性)
|
||||
|
||||
// AddTraderFromDB 从数据库配置添加trader
|
||||
func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string) error {
|
||||
func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
@@ -368,7 +368,7 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModel
|
||||
}
|
||||
|
||||
// 创建trader实例
|
||||
at, err := trader.NewAutoTrader(traderConfig)
|
||||
at, err := trader.NewAutoTrader(traderConfig, database, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建trader失败: %w", err)
|
||||
}
|
||||
@@ -832,7 +832,7 @@ func (tm *TraderManager) LoadUserTraders(database config.DatabaseInterface, user
|
||||
}
|
||||
|
||||
// 使用现有的方法加载交易员
|
||||
err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins)
|
||||
err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, database, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err)
|
||||
}
|
||||
@@ -842,7 +842,7 @@ func (tm *TraderManager) LoadUserTraders(database config.DatabaseInterface, user
|
||||
}
|
||||
|
||||
// loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑)
|
||||
func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string) error {
|
||||
func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database config.DatabaseInterface, userID string) error {
|
||||
// 处理交易币种列表
|
||||
var tradingCoins []string
|
||||
if traderCfg.TradingSymbols != "" {
|
||||
@@ -889,6 +889,7 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode
|
||||
DefaultCoins: defaultCoins,
|
||||
TradingCoins: tradingCoins,
|
||||
SystemPromptTemplate: traderCfg.SystemPromptTemplate, // 系统提示词模板
|
||||
HyperliquidTestnet: exchangeCfg.Testnet, // Hyperliquid测试网
|
||||
}
|
||||
|
||||
// 根据交易所类型设置API密钥
|
||||
@@ -912,7 +913,7 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode
|
||||
}
|
||||
|
||||
// 创建trader实例
|
||||
at, err := trader.NewAutoTrader(traderConfig)
|
||||
at, err := trader.NewAutoTrader(traderConfig, database, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建trader失败: %w", err)
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
// ✅ FIX: 返回深拷贝而非引用
|
||||
result := make([]Kline, len(klines))
|
||||
copy(result, klines)
|
||||
return result, nil
|
||||
}
|
||||
return klines, fmt.Errorf("symbol不存在")
|
||||
}
|
||||
return value.([]Kline), nil
|
||||
|
||||
// ✅ FIX: 返回深拷贝而非引用,避免并发竞态条件
|
||||
klines := value.([]Kline)
|
||||
result := make([]Kline, len(klines))
|
||||
copy(result, klines)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *WSMonitor) Close() {
|
||||
|
||||
+19
-2
@@ -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需要分析大量数据
|
||||
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 不支持
|
||||
@@ -280,6 +295,8 @@ func isRetryableError(err error) bool {
|
||||
"connection refused",
|
||||
"temporary failure",
|
||||
"no such host",
|
||||
"stream error", // HTTP/2 stream 错误
|
||||
"INTERNAL_ERROR", // 服务端内部错误
|
||||
}
|
||||
for _, retryable := range retryableErrors {
|
||||
if strings.Contains(errStr, retryable) {
|
||||
|
||||
@@ -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是底线
|
||||
- 纪律执行是长期盈利的关键
|
||||
|
||||
**现在,请基于以上原则分析市场并做出稳健决策**
|
||||
+33
-22
@@ -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 步:疑惑检查
|
||||
@@ -330,26 +342,25 @@
|
||||
|
||||
## 仓位计算公式
|
||||
|
||||
```
|
||||
仓位大小(USD) = 可用资金 × 风险预算 / 止损距离百分比
|
||||
仓位数量(Coins) = 仓位大小(USD) / 当前价格
|
||||
```
|
||||
**重要**:position_size_usd 是**名义价值**(包含杠杆),非保证金需求。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
账户净值:10,000 USDT
|
||||
风险预算:2%(信心度 90-95)
|
||||
止损距离:2%(50,000 → 49,000)
|
||||
**计算步骤**:
|
||||
1. **可用保证金** = Available Cash × 0.95 × Allocation %(预留5%给手续费)
|
||||
2. **名义价值** = 可用保证金 × Leverage
|
||||
3. **position_size_usd** = 名义价值(这是 JSON 中应填写的值)
|
||||
4. **Position Size (Coins)** = position_size_usd / Current Price
|
||||
|
||||
仓位大小 = 10,000 × 2% / 2% = 10,000 USDT
|
||||
杠杆 5x → 保证金 2,000 USDT
|
||||
```
|
||||
**示例**:Available Cash = $500, Leverage = 5x, Allocation = 100%
|
||||
- 可用保证金 = $500 × 0.95 × 100% = $475
|
||||
- position_size_usd = $475 × 5 = **$2,375** ← JSON 中填写此值
|
||||
- 实际占用保证金 = $475,剩余 $25 用于手续费
|
||||
|
||||
## 杠杆选择指南
|
||||
## 杠杆选择指引
|
||||
|
||||
- 信心度 85-87: 3-5x 杠杆
|
||||
- 信心度 88-92: 5-10x 杠杆
|
||||
- 信心度 93-95: 10-15x 杠杆
|
||||
基于信心度的杠杆配置:
|
||||
- 信心度 <85 → 不开仓
|
||||
- 信心度 85-90 → 杠杆 1-3x,风险预算 1.5%
|
||||
- 信心度 90-95 → 杠杆 3-8x,风险预算 2%
|
||||
- 信心度 >95: 最高 20x 杠杆(谨慎)
|
||||
|
||||
## 风险控制原则
|
||||
|
||||
@@ -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%+ 胜率
|
||||
- 允许更多山寨币机会
|
||||
- 保持核心風控(夏普、連虧停手)
|
||||
@@ -106,6 +106,21 @@
|
||||
3. 寻找新机会: 有强信号吗?多空机会?
|
||||
4. 输出决策: 思维链分析 + JSON
|
||||
|
||||
# 仓位大小计算
|
||||
|
||||
**重要**:`position_size_usd` 是**名义价值**(包含杠杆),非保证金需求。
|
||||
|
||||
**计算步骤**:
|
||||
1. **可用保证金** = Available Cash × 0.95 × 配置比例(预留5%手续费)
|
||||
2. **名义价值** = 可用保证金 × Leverage
|
||||
3. **position_size_usd** = 名义价值(JSON中填写此值)
|
||||
4. **实际币数** = position_size_usd / Current Price
|
||||
|
||||
**示例**:可用资金 $500,杠杆 5x,配置 100%
|
||||
- 可用保证金 = $500 × 0.95 = $475
|
||||
- position_size_usd = $475 × 5 = **$2,375** ← JSON填此值
|
||||
- 实际占用保证金 = $475,剩余 $25 用于手续费
|
||||
|
||||
---
|
||||
|
||||
记住:
|
||||
|
||||
+24
-9
@@ -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
|
||||
|
||||
@@ -45,10 +51,19 @@ You have exactly FOUR possible actions per decision cycle:
|
||||
|
||||
# POSITION SIZING FRAMEWORK
|
||||
|
||||
Calculate position size using this formula:
|
||||
**IMPORTANT**: `position_size_usd` is the **notional value** (includes leverage), NOT margin requirement.
|
||||
|
||||
Position Size (USD) = Available Cash × Leverage × Allocation %
|
||||
Position Size (Coins) = Position Size (USD) / Current Price
|
||||
## Calculation Steps:
|
||||
|
||||
1. **Available Margin** = Available Cash × 0.95 × Allocation % (reserve 5% for fees)
|
||||
2. **Notional Value** = Available Margin × Leverage
|
||||
3. **position_size_usd** = Notional Value (this is the value for JSON)
|
||||
4. **Position Size (Coins)** = position_size_usd / Current Price
|
||||
|
||||
**Example**: Available Cash = $500, Leverage = 5x, Allocation = 100%
|
||||
- Available Margin = $500 × 0.95 × 100% = $475
|
||||
- position_size_usd = $475 × 5 = **$2,375** ← Fill this value in JSON
|
||||
- Actual margin used = $475, remaining $25 for fees
|
||||
|
||||
## Sizing Considerations
|
||||
|
||||
|
||||
@@ -165,6 +165,16 @@ start() {
|
||||
# 读取环境变量
|
||||
read_env_vars
|
||||
|
||||
# 确保必要的文件和目录存在(修复 Docker volume 挂载问题)
|
||||
if [ ! -f "config.db" ]; then
|
||||
print_info "创建数据库文件..."
|
||||
touch config.db
|
||||
fi
|
||||
if [ ! -d "decision_logs" ]; then
|
||||
print_info "创建日志目录..."
|
||||
mkdir -p decision_logs
|
||||
fi
|
||||
|
||||
# Auto-build frontend if missing or forced
|
||||
# if [ ! -d "web/dist" ] || [ "$1" == "--build" ]; then
|
||||
# build_frontend
|
||||
|
||||
+198
-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
|
||||
}
|
||||
|
||||
@@ -842,6 +866,21 @@ func (t *AsterTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
log.Printf(" ✓ %s 仓位模式已是 %s 或有持仓无法更改", symbol, marginType)
|
||||
return nil
|
||||
}
|
||||
// 检测多资产模式(错误码 -4168)
|
||||
if strings.Contains(err.Error(), "Multi-Assets mode") ||
|
||||
strings.Contains(err.Error(), "-4168") ||
|
||||
strings.Contains(err.Error(), "4168") {
|
||||
log.Printf(" ⚠️ %s 检测到多资产模式,强制使用全仓模式", symbol)
|
||||
log.Printf(" 💡 提示:如需使用逐仓模式,请在交易所关闭多资产模式")
|
||||
return nil
|
||||
}
|
||||
// 检测统一账户 API
|
||||
if strings.Contains(err.Error(), "unified") ||
|
||||
strings.Contains(err.Error(), "portfolio") ||
|
||||
strings.Contains(err.Error(), "Portfolio") {
|
||||
log.Printf(" ❌ %s 检测到统一账户 API,无法进行合约交易", symbol)
|
||||
return fmt.Errorf("请使用「现货与合约交易」API 权限,不要使用「统一账户 API」")
|
||||
}
|
||||
log.Printf(" ⚠️ 设置仓位模式失败: %v", err)
|
||||
// 不返回错误,让交易继续
|
||||
return nil
|
||||
@@ -971,6 +1010,108 @@ func (t *AsterTrader) SetTakeProfit(symbol string, positionSide string, quantity
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
|
||||
// CancelStopLossOrders 仅取消止损单(不影响止盈单)
|
||||
func (t *AsterTrader) CancelStopLossOrders(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 == "STOP" {
|
||||
orderID, _ := order["orderId"].(float64)
|
||||
cancelParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": int64(orderID),
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v1/order", cancelParams)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠ 取消止损单 %d 失败: %v", int64(orderID), err)
|
||||
continue
|
||||
}
|
||||
|
||||
canceledCount++
|
||||
log.Printf(" ✓ 已取消止损单 (订单ID: %d, 类型: %s)", int64(orderID), orderType)
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有止损单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个止损单", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders 仅取消止盈单(不影响止损单)
|
||||
func (t *AsterTrader) CancelTakeProfitOrders(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 == "TAKE_PROFIT_MARKET" || orderType == "TAKE_PROFIT" {
|
||||
orderID, _ := order["orderId"].(float64)
|
||||
cancelParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": int64(orderID),
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v1/order", cancelParams)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠ 取消止盈单 %d 失败: %v", int64(orderID), err)
|
||||
continue
|
||||
}
|
||||
|
||||
canceledCount++
|
||||
log.Printf(" ✓ 已取消止盈单 (订单ID: %d, 类型: %s)", int64(orderID), orderType)
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有止盈单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个止盈单", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders 取消所有订单
|
||||
func (t *AsterTrader) CancelAllOrders(symbol string) error {
|
||||
params := map[string]interface{}{
|
||||
@@ -981,6 +1122,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)
|
||||
|
||||
+544
-17
@@ -4,12 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"nofx/decision"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/pool"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -98,10 +100,17 @@ type AutoTrader struct {
|
||||
startTime time.Time // 系统启动时间
|
||||
callCount int // AI调用次数
|
||||
positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒)
|
||||
stopMonitorCh chan struct{} // 用于停止监控goroutine
|
||||
monitorWg sync.WaitGroup // 用于等待监控goroutine结束
|
||||
peakPnLCache map[string]float64 // 最高收益缓存 (symbol -> 峰值盈亏百分比)
|
||||
peakPnLCacheMutex sync.RWMutex // 缓存读写锁
|
||||
lastBalanceSyncTime time.Time // 上次余额同步时间
|
||||
database interface{} // 数据库引用(用于自动更新余额)
|
||||
userID string // 用户ID
|
||||
}
|
||||
|
||||
// NewAutoTrader 创建自动交易器
|
||||
func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) {
|
||||
func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) (*AutoTrader, error) {
|
||||
// 设置默认值
|
||||
if config.ID == "" {
|
||||
config.ID = "default_trader"
|
||||
@@ -195,7 +204,8 @@ func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) {
|
||||
// 设置默认系统提示词模板
|
||||
systemPromptTemplate := config.SystemPromptTemplate
|
||||
if systemPromptTemplate == "" {
|
||||
systemPromptTemplate = "default" // 默认使用 default 模板
|
||||
// feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损)
|
||||
systemPromptTemplate = "adaptive"
|
||||
}
|
||||
|
||||
return &AutoTrader{
|
||||
@@ -216,6 +226,13 @@ func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) {
|
||||
callCount: 0,
|
||||
isRunning: false,
|
||||
positionFirstSeenTime: make(map[string]int64),
|
||||
stopMonitorCh: make(chan struct{}),
|
||||
monitorWg: sync.WaitGroup{},
|
||||
peakPnLCache: make(map[string]float64),
|
||||
peakPnLCacheMutex: sync.RWMutex{},
|
||||
lastBalanceSyncTime: time.Now(), // 初始化为当前时间
|
||||
database: database,
|
||||
userID: userID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -227,6 +244,9 @@ func (at *AutoTrader) Run() error {
|
||||
log.Printf("⚙️ 扫描间隔: %v", at.config.ScanInterval)
|
||||
log.Println("🤖 AI将全权决定杠杆、仓位大小、止损止盈等参数")
|
||||
|
||||
// 启动回撤监控
|
||||
at.startDrawdownMonitor()
|
||||
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -250,16 +270,113 @@ func (at *AutoTrader) Run() error {
|
||||
// Stop 停止自动交易
|
||||
func (at *AutoTrader) Stop() {
|
||||
at.isRunning = false
|
||||
close(at.stopMonitorCh) // 通知监控goroutine停止
|
||||
at.monitorWg.Wait() // 等待监控goroutine结束
|
||||
log.Println("⏹ 自动交易系统停止")
|
||||
}
|
||||
|
||||
// autoSyncBalanceIfNeeded 自动同步余额(每10分钟检查一次,变化>5%才更新)
|
||||
func (at *AutoTrader) autoSyncBalanceIfNeeded() {
|
||||
// 距离上次同步不足10分钟,跳过
|
||||
if time.Since(at.lastBalanceSyncTime) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🔄 [%s] 开始自动检查余额变化...", at.name)
|
||||
|
||||
// 查询实际余额
|
||||
balanceInfo, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [%s] 查询余额失败: %v", at.name, err)
|
||||
at.lastBalanceSyncTime = time.Now() // 即使失败也更新时间,避免频繁重试
|
||||
return
|
||||
}
|
||||
|
||||
// 提取可用余额
|
||||
var actualBalance float64
|
||||
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
actualBalance = totalBalance
|
||||
} else {
|
||||
log.Printf("⚠️ [%s] 无法提取可用余额", at.name)
|
||||
at.lastBalanceSyncTime = time.Now()
|
||||
return
|
||||
}
|
||||
|
||||
oldBalance := at.initialBalance
|
||||
|
||||
// 防止除以零:如果初始余额无效,直接更新为实际余额
|
||||
if oldBalance <= 0 {
|
||||
log.Printf("⚠️ [%s] 初始余额无效 (%.2f),直接更新为实际余额 %.2f USDT", at.name, oldBalance, actualBalance)
|
||||
at.initialBalance = actualBalance
|
||||
if at.database != nil {
|
||||
type DatabaseUpdater interface {
|
||||
UpdateTraderInitialBalance(userID, id string, newBalance float64) error
|
||||
}
|
||||
if db, ok := at.database.(DatabaseUpdater); ok {
|
||||
if err := db.UpdateTraderInitialBalance(at.userID, at.id, actualBalance); err != nil {
|
||||
log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err)
|
||||
} else {
|
||||
log.Printf("✅ [%s] 已自动同步余额到数据库", at.name)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [%s] 数据库类型不支持UpdateTraderInitialBalance接口", at.name)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [%s] 数据库引用为空,余额仅在内存中更新", at.name)
|
||||
}
|
||||
at.lastBalanceSyncTime = time.Now()
|
||||
return
|
||||
}
|
||||
|
||||
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
|
||||
|
||||
// 变化超过5%才更新
|
||||
if math.Abs(changePercent) > 5.0 {
|
||||
log.Printf("🔔 [%s] 检测到余额大幅变化: %.2f → %.2f USDT (%.2f%%)",
|
||||
at.name, oldBalance, actualBalance, changePercent)
|
||||
|
||||
// 更新内存中的 initialBalance
|
||||
at.initialBalance = actualBalance
|
||||
|
||||
// 更新数据库(需要类型断言)
|
||||
if at.database != nil {
|
||||
// 这里需要根据实际的数据库类型进行类型断言
|
||||
// 由于使用了 interface{},我们需要在 TraderManager 层面处理更新
|
||||
// 或者在这里进行类型检查
|
||||
type DatabaseUpdater interface {
|
||||
UpdateTraderInitialBalance(userID, id string, newBalance float64) error
|
||||
}
|
||||
if db, ok := at.database.(DatabaseUpdater); ok {
|
||||
err := db.UpdateTraderInitialBalance(at.userID, at.id, actualBalance)
|
||||
if err != nil {
|
||||
log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err)
|
||||
} else {
|
||||
log.Printf("✅ [%s] 已自动同步余额到数据库", at.name)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [%s] 数据库类型不支持UpdateTraderInitialBalance接口", at.name)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [%s] 数据库引用为空,余额仅在内存中更新", at.name)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✓ [%s] 余额变化不大 (%.2f%%),无需更新", at.name, changePercent)
|
||||
}
|
||||
|
||||
at.lastBalanceSyncTime = time.Now()
|
||||
}
|
||||
|
||||
// runCycle 运行一个交易周期(使用AI全权决策)
|
||||
func (at *AutoTrader) runCycle() error {
|
||||
at.callCount++
|
||||
|
||||
log.Print("\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.Print(strings.Repeat("=", 70))
|
||||
log.Println(strings.Repeat("=", 70))
|
||||
|
||||
// 创建决策记录
|
||||
record := &logger.DecisionRecord{
|
||||
@@ -284,7 +401,10 @@ func (at *AutoTrader) runCycle() error {
|
||||
log.Println("📅 日盈亏已重置")
|
||||
}
|
||||
|
||||
// 3. 收集交易上下文
|
||||
// 3. 自动同步余额(每10分钟检查一次,充值/提现后自动更新)
|
||||
at.autoSyncBalanceIfNeeded()
|
||||
|
||||
// 4. 收集交易上下文
|
||||
ctx, err := at.buildTradingContext()
|
||||
if err != nil {
|
||||
record.Success = false
|
||||
@@ -316,7 +436,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
})
|
||||
}
|
||||
|
||||
// 保存候选币种列表
|
||||
log.Print(strings.Repeat("=", 70))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||||
}
|
||||
@@ -324,7 +444,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
log.Printf("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d",
|
||||
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
|
||||
|
||||
// 4. 调用AI获取完整决策
|
||||
// 5. 调用AI获取完整决策
|
||||
log.Printf("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate)
|
||||
decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate)
|
||||
|
||||
@@ -345,20 +465,18 @@ func (at *AutoTrader) runCycle() error {
|
||||
|
||||
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
|
||||
if decision != nil {
|
||||
if decision.SystemPrompt != "" {
|
||||
log.Print("\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.Print(strings.Repeat("=", 70) + "\n")
|
||||
}
|
||||
log.Println(strings.Repeat("=", 70))
|
||||
|
||||
if decision.CoTTrace != "" {
|
||||
log.Print("\n" + strings.Repeat("-", 70))
|
||||
log.Print("\n" + strings.Repeat("-", 70) + "\n")
|
||||
log.Println("💭 AI思维链分析(错误情况):")
|
||||
log.Println(strings.Repeat("-", 70))
|
||||
log.Println(decision.CoTTrace)
|
||||
log.Print(strings.Repeat("-", 70) + "\n")
|
||||
log.Println(strings.Repeat("-", 70))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +508,9 @@ func (at *AutoTrader) runCycle() error {
|
||||
// }
|
||||
// }
|
||||
log.Println()
|
||||
log.Print(strings.Repeat("-", 70))
|
||||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||||
log.Print(strings.Repeat("-", 70))
|
||||
|
||||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||||
sortedDecisions := sortDecisionsByPriority(decision.Decisions)
|
||||
@@ -481,6 +602,12 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
if quantity < 0 {
|
||||
quantity = -quantity // 空仓数量为负,转为正数
|
||||
}
|
||||
|
||||
// 跳过已平仓的持仓(quantity = 0),防止"幽灵持仓"传递给AI
|
||||
if quantity == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||||
|
||||
@@ -593,6 +720,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
|
||||
@@ -626,6 +759,27 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
actionRecord.Quantity = quantity
|
||||
actionRecord.Price = marketData.CurrentPrice
|
||||
|
||||
// ⚠️ 保证金验证:防止保证金不足错误(code=-2019)
|
||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||||
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取账户余额失败: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// 手续费估算(Taker费率 0.04%)
|
||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||
totalRequired := requiredMargin + estimatedFee
|
||||
|
||||
if totalRequired > availableBalance {
|
||||
return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT",
|
||||
totalRequired, requiredMargin, estimatedFee, availableBalance)
|
||||
}
|
||||
|
||||
// 设置仓位模式
|
||||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||||
log.Printf(" ⚠️ 设置仓位模式失败: %v", err)
|
||||
@@ -685,6 +839,27 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
actionRecord.Quantity = quantity
|
||||
actionRecord.Price = marketData.CurrentPrice
|
||||
|
||||
// ⚠️ 保证金验证:防止保证金不足错误(code=-2019)
|
||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||||
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取账户余额失败: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// 手续费估算(Taker费率 0.04%)
|
||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||
totalRequired := requiredMargin + estimatedFee
|
||||
|
||||
if totalRequired > availableBalance {
|
||||
return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT",
|
||||
totalRequired, requiredMargin, estimatedFee, availableBalance)
|
||||
}
|
||||
|
||||
// 设置仓位模式
|
||||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||||
log.Printf(" ⚠️ 设置仓位模式失败: %v", err)
|
||||
@@ -771,6 +946,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
|
||||
@@ -984,12 +1354,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 // 未知动作放最后
|
||||
}
|
||||
@@ -1081,3 +1453,158 @@ func normalizeSymbol(symbol string) string {
|
||||
|
||||
return symbol
|
||||
}
|
||||
|
||||
// 启动回撤监控
|
||||
func (at *AutoTrader) startDrawdownMonitor() {
|
||||
at.monitorWg.Add(1)
|
||||
go func() {
|
||||
defer at.monitorWg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute) // 每分钟检查一次
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("📊 启动持仓回撤监控(每分钟检查一次)")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
at.checkPositionDrawdown()
|
||||
case <-at.stopMonitorCh:
|
||||
log.Println("⏹ 停止持仓回撤监控")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 检查持仓回撤情况
|
||||
func (at *AutoTrader) checkPositionDrawdown() {
|
||||
// 获取当前持仓
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err != nil {
|
||||
log.Printf("❌ 回撤监控:获取持仓失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pos := range positions {
|
||||
symbol := pos["symbol"].(string)
|
||||
side := pos["side"].(string)
|
||||
entryPrice := pos["entryPrice"].(float64)
|
||||
markPrice := pos["markPrice"].(float64)
|
||||
quantity := pos["positionAmt"].(float64)
|
||||
if quantity < 0 {
|
||||
quantity = -quantity // 空仓数量为负,转为正数
|
||||
}
|
||||
|
||||
// 计算当前盈亏百分比
|
||||
leverage := 10 // 默认值
|
||||
if lev, ok := pos["leverage"].(float64); ok {
|
||||
leverage = int(lev)
|
||||
}
|
||||
|
||||
var currentPnLPct float64
|
||||
if side == "long" {
|
||||
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
|
||||
} else {
|
||||
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
|
||||
}
|
||||
|
||||
// 获取该持仓的历史最高收益
|
||||
at.peakPnLCacheMutex.RLock()
|
||||
peakPnLPct, exists := at.peakPnLCache[symbol]
|
||||
at.peakPnLCacheMutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
// 如果没有历史最高记录,使用当前盈亏作为初始值
|
||||
peakPnLPct = currentPnLPct
|
||||
at.UpdatePeakPnL(symbol, currentPnLPct)
|
||||
} else {
|
||||
// 更新峰值缓存
|
||||
at.UpdatePeakPnL(symbol, currentPnLPct)
|
||||
}
|
||||
|
||||
// 计算回撤(从最高点下跌的幅度)
|
||||
var drawdownPct float64
|
||||
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
|
||||
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
||||
}
|
||||
|
||||
// 检查平仓条件:收益大于5%且回撤超过40%
|
||||
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
|
||||
log.Printf("🚨 触发回撤平仓条件: %s %s | 当前收益: %.2f%% | 最高收益: %.2f%% | 回撤: %.2f%%",
|
||||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||||
|
||||
// 执行平仓
|
||||
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
||||
log.Printf("❌ 回撤平仓失败 (%s %s): %v", symbol, side, err)
|
||||
} else {
|
||||
log.Printf("✅ 回撤平仓成功: %s %s", symbol, side)
|
||||
// 平仓后清理该symbol的缓存
|
||||
at.ClearPeakPnLCache(symbol)
|
||||
}
|
||||
} else if currentPnLPct > 5.0 {
|
||||
// 记录接近平仓条件的情况(用于调试)
|
||||
log.Printf("📊 回撤监控: %s %s | 收益: %.2f%% | 最高: %.2f%% | 回撤: %.2f%%",
|
||||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 紧急平仓函数
|
||||
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
|
||||
switch side {
|
||||
case "long":
|
||||
order, err := at.trader.CloseLong(symbol, 0) // 0 = 全部平仓
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("✅ 紧急平多仓成功,订单ID: %v", order["orderId"])
|
||||
case "short":
|
||||
order, err := at.trader.CloseShort(symbol, 0) // 0 = 全部平仓
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("✅ 紧急平空仓成功,订单ID: %v", order["orderId"])
|
||||
default:
|
||||
return fmt.Errorf("未知的持仓方向: %s", side)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPeakPnLCache 获取最高收益缓存
|
||||
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
|
||||
at.peakPnLCacheMutex.RLock()
|
||||
defer at.peakPnLCacheMutex.RUnlock()
|
||||
|
||||
// 返回缓存的副本
|
||||
cache := make(map[string]float64)
|
||||
for k, v := range at.peakPnLCache {
|
||||
cache[k] = v
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
// UpdatePeakPnL 更新最高收益缓存
|
||||
func (at *AutoTrader) UpdatePeakPnL(symbol string, currentPnLPct float64) {
|
||||
at.peakPnLCacheMutex.Lock()
|
||||
defer at.peakPnLCacheMutex.Unlock()
|
||||
|
||||
if peak, exists := at.peakPnLCache[symbol]; exists {
|
||||
// 更新峰值(如果是多头,取较大值;如果是空头,currentPnLPct为负,也要比较)
|
||||
if currentPnLPct > peak {
|
||||
at.peakPnLCache[symbol] = currentPnLPct
|
||||
}
|
||||
} else {
|
||||
// 首次记录
|
||||
at.peakPnLCache[symbol] = currentPnLPct
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPeakPnLCache 清除指定symbol的峰值缓存
|
||||
func (at *AutoTrader) ClearPeakPnLCache(symbol string) {
|
||||
at.peakPnLCacheMutex.Lock()
|
||||
defer at.peakPnLCacheMutex.Unlock()
|
||||
|
||||
delete(at.peakPnLCache, symbol)
|
||||
}
|
||||
|
||||
+240
-1
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -32,10 +33,56 @@ type FuturesTrader struct {
|
||||
// NewFuturesTrader 创建合约交易器
|
||||
func NewFuturesTrader(apiKey, secretKey string) *FuturesTrader {
|
||||
client := futures.NewClient(apiKey, secretKey)
|
||||
return &FuturesTrader{
|
||||
// 同步时间,避免 Timestamp ahead 错误
|
||||
syncBinanceServerTime(client)
|
||||
trader := &FuturesTrader{
|
||||
client: client,
|
||||
cacheDuration: 15 * time.Second, // 15秒缓存
|
||||
}
|
||||
|
||||
// 设置双向持仓模式(Hedge Mode)
|
||||
// 这是必需的,因为代码中使用了 PositionSide (LONG/SHORT)
|
||||
if err := trader.setDualSidePosition(); err != nil {
|
||||
log.Printf("⚠️ 设置双向持仓模式失败: %v (如果已是双向模式则忽略此警告)", err)
|
||||
}
|
||||
|
||||
return trader
|
||||
}
|
||||
|
||||
// setDualSidePosition 设置双向持仓模式(初始化时调用)
|
||||
func (t *FuturesTrader) setDualSidePosition() error {
|
||||
// 尝试设置双向持仓模式
|
||||
err := t.client.NewChangePositionModeService().
|
||||
DualSide(true). // true = 双向持仓(Hedge Mode)
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
// 如果错误信息包含"No need to change",说明已经是双向持仓模式
|
||||
if strings.Contains(err.Error(), "No need to change position side") {
|
||||
log.Printf(" ✓ 账户已是双向持仓模式(Hedge Mode)")
|
||||
return nil
|
||||
}
|
||||
// 其他错误则返回(但在调用方不会中断初始化)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" ✓ 账户已切换为双向持仓模式(Hedge Mode)")
|
||||
log.Printf(" ℹ️ 双向持仓模式允许同时持有多单和空单")
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncBinanceServerTime 同步币安服务器时间,确保请求时间戳合法
|
||||
func syncBinanceServerTime(client *futures.Client) {
|
||||
serverTime, err := client.NewServerTimeService().Do(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 同步币安服务器时间失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
offset := now - serverTime
|
||||
client.TimeOffset = offset
|
||||
log.Printf("⏱ 已同步币安服务器时间,偏移 %dms", offset)
|
||||
}
|
||||
|
||||
// GetBalance 获取账户余额(带缓存)
|
||||
@@ -162,6 +209,17 @@ func (t *FuturesTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
log.Printf(" ⚠️ %s 有持仓,无法更改仓位模式,继续使用当前模式", symbol)
|
||||
return nil
|
||||
}
|
||||
// 检测多资产模式(错误码 -4168)
|
||||
if contains(err.Error(), "Multi-Assets mode") || contains(err.Error(), "-4168") || contains(err.Error(), "4168") {
|
||||
log.Printf(" ⚠️ %s 检测到多资产模式,强制使用全仓模式", symbol)
|
||||
log.Printf(" 💡 提示:如需使用逐仓模式,请在币安关闭多资产模式")
|
||||
return nil
|
||||
}
|
||||
// 检测统一账户 API(Portfolio Margin)
|
||||
if contains(err.Error(), "unified") || contains(err.Error(), "portfolio") || contains(err.Error(), "Portfolio") {
|
||||
log.Printf(" ❌ %s 检测到统一账户 API,无法进行合约交易", symbol)
|
||||
return fmt.Errorf("请使用「现货与合约交易」API 权限,不要使用「统一账户 API」")
|
||||
}
|
||||
log.Printf(" ⚠️ 设置仓位模式失败: %v", err)
|
||||
// 不返回错误,让交易继续
|
||||
return nil
|
||||
@@ -237,6 +295,17 @@ func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ✅ 检查格式化后的数量是否为 0(防止四舍五入导致的错误)
|
||||
quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64)
|
||||
if parseErr != nil || quantityFloat <= 0 {
|
||||
return nil, fmt.Errorf("开倉數量過小,格式化後為 0 (原始: %.8f → 格式化: %s)。建議增加開倉金額或選擇價格更低的幣種", quantity, quantityStr)
|
||||
}
|
||||
|
||||
// ✅ 检查最小名义价值(Binance 要求至少 10 USDT)
|
||||
if err := t.CheckMinNotional(symbol, quantityFloat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建市价买入订单
|
||||
order, err := t.client.NewCreateOrderService().
|
||||
Symbol(symbol).
|
||||
@@ -280,6 +349,17 @@ func (t *FuturesTrader) OpenShort(symbol string, quantity float64, leverage int)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ✅ 检查格式化后的数量是否为 0(防止四舍五入导致的错误)
|
||||
quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64)
|
||||
if parseErr != nil || quantityFloat <= 0 {
|
||||
return nil, fmt.Errorf("开倉數量過小,格式化後為 0 (原始: %.8f → 格式化: %s)。建議增加開倉金額或選擇價格更低的幣種", quantity, quantityStr)
|
||||
}
|
||||
|
||||
// ✅ 检查最小名义价值(Binance 要求至少 10 USDT)
|
||||
if err := t.CheckMinNotional(symbol, quantityFloat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建市价卖出订单
|
||||
order, err := t.client.NewCreateOrderService().
|
||||
Symbol(symbol).
|
||||
@@ -411,6 +491,92 @@ func (t *FuturesTrader) CloseShort(symbol string, quantity float64) (map[string]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
// CancelStopLossOrders 仅取消止损单(不影响止盈单)
|
||||
func (t *FuturesTrader) CancelStopLossOrders(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.OrderTypeStop {
|
||||
_, 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(" ✓ 已取消止损单 (订单ID: %d, 类型: %s)", order.OrderID, orderType)
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有止损单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个止损单", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders 仅取消止盈单(不影响止损单)
|
||||
func (t *FuturesTrader) CancelTakeProfitOrders(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.OrderTypeTakeProfitMarket || 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(" ✓ 已取消止盈单 (订单ID: %d, 类型: %s)", order.OrderID, orderType)
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
log.Printf(" ℹ %s 没有止盈单需要取消", symbol)
|
||||
} else {
|
||||
log.Printf(" ✓ 已取消 %s 的 %d 个止盈单", symbol, canceledCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders 取消该币种的所有挂单
|
||||
func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
err := t.client.NewCancelAllOpenOrdersService().
|
||||
@@ -425,6 +591,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())
|
||||
@@ -528,6 +741,32 @@ func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quanti
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMinNotional 获取最小名义价值(Binance要求)
|
||||
func (t *FuturesTrader) GetMinNotional(symbol string) float64 {
|
||||
// 使用保守的默认值 10 USDT,确保订单能够通过交易所验证
|
||||
return 10.0
|
||||
}
|
||||
|
||||
// CheckMinNotional 检查订单是否满足最小名义价值要求
|
||||
func (t *FuturesTrader) CheckMinNotional(symbol string, quantity float64) error {
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取市价失败: %w", err)
|
||||
}
|
||||
|
||||
notionalValue := quantity * price
|
||||
minNotional := t.GetMinNotional(symbol)
|
||||
|
||||
if notionalValue < minNotional {
|
||||
return fmt.Errorf(
|
||||
"订单金额 %.2f USDT 低于最小要求 %.2f USDT (数量: %.4f, 价格: %.4f)",
|
||||
notionalValue, minNotional, quantity, price,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSymbolPrecision 获取交易对的数量精度
|
||||
func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) {
|
||||
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
|
||||
|
||||
+147
-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
|
||||
}
|
||||
@@ -477,6 +549,25 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CancelStopOrders 取消该币种的止盈/止
|
||||
|
||||
|
||||
// CancelStopLossOrders 仅取消止损单(Hyperliquid 暂无法区分止损和止盈,取消所有)
|
||||
func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error {
|
||||
// Hyperliquid SDK 的 OpenOrder 结构不暴露 trigger 字段
|
||||
// 无法区分止损和止盈单,因此取消该币种的所有挂单
|
||||
log.Printf(" ⚠️ Hyperliquid 无法区分止损/止盈单,将取消所有挂单")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders 仅取消止盈单(Hyperliquid 暂无法区分止损和止盈,取消所有)
|
||||
func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
// Hyperliquid SDK 的 OpenOrder 结构不暴露 trigger 字段
|
||||
// 无法区分止损和止盈单,因此取消该币种的所有挂单
|
||||
log.Printf(" ⚠️ Hyperliquid 无法区分止损/止盈单,将取消所有挂单")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelAllOrders 取消该币种的所有挂单
|
||||
func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
@@ -501,6 +592,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)
|
||||
|
||||
@@ -36,9 +36,18 @@ type Trader interface {
|
||||
// SetTakeProfit 设置止盈单
|
||||
SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error
|
||||
|
||||
// CancelStopLossOrders 仅取消止损单(修复 BUG:调整止损时不删除止盈)
|
||||
CancelStopLossOrders(symbol string) error
|
||||
|
||||
// CancelTakeProfitOrders 仅取消止盈单(修复 BUG:调整止盈时不删除止损)
|
||||
CancelTakeProfitOrders(symbol string) error
|
||||
|
||||
// CancelAllOrders 取消该币种的所有挂单
|
||||
CancelAllOrders(symbol string) error
|
||||
|
||||
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
|
||||
CancelStopOrders(symbol string) error
|
||||
|
||||
// FormatQuantity 格式化数量到正确的精度
|
||||
FormatQuantity(symbol string, quantity float64) (string, error)
|
||||
}
|
||||
|
||||
@@ -59,14 +59,6 @@ GROUP BY used
|
||||
ORDER BY used;
|
||||
"
|
||||
|
||||
echo -e "\n📝 未使用的内测码:"
|
||||
$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c "
|
||||
SELECT code
|
||||
FROM beta_codes
|
||||
WHERE used = false
|
||||
ORDER BY created_at DESC;
|
||||
"
|
||||
|
||||
echo -e "\n👥 用户信息:"
|
||||
$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c "
|
||||
SELECT id, email, otp_verified, created_at FROM users ORDER BY created_at;
|
||||
|
||||
@@ -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
+3715
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 712 KiB |
+547
-220
File diff suppressed because it is too large
Load Diff
+753
-324
File diff suppressed because it is too large
Load Diff
+1282
-480
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,100 +9,109 @@ 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())
|
||||
@@ -111,27 +120,29 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
const entry: any = {
|
||||
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,31 +222,49 @@ 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
|
||||
style={{
|
||||
@@ -245,13 +276,16 @@ 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 }}>
|
||||
<LineChart
|
||||
data={displayData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 40 }}
|
||||
>
|
||||
<defs>
|
||||
{traders.map((trader) => (
|
||||
<linearGradient
|
||||
@@ -262,8 +296,16 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
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} />
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={traderColor(trader.trader_id)}
|
||||
stopOpacity={0.9}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={traderColor(trader.trader_id)}
|
||||
stopOpacity={0.2}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
@@ -312,8 +354,17 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
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 }}
|
||||
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
|
||||
/>
|
||||
@@ -323,13 +374,21 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
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);
|
||||
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' }}>
|
||||
<span
|
||||
style={{
|
||||
color: entry.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
@@ -337,24 +396,75 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
</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="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="text-sm md:text-base font-bold"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
PnL %
|
||||
</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>
|
||||
<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' }}
|
||||
>
|
||||
{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={{
|
||||
<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' }} />
|
||||
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={{
|
||||
<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' }} />
|
||||
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
|
||||
style={
|
||||
isWinning
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
|
||||
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)'
|
||||
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)'
|
||||
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,20 +1,22 @@
|
||||
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
|
||||
@@ -29,10 +31,10 @@ export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureC
|
||||
>
|
||||
<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)]",
|
||||
'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
|
||||
)}
|
||||
>
|
||||
@@ -53,7 +55,7 @@ export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureC
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
|
||||
backgroundSize: "32px 32px",
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -63,14 +65,15 @@ export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureC
|
||||
<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)'
|
||||
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)",
|
||||
? '0 0 20px rgba(240, 185, 11, 0.4)'
|
||||
: '0 0 0px rgba(240, 185, 11, 0)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
@@ -78,10 +81,20 @@ export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureC
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-bold mb-3" style={{ color: 'var(--brand-light-gray)' }}>{title}</h3>
|
||||
<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>
|
||||
<p
|
||||
className="mb-6 flex-grow leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Features list */}
|
||||
<div className="space-y-3 mb-6">
|
||||
@@ -95,21 +108,29 @@ export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureC
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: 'var(--brand-light-gray)' }}>{feature}</span>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
})
|
||||
|
||||
CryptoFeatureCard.displayName = "CryptoFeatureCard";
|
||||
CryptoFeatureCard.displayName = 'CryptoFeatureCard'
|
||||
|
||||
+131
-110
@@ -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,20 +106,21 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
// 限制显示最近的数据点(性能优化)
|
||||
// 如果数据超过2000个点,只显示最近2000个
|
||||
const MAX_DISPLAY_POINTS = 2000;
|
||||
const displayHistory = validHistory.length > MAX_DISPLAY_POINTS
|
||||
const MAX_DISPLAY_POINTS = 2000
|
||||
const displayHistory =
|
||||
validHistory.length > MAX_DISPLAY_POINTS
|
||||
? validHistory.slice(-MAX_DISPLAY_POINTS)
|
||||
: validHistory;
|
||||
: validHistory
|
||||
|
||||
// 计算初始余额(使用第一个有效数据点,如果无数据则从account获取,最后才用默认值)
|
||||
const initialBalance = validHistory[0]?.total_equity
|
||||
|| account?.total_equity
|
||||
|| 100; // 默认值改为100,与常见配置一致
|
||||
// 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推)
|
||||
const initialBalance = account?.initial_balance // 从交易员配置读取真实初始余额
|
||||
|| (validHistory[0] ? validHistory[0].total_equity - validHistory[0].pnl : undefined) // 备选:淨值 - 盈亏
|
||||
|| 1000; // 默认值(与创建交易员时的默认配置一致)
|
||||
|
||||
// 转换数据格式
|
||||
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,13 +278,20 @@ 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
|
||||
style={{
|
||||
@@ -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,13 +1,17 @@
|
||||
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 }) => (
|
||||
const BinanceIcon: React.FC<IconProps> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width}
|
||||
@@ -20,10 +24,14 @@ const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }
|
||||
fill="#f0b90b"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// Hyperliquid SVG 图标组件
|
||||
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
const HyperliquidIcon: React.FC<IconProps> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -37,10 +45,14 @@ const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, classNa
|
||||
fill="#97FCE4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
|
||||
// Aster SVG 图标组件
|
||||
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
const AsterIcon: React.FC<IconProps> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -50,52 +62,98 @@ const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className })
|
||||
className={className}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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
|
||||
@@ -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">
|
||||
@@ -30,11 +30,15 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
</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'
|
||||
style={
|
||||
language === 'zh'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
@@ -44,7 +48,8 @@ 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'
|
||||
style={
|
||||
language === 'en'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
@@ -55,5 +60,5 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
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)' }}>
|
||||
@@ -59,35 +59,56 @@ export function LoginPage() {
|
||||
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)' }}>
|
||||
<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)' }}>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -95,14 +116,21 @@ export function LoginPage() {
|
||||
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)' }}
|
||||
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)' }}>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -110,14 +138,24 @@ export function LoginPage() {
|
||||
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)' }}
|
||||
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)' }}>
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -126,9 +164,14 @@ export function LoginPage() {
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{loading ? t('loading', language) : t('loginButton', language)}
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('loginButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
@@ -136,21 +179,31 @@ export function LoginPage() {
|
||||
<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('scanQRCodeInstructions', language)}
|
||||
<br />
|
||||
{t('enterOTPCode', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<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))}
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
@@ -158,7 +211,13 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -168,7 +227,10 @@ export function LoginPage() {
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
@@ -178,7 +240,9 @@ export function LoginPage() {
|
||||
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)}
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('verifyOTP', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -191,8 +255,8 @@ export function LoginPage() {
|
||||
还没有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
window.history.pushState({}, '', '/register')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
className="font-semibold hover:underline transition-colors"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
@@ -204,5 +268,5 @@ export function LoginPage() {
|
||||
</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;
|
||||
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
|
||||
|
||||
let iconPath: string | null = null;
|
||||
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%' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,92 +1,96 @@
|
||||
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)' }}>
|
||||
@@ -97,20 +101,26 @@ export function RegisterPage() {
|
||||
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" />
|
||||
<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)}
|
||||
@@ -123,11 +133,20 @@ export function RegisterPage() {
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
|
||||
<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)' }}>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -135,14 +154,21 @@ export function RegisterPage() {
|
||||
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)' }}
|
||||
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)' }}>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -150,14 +176,21 @@ export function RegisterPage() {
|
||||
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)' }}
|
||||
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)' }}>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<input
|
||||
@@ -165,7 +198,11 @@ export function RegisterPage() {
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
@@ -173,15 +210,28 @@ export function RegisterPage() {
|
||||
|
||||
{betaMode && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
<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())}
|
||||
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' }}
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
placeholder="请输入6位内测码"
|
||||
maxLength={6}
|
||||
required={betaMode}
|
||||
@@ -193,7 +243,13 @@ export function RegisterPage() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -202,9 +258,14 @@ export function RegisterPage() {
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{loading ? t('loading', language) : t('registerButton', language)}
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('registerButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -213,7 +274,10 @@ export function RegisterPage() {
|
||||
<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' }}>
|
||||
<h3
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('setupTwoFactor', language)}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
@@ -222,17 +286,38 @@ export function RegisterPage() {
|
||||
</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)' }}>
|
||||
<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)' }}>
|
||||
<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)' }}>
|
||||
<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' }}>
|
||||
@@ -241,25 +326,43 @@ export function RegisterPage() {
|
||||
|
||||
{qrCodeURL && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
|
||||
<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" />
|
||||
<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-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
|
||||
<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)' }}>
|
||||
<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)' }}
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{t('copy', language)}
|
||||
</button>
|
||||
@@ -267,11 +370,23 @@ export function RegisterPage() {
|
||||
</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)' }}>
|
||||
<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)' }}>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('authStep3Desc', language)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -292,21 +407,31 @@ export function RegisterPage() {
|
||||
<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('enterOTPCode', language)}
|
||||
<br />
|
||||
{t('completeRegistrationSubtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
|
||||
<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))}
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
@@ -314,7 +439,13 @@ export function RegisterPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -324,7 +455,10 @@ export function RegisterPage() {
|
||||
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)' }}
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
@@ -334,7 +468,9 @@ export function RegisterPage() {
|
||||
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)}
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('completeRegistration', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -348,8 +484,8 @@ export function RegisterPage() {
|
||||
已有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
className="font-semibold hover:underline transition-colors"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
@@ -362,5 +498,5 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
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({
|
||||
@@ -44,9 +44,9 @@ export function TraderConfigModal({
|
||||
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) {
|
||||
if (traderData && traderData.system_prompt_template === undefined) {
|
||||
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 => {
|
||||
setSelectedCoins((prev) => {
|
||||
if (prev.includes(coin)) {
|
||||
return prev.filter(c => c !== coin);
|
||||
return prev.filter((c) => c !== coin)
|
||||
} else {
|
||||
return [...prev, coin];
|
||||
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 handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
if (!onSave) return
|
||||
|
||||
setIsSaving(true);
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const saveData: CreateTraderRequest = {
|
||||
name: formData.trader_name,
|
||||
@@ -188,15 +243,15 @@ 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();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
await onSave(saveData)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
|
||||
@@ -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,7 +356,9 @@ 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"
|
||||
@@ -302,7 +373,9 @@ 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'
|
||||
@@ -314,32 +387,76 @@ 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) => {
|
||||
const parsedValue = Number(e.target.value)
|
||||
const safeValue = Number.isFinite(parsedValue)
|
||||
? Math.max(3, parsedValue)
|
||||
: 3
|
||||
handleInputChange('scan_interval_minutes', safeValue)
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
min="3"
|
||||
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 +464,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 +504,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,7 +518,9 @@ 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"
|
||||
/>
|
||||
@@ -393,9 +528,11 @@ export function TraderConfigModal({
|
||||
{/* 币种选择器 */}
|
||||
{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 +563,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 +596,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 +626,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 +684,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
|
||||
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,7 +77,7 @@ 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">
|
||||
@@ -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,7 +102,8 @@ 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
|
||||
style={
|
||||
traderData.is_running
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
}
|
||||
@@ -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,9 +161,18 @@ 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.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 || '使用默认币种'}
|
||||
@@ -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
|
||||
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
|
||||
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='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="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)',
|
||||
}}
|
||||
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,47 +57,62 @@ 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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -91,7 +122,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -101,24 +132,30 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
|
||||
<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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -128,7 +165,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -138,24 +175,30 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
|
||||
<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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -165,7 +208,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -176,22 +219,25 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
) : (
|
||||
// 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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -201,7 +247,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -212,13 +258,14 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
</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 && [
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) }
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
@@ -229,14 +276,22 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
? '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'
|
||||
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'
|
||||
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>
|
||||
@@ -244,28 +299,69 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
|
||||
{/* 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,8 +369,11 @@ 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)}
|
||||
</button>
|
||||
@@ -285,19 +384,23 @@ 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)}
|
||||
</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)}
|
||||
</a>
|
||||
@@ -306,22 +409,33 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
)}
|
||||
|
||||
{/* 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')
|
||||
@@ -332,11 +446,14 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
}`}
|
||||
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={() => {
|
||||
@@ -348,11 +465,14 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
}`}
|
||||
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,28 +499,41 @@ 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 */}
|
||||
@@ -405,7 +542,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -414,13 +551,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href='/competition'
|
||||
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
|
||||
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 */}
|
||||
@@ -429,7 +569,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -442,18 +582,24 @@ 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 */}
|
||||
@@ -462,7 +608,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -471,18 +617,24 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
</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 */}
|
||||
@@ -491,7 +643,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -502,11 +654,12 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
)}
|
||||
|
||||
{/* Original Navigation Items - Only on home page */}
|
||||
{isHomePage && [
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) }
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
@@ -517,9 +670,17 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
? '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'
|
||||
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}
|
||||
@@ -527,22 +688,29 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
))}
|
||||
|
||||
{/* 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,20 +782,28 @@ 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'>
|
||||
{!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)' }}
|
||||
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)' }}
|
||||
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)}
|
||||
@@ -612,4 +815,3 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
|
||||
</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}>
|
||||
<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'
|
||||
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,16 +178,16 @@ 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'
|
||||
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 }}
|
||||
@@ -117,15 +195,15 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
||||
|
||||
{/* Hand Layer - Animated */}
|
||||
<motion.img
|
||||
src='/images/hand.png'
|
||||
alt='Robot Hand'
|
||||
className='absolute top-0 left-0 w-full'
|
||||
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-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 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)',
|
||||
}}
|
||||
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));
|
||||
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'));
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
|
||||
return { success: true, message: data.message };
|
||||
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));
|
||||
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'));
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
|
||||
return { success: true, message: data.message };
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { config, loading, error };
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { config, loading, error }
|
||||
}
|
||||
+195
-69
@@ -1,4 +1,4 @@
|
||||
export type Language = 'en' | 'zh';
|
||||
export type Language = 'en' | 'zh'
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
@@ -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?',
|
||||
@@ -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 (Note: Only EVM wallets are supported, Solana wallets are not supported)',
|
||||
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,38 +249,61 @@ 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',
|
||||
closeGuide: 'Close',
|
||||
whitelistIP: 'Whitelist IP',
|
||||
whitelistIPDesc: 'Binance requires adding server IP to API whitelist',
|
||||
serverIPAddresses: 'Server IP Addresses',
|
||||
copyIP: 'Copy',
|
||||
ipCopied: 'IP Copied',
|
||||
loadingServerIP: 'Loading server IP...',
|
||||
|
||||
// Error Messages
|
||||
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',
|
||||
@@ -299,12 +340,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',
|
||||
@@ -339,33 +383,40 @@ export const translations = {
|
||||
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',
|
||||
@@ -374,12 +425,18 @@ export const translations = {
|
||||
// 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...',
|
||||
@@ -388,17 +445,23 @@ export const translations = {
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -412,8 +475,24 @@ export const translations = {
|
||||
|
||||
// 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',
|
||||
|
||||
// Candidate Coins Warnings
|
||||
candidateCoins: 'Candidate Coins',
|
||||
candidateCoinsZeroWarning: 'Candidate Coins Count is 0',
|
||||
possibleReasons: 'Possible Reasons:',
|
||||
coinPoolApiNotConfigured: 'Coin pool API not configured or inaccessible (check signal source settings)',
|
||||
apiConnectionTimeout: 'API connection timeout or returned empty data',
|
||||
noCustomCoinsAndApiFailed: 'No custom coins configured and API fetch failed',
|
||||
solutions: 'Solutions:',
|
||||
setCustomCoinsInConfig: 'Set custom coin list in trader configuration',
|
||||
orConfigureCorrectApiUrl: 'Or configure correct coin pool API address',
|
||||
orDisableCoinPoolOptions: 'Or disable "Use Coin Pool" and "Use OI Top" options',
|
||||
signalSourceNotConfigured: 'Signal Source Not Configured',
|
||||
signalSourceWarningMessage: 'You have traders that enabled "Use Coin Pool" or "Use OI Top", but signal source API address is not configured yet. This will cause candidate coins count to be 0, and traders cannot work properly.',
|
||||
configureSignalSourceNow: 'Configure Signal Source Now',
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
@@ -608,7 +687,15 @@ export const translations = {
|
||||
enterPassphrase: '输入Passphrase (OKX必填)',
|
||||
hyperliquidPrivateKeyDesc: 'Hyperliquid 使用私钥进行交易认证',
|
||||
hyperliquidWalletAddressDesc: '与私钥对应的钱包地址',
|
||||
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
|
||||
asterUserDesc:
|
||||
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(注意:仅支持 EVM 钱包,不支持 Solana 钱包)',
|
||||
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: '保存配置',
|
||||
|
||||
@@ -623,13 +710,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结尾',
|
||||
@@ -648,7 +737,8 @@ export const translations = {
|
||||
coinPoolDescription: '用于获取币种池数据的API地址,留空则不使用此信号源',
|
||||
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
|
||||
information: '说明',
|
||||
signalSourceInfo1: '• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
|
||||
signalSourceInfo1:
|
||||
'• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
|
||||
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
|
||||
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
|
||||
editAIModel: '编辑AI模型',
|
||||
@@ -671,6 +761,15 @@ export const translations = {
|
||||
exchangeConfigWarning2: '• 不要授予提现权限,确保资金安全',
|
||||
exchangeConfigWarning3: '• 删除配置后,相关交易员将无法正常交易',
|
||||
edit: '编辑',
|
||||
viewGuide: '查看教程',
|
||||
binanceSetupGuide: '币安配置教程',
|
||||
closeGuide: '关闭',
|
||||
whitelistIP: '白名单IP',
|
||||
whitelistIPDesc: '币安交易所需要填写白名单IP',
|
||||
serverIPAddresses: '服务器IP地址',
|
||||
copyIP: '复制',
|
||||
ipCopied: 'IP已复制',
|
||||
loadingServerIP: '正在加载服务器IP...',
|
||||
|
||||
// Error Messages
|
||||
createTraderFailed: '创建交易员失败',
|
||||
@@ -753,12 +852,14 @@ export const translations = {
|
||||
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: '查看源码',
|
||||
|
||||
@@ -788,11 +889,13 @@ export const translations = {
|
||||
// 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 提示词和资金',
|
||||
@@ -804,15 +907,19 @@ export const translations = {
|
||||
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)
|
||||
|
||||
@@ -828,18 +935,37 @@ export const translations = {
|
||||
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;
|
||||
// Candidate Coins Warnings
|
||||
candidateCoins: '候选币种',
|
||||
candidateCoinsZeroWarning: '候选币种数量为 0',
|
||||
possibleReasons: '可能原因:',
|
||||
coinPoolApiNotConfigured: '币种池API未配置或无法访问(请检查信号源设置)',
|
||||
apiConnectionTimeout: 'API连接超时或返回数据为空',
|
||||
noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败',
|
||||
solutions: '解决方案:',
|
||||
setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表',
|
||||
orConfigureCorrectApiUrl: '或者配置正确的币种池API地址',
|
||||
orDisableCoinPoolOptions: '或者禁用"使用币种池"和"使用OI Top"选项',
|
||||
signalSourceNotConfigured: '信号源未配置',
|
||||
signalSourceWarningMessage: '您有交易员启用了"使用币种池"或"使用OI Top",但尚未配置信号源API地址。这将导致候选币种数量为0,交易员无法正常工作。',
|
||||
configureSignalSourceNow: '立即配置信号源',
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+54
-31
@@ -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 */
|
||||
--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;
|
||||
}
|
||||
|
||||
+128
-102
@@ -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,19 @@ export const api = {
|
||||
coin_pool_url: coinPoolUrl,
|
||||
oi_top_url: oiTopUrl,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败')
|
||||
},
|
||||
|
||||
// 获取服务器IP(需要认证,用于白名单配置)
|
||||
async getServerIP(): Promise<{
|
||||
public_ip: string;
|
||||
message: string;
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/server-ip`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败');
|
||||
if (!res.ok) throw new Error('获取服务器IP失败');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
+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>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user