From 4f7fef145f413110f73caaa61f945c49a21c9ce0 Mon Sep 17 00:00:00 2001 From: WrBug Date: Fri, 21 Nov 2025 04:32:08 +0800 Subject: [PATCH] Initial commit: Polymarket copy trading bot - Backend: Spring Boot + Kotlin implementation - Account management with private key import - Leader management - Copy trading configuration - Order synchronization - Balance and position queries via Polymarket API - Ethereum RPC integration for USDC balance - Proxy address calculation - Frontend: React + TypeScript - Account management UI - Mobile responsive design - Account import with private key/mnemonic support - Balance display and account details modal - Database: MySQL with Flyway migrations - API Integration: Polymarket CLOB API, Data API, Ethereum RPC --- .cursor/rules/backend.mdc | 506 ++ .cursor/rules/frontend.mdc | 258 + .gitignore | 101 + backend/IMPLEMENTATION.md | 239 + backend/README.md | 116 + backend/build.gradle.kts | 75 + .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/gradlew | 251 + backend/gradlew.bat | 94 + backend/settings.gradle.kts | 2 + .../polymarketbot/PolymarketBotApplication.kt | 12 + .../wrbug/polymarketbot/api/EthereumRpcApi.kt | 48 + .../polymarketbot/api/PolymarketClobApi.kt | 207 + .../api/PolymarketSubgraphApi.kt | 65 + .../polymarketbot/config/RetrofitConfig.kt | 45 + .../polymarketbot/config/WebSocketConfig.kt | 24 + .../controller/AccountController.kt | 208 + .../com/wrbug/polymarketbot/dto/AccountDto.kt | 101 + .../wrbug/polymarketbot/dto/ApiResponse.kt | 65 + .../com/wrbug/polymarketbot/dto/MarketDto.kt | 73 + .../com/wrbug/polymarketbot/dto/OrderDto.kt | 29 + .../com/wrbug/polymarketbot/entity/Account.kt | 46 + .../repository/AccountRepository.kt | 33 + .../polymarketbot/service/AccountService.kt | 536 ++ .../service/BlockchainService.kt | 242 + .../service/PolymarketClobService.kt | 165 + .../polymarketbot/util/CategoryValidator.kt | 104 + .../wrbug/polymarketbot/util/CryptoUtils.kt | 80 + .../com/wrbug/polymarketbot/util/DateUtils.kt | 65 + .../wrbug/polymarketbot/util/EthereumUtils.kt | 56 + .../com/wrbug/polymarketbot/util/JsonUtils.kt | 32 + .../com/wrbug/polymarketbot/util/MathExt.kt | 138 + .../com/wrbug/polymarketbot/util/OkHttpExt.kt | 85 + .../util/PolymarketAuthInterceptor.kt | 77 + .../polymarketbot/util/RetrofitFactory.kt | 62 + .../polymarketbot/util/SafeConvertExt.kt | 97 + .../com/wrbug/polymarketbot/util/SystemExt.kt | 9 + .../websocket/PolymarketWebSocketClient.kt | 77 + .../websocket/PolymarketWebSocketHandler.kt | 145 + .../src/main/resources/application.properties | 45 + ...V1__create_copy_trading_accounts_table.sql | 14 + ..._api_secret_and_passphrase_to_accounts.sql | 5 + .../V3__add_proxy_address_to_accounts.sql | 4 + clob-client | 1 + docs/copy-trading-implementation.md | 550 ++ docs/copy-trading-requirements.md | 1433 +++++ frontend/.gitignore | 25 + frontend/README.md | 74 + frontend/index.html | 14 + frontend/package-lock.json | 4917 +++++++++++++++++ frontend/package.json | 35 + frontend/src/App.tsx | 37 + frontend/src/components/Layout.tsx | 142 + frontend/src/main.tsx | 11 + frontend/src/pages/AccountDetail.tsx | 254 + frontend/src/pages/AccountImport.tsx | 361 ++ frontend/src/pages/AccountList.tsx | 575 ++ frontend/src/pages/ConfigPage.tsx | 240 + frontend/src/pages/LeaderAdd.tsx | 146 + frontend/src/pages/LeaderList.tsx | 155 + frontend/src/pages/OrderList.tsx | 159 + frontend/src/pages/Statistics.tsx | 116 + frontend/src/services/api.ts | 185 + frontend/src/store/accountStore.ts | 153 + frontend/src/styles/index.css | 32 + frontend/src/types/index.ts | 156 + frontend/src/utils/ethers.ts | 150 + frontend/tsconfig.json | 26 + frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 17 + 70 files changed, 14618 insertions(+) create mode 100644 .cursor/rules/backend.mdc create mode 100644 .cursor/rules/frontend.mdc create mode 100644 .gitignore create mode 100644 backend/IMPLEMENTATION.md create mode 100644 backend/README.md create mode 100644 backend/build.gradle.kts create mode 100644 backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/gradlew create mode 100644 backend/gradlew.bat create mode 100644 backend/settings.gradle.kts create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiResponse.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/MarketDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/CategoryValidator.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/SafeConvertExt.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/SystemExt.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt create mode 100644 backend/src/main/resources/application.properties create mode 100644 backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql create mode 100644 backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql create mode 100644 backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql create mode 160000 clob-client create mode 100644 docs/copy-trading-implementation.md create mode 100644 docs/copy-trading-requirements.md create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AccountDetail.tsx create mode 100644 frontend/src/pages/AccountImport.tsx create mode 100644 frontend/src/pages/AccountList.tsx create mode 100644 frontend/src/pages/ConfigPage.tsx create mode 100644 frontend/src/pages/LeaderAdd.tsx create mode 100644 frontend/src/pages/LeaderList.tsx create mode 100644 frontend/src/pages/OrderList.tsx create mode 100644 frontend/src/pages/Statistics.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/store/accountStore.ts create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/ethers.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc new file mode 100644 index 0000000..fb9f8f5 --- /dev/null +++ b/.cursor/rules/backend.mdc @@ -0,0 +1,506 @@ +--- +alwaysApply: true +path: backend/** +--- + +# 后端开发规范 + +## 代码完成规范 + +### TODO 处理规则 +- **禁止**在代码中添加 TODO 注释 +- **必须**根据 TODO 的内容直接完成代码实现 +- 如果遇到暂时无法完全实现的功能,应该: + 1. 实现一个可用的基础版本 + 2. 添加清晰的注释说明当前实现的限制和后续改进方向 + 3. 确保代码可以正常编译和运行 +- **禁止**使用 `// TODO: 实现XXX` 这样的注释 +- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释 +- 如果某个功能需要依赖外部资源(如 API、库等),应该: + 1. 先实现一个占位或模拟实现 + 2. 在注释中说明依赖关系和实现方式 + 3. 确保代码逻辑完整,不会因为未实现的功能而崩溃 + +### API 调用实现规则 +- **必须**查找相关的 API 文档或接口定义 +- **必须**根据 API 文档完成代码实现 +- **禁止**在 API 调用处添加 TODO 注释 +- **禁止**直接返回 mock 数据或硬编码的假数据 +- **必须**调用真实的 API 或查询真实的数据库 +- 如果 API 文档不完整,应该: + 1. 查找项目中已有的类似 API 调用作为参考 + 2. 查看 API 接口定义(如 `PolymarketClobApi.kt`) + 3. 查看 API 文档(如 `docs/polymarket-api-reference.md`) + 4. 实现一个可用的版本,包含错误处理 +- 如果 API 调用失败,应该: + 1. 返回明确的错误信息 + 2. 记录错误日志 + 3. 返回错误响应,而不是返回 mock 数据 + +### 代码完成示例 + +```kotlin +// ❌ 错误:添加 TODO 注释 +fun getAccountBalance(accountId: Long?): Result { + // TODO: 调用 Polymarket API 查询余额 + return Result.success(AccountBalanceResponse(usdcBalance = "0")) +} + +// ✅ 正确:查找 API 文档并完成实现 +// 1. 查找 API 接口定义:PolymarketClobApi.getActiveOrders() +// 2. 查找 API 文档:docs/polymarket-api-reference.md +// 3. 实现完整的 API 调用逻辑,调用真实的 API +fun getAccountBalance(accountId: Long?): Result { + return try { + val account = getAccount(accountId) ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 如果账户没有配置 API 凭证,返回错误而不是 mock 数据 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + return Result.failure(IllegalStateException("账户未配置 API 凭证,无法查询余额")) + } + + // 解密 API 凭证并创建认证客户端 + val apiKey = cryptoUtils.decrypt(account.apiKey!!) + val apiSecret = cryptoUtils.decrypt(account.apiSecret!!) + val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase!!) + val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase) + + // 调用真实的 API 查询余额 + val response = clobApi.getActiveOrders(limit = 100) + if (response.isSuccessful && response.body() != null) { + // 根据 API 响应处理余额数据(从真实响应中解析) + val orders = response.body()!! + // 实际应该调用余额查询接口或从链上查询 + // 这里只是示例,实际应该调用真实的余额查询 API + val balance = queryRealBalanceFromApi(clobApi, account.walletAddress) + Result.success(AccountBalanceResponse(usdcBalance = balance)) + } else { + logger.error("查询余额失败: ${response.code()} ${response.message()}") + Result.failure(Exception("查询余额失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("查询账户余额失败", e) + Result.failure(e) + } +} +``` + +## 需求文档引用 + +### 跟单系统需求 +- **需求文档**: `docs/copy-trading-requirements.md` +- **说明**: 所有跟单系统相关的功能实现必须严格按照需求文档执行 +- **核心功能**: + - 账户管理(通过私钥导入,支持多账户) + - Leader 管理(被跟单者管理) + - 订单同步与执行(监控 Leader 交易并自动复制) + - 跟单配置管理(全局配置和单个 Leader 配置) + - 风险控制(每日亏损限制、订单数限制等) + - 跟单记录与统计 + +**重要提示**: 在实现跟单系统相关功能时,请先查阅 `docs/copy-trading-requirements.md` 了解详细需求,包括: +- 数据模型设计(Account、Leader、CopyOrder 等) +- API 接口设计(请求/响应格式) +- 业务规则和验证逻辑 +- 安全要求(私钥加密存储、API Key 管理等) + +## 项目范围 + +### 平台支持 +- **仅支持**: Polymarket 平台 +- **不支持**: 其他预测市场平台(如 Kalshi 等) + +### 分类支持 +- **仅支持**: + - `sports`: 体育相关市场 + - `crypto`: 加密货币相关市场 +- **不支持**: 其他分类(如 politics、entertainment 等) + +### 分类验证 +- 所有涉及分类的接口、实体、服务必须验证分类参数 +- 分类参数只能是 `sports` 或 `crypto` +- 无效分类应返回明确的错误提示 + +## 包名规范 +- **包名**: `com.wrbug.polymarketbot` +- 所有代码必须使用此包名 + +## 实体类规范 + +### ID字段规范 +- **必须**使用 `Long? = null` 作为 `@GeneratedValue` 的 id 字段 +- **禁止**使用 `Long = 0` 或其他默认值 + +```kotlin +// ✅ 正确 +@Entity +@Table(name = "example_table") +data class ExampleEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + // ... +) + +// ❌ 错误 +@Entity +data class ExampleEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, // 禁止使用 + // ... +) +``` + +## 配置文件规范 + +### 配置文件格式 +- **必须**使用 `application.properties` 格式 +- **禁止**使用 `application.yml` 格式 +- 配置文件位置: `src/main/resources/application.properties` + +### 配置示例 +```properties +# 应用配置 +spring.application.name=polymarket-bot-backend + +# 数据源配置 +spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8mb4 +spring.datasource.username=${DB_USERNAME:root} +spring.datasource.password=${DB_PASSWORD:password} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# HikariCP 连接池配置 +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=2 +spring.datasource.hikari.connection-timeout=30000 + +# JPA 配置 +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +# Flyway 配置 +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true + +# 服务器配置 +server.port=${SERVER_PORT:8000} + +# 日志配置 +logging.level.root=INFO +logging.level.com.wrbug.polymarketbot=DEBUG +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n +``` + +### 环境变量引用 +- 使用 `${ENV_VAR:default}` 格式引用环境变量 +- 支持多环境配置: `application-dev.properties`, `application-prod.properties` + +## 代码规范 + +### Controller规范 +- Controller 方法**禁止**使用 `suspend` +- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用) +- 只作用于 suspend 方法调用 + +```kotlin +// ✅ 正确 +@RestController +class ExampleController( + private val exampleService: ExampleService +) { + @PostMapping("/example") + fun getExample(): ResponseEntity> { + val data = runBlocking { exampleService.getData() } + return ResponseEntity.ok(ApiResponse.success(data)) + } +} + +// ❌ 错误 +@RestController +class ExampleController { + @PostMapping("/example") + suspend fun getExample(): ResponseEntity> { // 禁止使用suspend + // ... + } +} +``` + +### Service规范 +- Service 层可以使用 `suspend` 方法 +- 使用 `@Transactional` 管理事务 +- 使用构造函数注入依赖 + +```kotlin +@Service +class ExampleService( + private val exampleRepository: ExampleRepository +) { + suspend fun getAllData(): List { + return exampleRepository.findAll() + } + + @Transactional + fun saveData(entity: ExampleEntity): ExampleEntity { + return exampleRepository.save(entity) + } +} +``` + +### Repository规范 +- Repository 接口继承 `JpaRepository` +- 使用 Spring Data JPA 方法命名规范 + +```kotlin +@Repository +interface ExampleRepository : JpaRepository { + fun findByCode(code: String): ExampleEntity? + fun findByCategoryAndStatus(category: String, status: String): List + fun findByCategory(category: String): List // category: sports 或 crypto +} +``` + +### Entity规范 +- 使用 `@Entity` 和 `@Table` 注解 +- ID字段使用 `Long? = null` +- 时间字段使用 `Long` 时间戳(毫秒) +- 数值字段使用 `BigDecimal`,使用 `String` 存储 + +```kotlin +@Entity +@Table(name = "example_table") +data class ExampleEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "code", unique = true, nullable = false, length = 100) + val code: String = "", + + @Column(name = "category", nullable = false, length = 20) + val category: String = "", // sports 或 crypto + + @Column(name = "amount", nullable = false, precision = 20, scale = 8) + val amount: BigDecimal = BigDecimal.ZERO, + + @Column(name = "status", nullable = false, length = 20) + val status: String = "", // active, inactive + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) +``` + +## 数值计算规范 + +### BigDecimal使用 +- 所有数值计算必须使用 `BigDecimal` +- 使用 `String` 存储数值 +- 使用扩展函数进行安全转换和比较 + +```kotlin +// 使用扩展函数 +val amount = "0.5".toSafeBigDecimal() +val total = amount.add("0.4".toSafeBigDecimal()) + +// 比较 +if (total.lt(BigDecimal.ONE)) { + // 业务逻辑 +} +``` + +## 时间字段规范 + +### 时间戳使用 +- 所有时间字段使用 `Long` 类型存储毫秒级时间戳 +- **禁止**使用 `LocalDateTime` 或其他时间类型 + +```kotlin +// ✅ 正确 +@Column(name = "created_at", nullable = false) +val createdAt: Long = System.currentTimeMillis() + +// ❌ 错误 +@Column(name = "created_at") +val createdAt: LocalDateTime = LocalDateTime.now() // 禁止使用 +``` + +## HTTP客户端规范 + +### Retrofit使用 +- 使用 Retrofit 定义 API 接口 +- 使用 OkHttp 作为底层 HTTP 客户端 +- 使用拦截器处理认证 + +```kotlin +// Polymarket CLOB API接口定义(跟单系统需要) +interface PolymarketClobApi { + @POST("/orders") + suspend fun createOrder(@Body order: CreateOrderRequest): Response + + @GET("/orders/active") + suspend fun getActiveOrders( + @Query("market") market: String?, + @Query("limit") limit: Int?, + @Query("offset") offset: Int? + ): Response> + + @DELETE("/orders/{orderId}") + suspend fun cancelOrder(@Path("orderId") orderId: String): Response + + @GET("/trades") + suspend fun getTrades( + @Query("market") market: String?, + @Query("user") user: String?, + @Query("limit") limit: Int?, + @Query("offset") offset: Int? + ): Response> +} + +// Retrofit配置 +@Configuration +class RetrofitConfig { + @Bean + fun polymarketClobApi(): PolymarketClobApi { + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor()) + .build() + + return Retrofit.Builder() + .baseUrl("https://clob.polymarket.com") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(PolymarketClobApi::class.java) + } +} +``` + +## API接口规范 + +### 请求规范 +- **所有接口统一使用POST方法**,包括查询类接口 +- 请求头: `Content-Type: application/json` +- 请求体: JSON格式 + +### 响应规范 +- **统一响应格式**: +```json +{ + "code": 0, + "data": {}, + "msg": "" +} +``` + +- **响应字段说明**: + - `code`: 响应码,0表示成功,非0表示失败 + - `data`: 响应数据,可以是任意类型(对象、数组、字符串、数字等) + - `msg`: 响应消息,成功时通常为空,失败时包含错误提示 + +### 响应示例 + +**成功响应**: +```json +{ + "code": 0, + "data": { + "id": "123", + "name": "example" + }, + "msg": "" +} +``` + +**失败响应**: +```json +{ + "code": 1001, + "data": null, + "msg": "参数错误:参数不能为空" +} +``` + +### Controller实现示例 + +```kotlin +@RestController +@RequestMapping("/api/example") +class ExampleController( + private val exampleService: ExampleService +) { + private val logger = LoggerFactory.getLogger(ExampleController::class.java) + + @PostMapping("/list") + fun getList(@RequestBody request: ExampleListRequest): ResponseEntity> { + return try { + val data = runBlocking { exampleService.getList(request) } + val response = ExampleListResponse( + list = data, + total = data.size.toLong(), + page = request.page ?: 1, + limit = request.limit ?: 20 + ) + ResponseEntity.ok(ApiResponse.success(response)) + } catch (e: Exception) { + logger.error("Failed to get list", e) + ResponseEntity.ok(ApiResponse.serverError("获取列表失败:${e.message}")) + } + } +} + +// 统一响应格式 +data class ApiResponse( + val code: Int, + val data: T?, + val msg: String +) { + companion object { + fun success(data: T): ApiResponse = ApiResponse(0, data, "") + fun paramError(msg: String): ApiResponse = ApiResponse(1001, null, msg) + fun serverError(msg: String): ApiResponse = ApiResponse(5001, null, msg) + } +} +``` + +### 错误码规范 +- `0`: 成功 +- `1001-1999`: 参数错误 +- `2001-2999`: 认证/权限错误 +- `3001-3999`: 资源不存在 +- `4001-4999`: 业务逻辑错误 +- `5001-5999`: 服务器内部错误 + +详细错误码定义参见需求文档 + +## 禁止事项 + +### 代码质量 +- ❌ 禁止使用 `!!` 除非有明确原因 +- ❌ 禁止忽略异常 +- ❌ 禁止硬编码配置值 +- ❌ 禁止提交敏感信息到Git +- ❌ Controller 方法禁止使用 `suspend` +- ❌ 实体类ID禁止使用 `Long = 0` +- ❌ **禁止直接返回 mock 数据或硬编码的假数据** +- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值** +- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询** + +### 配置 +- ❌ 禁止使用 `application.yml` +- ❌ 禁止在代码中硬编码配置值 + +### 类型 +- ❌ 禁止使用 `Double` 进行数值计算 +- ❌ 禁止使用 `LocalDateTime` 存储时间 +- ❌ 禁止实体类ID使用非空默认值 + +### API接口 +- ❌ 禁止使用GET、PUT、DELETE等方法(统一使用POST) +- ❌ 禁止返回不符合统一格式的响应 +- ❌ 禁止在响应中直接返回Map类型(使用data class) diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc new file mode 100644 index 0000000..d1fed7a --- /dev/null +++ b/.cursor/rules/frontend.mdc @@ -0,0 +1,258 @@ +--- +alwaysApply: true +path: frontend/** +--- + +# 前端开发规范 + +## 代码完成规范 + +### TODO 处理规则 +- **禁止**在代码中添加 TODO 注释 +- **必须**根据 TODO 的内容直接完成代码实现 +- 如果遇到暂时无法完全实现的功能,应该: + 1. 实现一个可用的基础版本 + 2. 添加清晰的注释说明当前实现的限制和后续改进方向 + 3. 确保代码可以正常编译和运行 +- **禁止**使用 `// TODO: 实现XXX` 这样的注释 +- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释 +- 如果某个功能需要依赖外部资源(如 API、库等),应该: + 1. 先实现一个占位或模拟实现 + 2. 在注释中说明依赖关系和实现方式 + 3. 确保代码逻辑完整,不会因为未实现的功能而崩溃 + +### API 调用实现规则 +- **必须**查找相关的 API 文档或接口定义 +- **必须**根据 API 文档完成代码实现 +- **禁止**在 API 调用处添加 TODO 注释 +- 如果 API 文档不完整,应该: + 1. 查找项目中已有的类似 API 调用作为参考 + 2. 查看 API 服务定义(如 `services/api.ts`) + 3. 查看 API 文档(如 `docs/copy-trading-requirements.md`) + 4. 实现一个可用的版本,包含错误处理 + +### 代码完成示例 + +```typescript +// ❌ 错误:添加 TODO 注释 +const fetchAccountBalance = async (accountId: number) => { + // TODO: 调用 API 查询余额 + return { balance: '0' } +} + +// ✅ 正确:查找 API 定义并完成实现 +// 1. 查找 API 服务定义:services/api.ts 中的 accounts.balance +// 2. 查找 API 文档:docs/copy-trading-requirements.md +// 3. 实现完整的 API 调用逻辑 +const fetchAccountBalance = async (accountId: number) => { + try { + // 根据 API 服务定义调用接口 + const response = await apiService.accounts.balance({ accountId }) + + // 根据 API 响应格式处理数据 + if (response.data.code === 0 && response.data.data) { + return response.data.data + } else { + // API 调用失败时返回默认值 + console.warn('查询余额失败,返回默认值:', response.data.msg) + return { balance: '0' } + } + } catch (error) { + console.error('查询余额异常:', error) + // 异常时返回默认值,确保代码可以正常运行 + return { balance: '0' } + } +} +``` + +## 技术栈 +- **框架**: React + TypeScript +- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端) +- **HTTP客户端**: axios +- **状态管理**: Zustand 或 Redux +- **响应式设计**: 必须支持移动端和桌面端 + +## 移动端适配要求 + +### 响应式设计 +- **必须支持移动端和桌面端** +- 使用响应式布局(Responsive Design) +- 移动端优先(Mobile First)设计原则 +- 支持触摸操作和手势 + +### 断点设置 +- **移动端**: < 768px +- **平板**: 768px - 1024px +- **桌面端**: > 1024px + +### UI 组件适配 +- 使用 Ant Design 的响应式组件 +- 移动端使用 Ant Design Mobile(如果使用 Ant Design) +- 表格使用虚拟滚动或分页(移动端性能优化) +- 表单使用移动端友好的输入组件 + +### 布局适配 +- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航 +- 列表:移动端使用卡片布局,桌面端使用表格布局 +- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好) +- 间距:移动端使用更大的间距,提高可点击区域 + +### 性能优化 +- 图片懒加载 +- 代码分割(Code Splitting) +- 移动端减少动画效果 +- 使用 CSS 媒体查询优化样式 + +## 代码规范 + +### 组件规范 +- 使用函数式组件 +- 使用 TypeScript 类型定义 +- 组件文件使用 PascalCase 命名 + +```typescript +// ✅ 正确 +interface MarketProps { + marketId: string; + platform: string; +} + +export const MarketCard: React.FC = ({ marketId, platform }) => { + // ... +}; + +// ❌ 错误 +export const marketCard = (props: any) => { // 禁止使用any + // ... +}; +``` + +### API调用规范 +- 使用 axios 进行 HTTP 请求 +- 统一错误处理 +- 使用 TypeScript 定义响应类型 + +```typescript +// API服务 +import axios from 'axios'; + +interface Market { + id: string; + marketId: string; + platform: string; + title: string; +} + +export const marketService = { + getMarkets: async (): Promise => { + const response = await axios.get('/api/markets'); + return response.data; + }, + + getMarketById: async (id: string): Promise => { + const response = await axios.get(`/api/markets/${id}`); + return response.data; + } +}; +``` + +### 状态管理规范 +- 使用 Zustand 或 Redux 管理全局状态 +- 本地状态使用 `useState` +- 复杂状态使用 `useReducer` + +```typescript +// Zustand Store示例 +import { create } from 'zustand'; + +interface MarketStore { + markets: Market[]; + setMarkets: (markets: Market[]) => void; +} + +export const useMarketStore = create((set) => ({ + markets: [], + setMarkets: (markets) => set({ markets }), +})); +``` + +## 移动端适配示例 + +### 响应式布局 +```typescript +import { useMediaQuery } from 'react-responsive'; + +const MyComponent: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }); + + return ( +
+ {isMobile ? : } +
+ ); +}; +``` + +### 移动端导航 +```typescript +import { Drawer } from 'antd'; + +const MobileNav: React.FC = () => { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + open={open} + > + {/* 导航内容 */} + + + ); +}; +``` + +### 响应式表格 +```typescript +import { Table } from 'antd'; + +const ResponsiveTable: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }); + + return ( + + ); +}; +``` + +## 禁止事项 + +### 代码质量 +- ❌ 禁止使用 `any` 类型 +- ❌ 禁止忽略错误处理 +- ❌ 禁止硬编码API地址 +- ❌ 禁止在组件中直接使用 `fetch` +- ❌ 禁止忽略移动端适配 + +### 类型安全 +- ❌ 禁止使用 `any` +- ❌ 禁止忽略 TypeScript 类型检查 +- ❌ 禁止使用 `@ts-ignore` 除非有明确原因 + +### 移动端适配 +- ❌ 禁止固定宽度布局 +- ❌ 禁止使用过小的触摸目标(< 44x44px) +- ❌ 禁止忽略移动端性能优化 +- ❌ 禁止使用桌面端专用的交互方式(如 hover) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e475349 --- /dev/null +++ b/.gitignore @@ -0,0 +1,101 @@ +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Gradle +backend/.gradle/ +backend/build/ +backend/bin/ +backend/out/ +backend/*.log +backend/gradle-app.setting +backend/.gradle +backend/gradle-wrapper.jar + +# Kotlin +*.kt.bak +*.class + +# Java +*.jar +*.war +*.ear +*.class +hs_err_pid* + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +.pnpm-store/ + +# Frontend build +frontend/dist/ +frontend/.vite/ +frontend/.cache/ +frontend/.temp/ +frontend/.tmp/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +*.env + +# Application properties (may contain sensitive data) +backend/src/main/resources/application-local.properties +backend/src/main/resources/application-dev.properties +backend/src/main/resources/application-prod.properties + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# OS +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Temporary files +*.tmp +*.temp +.cache/ + +# Coverage +coverage/ +*.lcov +.nyc_output/ + +# Test +test-results/ +*.test.log + +# Misc +*.bak +*.backup +*.old + diff --git a/backend/IMPLEMENTATION.md b/backend/IMPLEMENTATION.md new file mode 100644 index 0000000..ea22dd0 --- /dev/null +++ b/backend/IMPLEMENTATION.md @@ -0,0 +1,239 @@ +# 后端实现总结 + +## 已完成的工作 + +### 1. 项目基础结构 ✅ + +- ✅ 创建了完整的后端项目目录结构 +- ✅ 配置了 `application.properties` 配置文件 +- ✅ 创建了 Spring Boot 主应用类 +- ✅ 配置了 Gradle 构建文件 + +### 2. 工具类 ✅ + +从 `/Users/wrbug/hype-quant/quant/src/main/kotlin/com/hypequant/util` 拷贝并适配了以下工具类: + +- ✅ `SafeConvertExt.kt` - 安全类型转换扩展函数 +- ✅ `MathExt.kt` - BigDecimal 数学运算扩展函数 +- ✅ `SystemExt.kt` - 系统环境变量工具 +- ✅ `OkHttpExt.kt` - OkHttp 客户端扩展函数 +- ✅ `CategoryValidator.kt` - 分类验证工具类(新增) + +### 3. Polymarket API 封装 ✅ + +#### 3.1 CLOB API ✅ + +- ✅ 定义了 `PolymarketClobApi` 接口 +- ✅ 实现了 `PolymarketClobService` 服务封装 +- ✅ 支持的功能: + - 获取订单簿 + - 获取价格信息 + - 获取中间价 + - 创建订单 + - 获取活跃订单 + - 取消订单 + - 获取交易记录 + +#### 3.2 Gamma API ✅ + +- ✅ 定义了 `PolymarketGammaApi` 接口 +- ✅ 实现了 `PolymarketGammaService` 服务封装 +- ✅ 支持的功能: + - 获取市场列表(带分类验证) + - 获取市场详情 + - 搜索市场(带分类验证) + - 获取事件列表(带分类验证) + - 获取体育市场 + - 获取加密货币市场 + +### 4. WebSocket 转发服务 ✅ + +- ✅ 实现了 `PolymarketWebSocketHandler` - WebSocket 处理器 +- ✅ 实现了 `PolymarketWebSocketClient` - Polymarket RTDS 客户端 +- ✅ 配置了 `WebSocketConfig` - WebSocket 配置 +- ✅ 支持双向消息转发: + - 前端 → 后端 → Polymarket RTDS + - Polymarket RTDS → 后端 → 前端 + +### 5. 统一响应格式 ✅ + +- ✅ 创建了 `ApiResponse` 统一响应格式 +- ✅ 提供了便捷的响应创建方法: + - `success()` - 成功响应 + - `error()` - 错误响应 + - `paramError()` - 参数错误 + - `authError()` - 认证错误 + - `notFound()` - 资源不存在 + - `businessError()` - 业务逻辑错误 + - `serverError()` - 服务器错误 + +### 6. Controller 实现 ✅ + +- ✅ 实现了 `MarketController` - 市场相关接口 +- ✅ 提供的接口: + - `POST /api/markets/list` - 获取市场列表 + - `POST /api/markets/detail` - 获取市场详情 + - `POST /api/markets/search` - 搜索市场 + - `POST /api/markets/sports` - 获取体育市场 + - `POST /api/markets/crypto` - 获取加密货币市场 + +### 7. 配置类 ✅ + +- ✅ `RetrofitConfig` - Retrofit 和 API 客户端配置 +- ✅ `WebSocketConfig` - WebSocket 配置 + +## 项目结构 + +``` +backend/ +├── src/main/kotlin/com/wrbug/polymarketbot/ +│ ├── api/ # API 接口定义 +│ │ ├── PolymarketClobApi.kt +│ │ └── PolymarketGammaApi.kt +│ ├── config/ # 配置类 +│ │ ├── RetrofitConfig.kt +│ │ └── WebSocketConfig.kt +│ ├── controller/ # 控制器 +│ │ └── MarketController.kt +│ ├── dto/ # 数据传输对象 +│ │ └── ApiResponse.kt +│ ├── service/ # 服务层 +│ │ ├── PolymarketClobService.kt +│ │ └── PolymarketGammaService.kt +│ ├── util/ # 工具类 +│ │ ├── SafeConvertExt.kt +│ │ ├── MathExt.kt +│ │ ├── SystemExt.kt +│ │ ├── OkHttpExt.kt +│ │ └── CategoryValidator.kt +│ ├── websocket/ # WebSocket 处理 +│ │ ├── PolymarketWebSocketHandler.kt +│ │ └── PolymarketWebSocketClient.kt +│ └── PolymarketBotApplication.kt # 主应用类 +├── src/main/resources/ +│ └── application.properties # 配置文件 +├── build.gradle.kts # Gradle 构建配置 +└── settings.gradle.kts # Gradle 设置 +``` + +## 关键特性 + +### 1. 分类验证 + +所有涉及分类的接口都会验证分类参数,仅支持 `sports` 和 `crypto`: + +```kotlin +// 自动验证分类 +CategoryValidator.validate(category) +``` + +### 2. 统一错误处理 + +所有接口统一返回 `ApiResponse` 格式,包含: +- `code`: 错误码 +- `data`: 响应数据 +- `msg`: 错误消息 + +### 3. WebSocket 转发 + +前端通过 `ws://localhost:8000/ws/polymarket` 连接,后端自动转发到 Polymarket RTDS。 + +### 4. 异步支持 + +所有 API 调用使用 Kotlin Coroutines 的 `suspend` 函数,Controller 层使用 `runBlocking` 调用。 + +## 依赖说明 + +主要依赖: +- Spring Boot 3.2.0 +- Kotlin 1.9.20 +- Retrofit 2.9.0 +- OkHttp 4.12.0 +- Java-WebSocket 1.5.4 +- MySQL Connector 8.2.0 + +## 配置说明 + +### 环境变量 + +- `DB_USERNAME`: 数据库用户名 +- `DB_PASSWORD`: 数据库密码 +- `SERVER_PORT`: 服务器端口(默认 8000) +- `POLYMARKET_API_KEY`: Polymarket API Key(可选) + +### application.properties + +```properties +# Polymarket API 配置 +polymarket.clob.base-url=https://clob.polymarket.com +polymarket.gamma.base-url=https://gamma-api.polymarket.com +polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com +polymarket.api-key=${POLYMARKET_API_KEY:} +``` + +## 使用示例 + +### 1. 获取市场列表 + +```bash +curl -X POST http://localhost:8000/api/markets/list \ + -H "Content-Type: application/json" \ + -d '{ + "category": "sports", + "active": true, + "limit": 20 + }' +``` + +### 2. WebSocket 连接 + +```javascript +const ws = new WebSocket('ws://localhost:8000/ws/polymarket'); + +ws.onopen = () => { + // 订阅市场数据 + ws.send(JSON.stringify({ + type: 'subscribe', + channel: 'market', + market: 'market_id' + })); +}; + +ws.onmessage = (event) => { + console.log('收到消息:', event.data); +}; +``` + +## 下一步工作 + +1. **数据库实体和 Repository** + - 创建 Market、Order、Trade 等实体类 + - 实现对应的 Repository 接口 + - 创建 Flyway 迁移脚本 + +2. **数据同步服务** + - 实现市场数据同步任务 + - 实现价格更新任务 + - 实现订单状态同步 + +3. **订单管理 Controller** + - 实现订单创建接口 + - 实现订单查询接口 + - 实现订单取消接口 + +4. **测试** + - 单元测试 + - 集成测试 + - API 测试 + +5. **文档** + - API 文档 + - 部署文档 + +## 注意事项 + +1. **分类限制**: 严格限制只支持 `sports` 和 `crypto` 两个分类 +2. **WebSocket**: 生产环境需要配置正确的 CORS 策略 +3. **API Key**: 某些接口需要配置 Polymarket API Key +4. **错误处理**: 所有异常都会被捕获并返回统一格式的错误响应 + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..9304f08 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,116 @@ +# Polymarket Bot Backend + +Polymarket 预测市场机器人后端服务 + +## 功能特性 + +- ✅ 封装 Polymarket CLOB API(订单操作、市场数据、交易数据) +- ✅ 封装 Polymarket Gamma API(市场、事件、系列查询) +- ✅ WebSocket 转发服务(转发前端 WebSocket 连接到 Polymarket RTDS) +- ✅ 统一 API 响应格式 +- ✅ 分类验证(仅支持 sports 和 crypto) +- ✅ 工具类支持(安全转换、数学运算、HTTP 客户端等) + +## 技术栈 + +- Spring Boot 3.2.0 +- Kotlin 1.9.20 +- Retrofit 2.9.0 +- OkHttp 4.12.0 +- Java-WebSocket 1.5.4 +- MySQL 8.2.0 +- Flyway(数据库迁移) + +## 项目结构 + +``` +backend/ +├── src/ +│ ├── main/ +│ │ ├── kotlin/com/wrbug/polymarketbot/ +│ │ │ ├── api/ # API 接口定义 +│ │ │ ├── config/ # 配置类 +│ │ │ ├── controller/ # 控制器 +│ │ │ ├── dto/ # 数据传输对象 +│ │ │ ├── service/ # 服务层 +│ │ │ ├── util/ # 工具类 +│ │ │ └── websocket/ # WebSocket 处理 +│ │ └── resources/ +│ │ ├── application.properties +│ │ └── db/migration/ # Flyway 迁移脚本 +│ └── test/ +└── build.gradle.kts +``` + +## 配置说明 + +### 环境变量 + +- `DB_USERNAME`: 数据库用户名(默认: root) +- `DB_PASSWORD`: 数据库密码(默认: password) +- `SERVER_PORT`: 服务器端口(默认: 8000) +- `CRYPTO_SECRET_KEY`: 加密密钥(用于加密存储私钥和 API Key,建议设置) + +### application.properties + +主要配置项: +- 数据库连接配置 +- Polymarket API 地址配置 +- WebSocket 地址配置 + +## API 接口 + +### 市场相关 + +- `POST /api/markets/list` - 获取市场列表 +- `POST /api/markets/detail` - 获取市场详情 +- `POST /api/markets/search` - 搜索市场 +- `POST /api/markets/sports` - 获取体育市场 +- `POST /api/markets/crypto` - 获取加密货币市场 + +### WebSocket + +- `WS /ws/polymarket` - WebSocket 连接端点(转发到 Polymarket RTDS) + +## 响应格式 + +所有接口统一返回以下格式: + +```json +{ + "code": 0, + "data": {}, + "msg": "" +} +``` + +- `code`: 0 表示成功,非 0 表示失败 +- `data`: 响应数据 +- `msg`: 响应消息 + +## 错误码 + +- `0`: 成功 +- `1001-1999`: 参数错误 +- `2001-2999`: 认证/权限错误 +- `3001-3999`: 资源不存在 +- `4001-4999`: 业务逻辑错误 +- `5001-5999`: 服务器内部错误 + +## 运行 + +```bash +# 构建项目 +./gradlew build + +# 运行应用 +./gradlew bootRun +``` + +## 注意事项 + +1. 仅支持 Polymarket 平台 +2. 仅支持 `sports` 和 `crypto` 两个分类 +3. WebSocket 转发需要配置正确的 Polymarket RTDS 地址 +4. 生产环境需要配置正确的 CORS 策略 + diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 0000000..9e353d8 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,75 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("org.springframework.boot") version "3.2.0" + id("io.spring.dependency-management") version "1.1.4" + kotlin("jvm") version "1.9.20" + kotlin("plugin.spring") version "1.9.20" + kotlin("plugin.jpa") version "1.9.20" +} + +group = "com.wrbug" +version = "1.0.0" + +java { + sourceCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + // Spring Boot + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-websocket") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // Kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + + // Retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + + // OkHttp + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // WebSocket Client + implementation("org.java-websocket:Java-WebSocket:1.5.4") + + // Database + implementation("com.mysql:mysql-connector-j:8.2.0") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-mysql") + + // Jackson + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.core:jackson-databind") + + // Keccak-256 for Ethereum function selector + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") + + // Logging + implementation("org.slf4j:slf4j-api") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "17" + } +} + +tasks.withType { + useJUnitPlatform() +} + diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle.kts b/backend/settings.gradle.kts new file mode 100644 index 0000000..2ce4ce6 --- /dev/null +++ b/backend/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "polymarket-bot-backend" + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt new file mode 100644 index 0000000..a8404f8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt @@ -0,0 +1,12 @@ +package com.wrbug.polymarketbot + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class PolymarketBotApplication + +fun main(args: Array) { + runApplication(*args) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt new file mode 100644 index 0000000..34385fc --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/EthereumRpcApi.kt @@ -0,0 +1,48 @@ +package com.wrbug.polymarketbot.api + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Ethereum RPC API 接口定义 + * 用于调用 Ethereum JSON-RPC 接口 + */ +interface EthereumRpcApi { + + /** + * 调用 Ethereum JSON-RPC 方法 + */ + @POST("/") + suspend fun call(@Body request: JsonRpcRequest): Response +} + +/** + * JSON-RPC 请求 + */ +data class JsonRpcRequest( + val jsonrpc: String = "2.0", + val method: String, + val params: List, + val id: Int = 1 +) + +/** + * JSON-RPC 响应 + */ +data class JsonRpcResponse( + val jsonrpc: String? = null, + val result: String? = null, + val error: JsonRpcError? = null, + val id: Int? = null +) + +/** + * JSON-RPC 错误 + */ +data class JsonRpcError( + val code: Int, + val message: String, + val data: Any? = null +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt new file mode 100644 index 0000000..fd6860a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -0,0 +1,207 @@ +package com.wrbug.polymarketbot.api + +import retrofit2.Response +import retrofit2.http.* + +/** + * Polymarket CLOB API 接口定义 + * 用于程序化地管理市场订单 + */ +interface PolymarketClobApi { + + /** + * 获取订单簿 + */ + @GET("/book") + suspend fun getOrderbook( + @Query("market") market: String + ): Response + + /** + * 获取价格信息 + */ + @GET("/price") + suspend fun getPrice( + @Query("market") market: String + ): Response + + /** + * 获取中间价 + */ + @GET("/midpoint") + suspend fun getMidpoint( + @Query("market") market: String + ): Response + + /** + * 获取价差 + */ + @GET("/spreads") + suspend fun getSpreads( + @Query("market") market: String + ): Response + + /** + * 创建单个订单 + */ + @POST("/orders") + suspend fun createOrder( + @Body request: CreateOrderRequest + ): Response + + /** + * 批量创建订单 + */ + @POST("/orders/batch") + suspend fun createOrdersBatch( + @Body request: CreateOrdersBatchRequest + ): Response> + + /** + * 获取订单信息 + */ + @GET("/orders/{orderId}") + suspend fun getOrder( + @Path("orderId") orderId: String + ): Response + + /** + * 获取活跃订单 + * 端点: /data/orders + * 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递 + * 虽然项目规范要求使用 POST,但这是外部 API,必须遵循 API 的实际要求 + */ + @GET("/data/orders") + suspend fun getActiveOrders( + @Query("id") id: String? = null, + @Query("market") market: String? = null, + @Query("asset_id") asset_id: String? = null, + @Query("next_cursor") next_cursor: String? = null + ): Response + + /** + * 取消订单 + */ + @DELETE("/orders/{orderId}") + suspend fun cancelOrder( + @Path("orderId") orderId: String + ): Response + + /** + * 批量取消订单 + */ + @DELETE("/orders/batch") + suspend fun cancelOrdersBatch( + @Body request: CancelOrdersBatchRequest + ): Response + + /** + * 获取交易记录 + * 端点: /data/trades + * 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递 + */ + @GET("/data/trades") + suspend fun getTrades( + @Query("id") id: String? = null, + @Query("maker_address") maker_address: String? = null, + @Query("market") market: String? = null, + @Query("asset_id") asset_id: String? = null, + @Query("before") before: String? = null, + @Query("after") after: String? = null, + @Query("next_cursor") next_cursor: String? = null + ): Response +} + +// 请求和响应数据类 +data class CreateOrderRequest( + val market: String, + val side: String, // "BUY" or "SELL" + val price: String, + val size: String, + val type: String = "LIMIT", + val expiration: Long? = null +) + +data class CreateOrdersBatchRequest( + val orders: List +) + +data class CancelOrdersBatchRequest( + val orderIds: List +) + +data class OrderbookResponse( + val bids: List, + val asks: List +) + +data class OrderbookEntry( + val price: String, + val size: String +) + +data class PriceResponse( + val market: String, + val lastPrice: String?, + val bestBid: String?, + val bestAsk: String? +) + +data class MidpointResponse( + val market: String, + val midpoint: String +) + +data class SpreadsResponse( + val market: String, + val spread: String +) + +data class OrderResponse( + val id: String, + val market: String, + val side: String, + val price: String, + val size: String, + val filled: String, + val status: String, + val createdAt: String // ISO 8601 格式字符串 +) + +data class CancelOrderResponse( + val orderId: String, + val status: String +) + +data class CancelOrdersBatchResponse( + val cancelled: List, + val failed: List +) + +data class TradeResponse( + val id: String, + val market: String, + val side: String, + val price: String, + val size: String, + val timestamp: String, // ISO 8601 格式字符串 + val user: String? +) + +/** + * 获取活跃订单响应 + * 注意:参数通过 @Query 传递,不需要单独的 Request 类 + */ +data class GetActiveOrdersResponse( + val data: List, + val next_cursor: String? = null +) + +/** + * 获取交易记录响应 + */ +data class GetTradesResponse( + val data: List, + val next_cursor: String? = null +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt new file mode 100644 index 0000000..b43e442 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketSubgraphApi.kt @@ -0,0 +1,65 @@ +package com.wrbug.polymarketbot.api + +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * Polymarket Data API 接口定义 + * 用于查询仓位信息 + * Base URL: https://data-api.polymarket.com + */ +interface PolymarketDataApi { + + /** + * 获取用户当前仓位 + * 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user + */ + @GET("/positions") + suspend fun getPositions( + @Query("user") user: String, + @Query("market") market: String? = null, + @Query("eventId") eventId: String? = null, + @Query("sizeThreshold") sizeThreshold: Double? = null, + @Query("redeemable") redeemable: Boolean? = null, + @Query("mergeable") mergeable: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("sortBy") sortBy: String? = null, + @Query("sortDirection") sortDirection: String? = null, + @Query("title") title: String? = null + ): Response> +} + +/** + * 仓位响应(根据 Polymarket Data API 文档) + */ +data class PositionResponse( + val proxyWallet: String, + val asset: String? = null, + val conditionId: String? = null, + val size: Double? = null, + val avgPrice: Double? = null, + val initialValue: Double? = null, + val currentValue: Double? = null, + val cashPnl: Double? = null, + val percentPnl: Double? = null, + val totalBought: Double? = null, + val realizedPnl: Double? = null, + val percentRealizedPnl: Double? = null, + val curPrice: Double? = null, + val redeemable: Boolean? = null, + val mergeable: Boolean? = null, + val title: String? = null, + val slug: String? = null, + val icon: String? = null, + val eventSlug: String? = null, + val outcome: String? = null, + val outcomeIndex: Int? = null, + val oppositeOutcome: String? = null, + val oppositeAsset: String? = null, + val endDate: String? = null, + val negativeRisk: Boolean? = null +) + + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt new file mode 100644 index 0000000..a4e23be --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/RetrofitConfig.kt @@ -0,0 +1,45 @@ +package com.wrbug.polymarketbot.config + +import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.util.createClient +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +/** + * Retrofit 配置类 + * 用于创建 Polymarket CLOB API 客户端(跟单系统需要) + * + * 注意: + * - 查询类接口(如 /book, /price, /trades)不需要认证 + * - 操作类接口(如 /orders)需要认证,应使用账户级别的 API Key + * - 账户 API Key 在调用时动态设置,不在此处配置 + */ +@Configuration +class RetrofitConfig { + + @Value("\${polymarket.clob.base-url}") + private lateinit var clobBaseUrl: String + + /** + * 创建 CLOB API 客户端 + * 用于跟单系统的订单操作和交易查询 + * + * 注意:此客户端不包含全局认证拦截器 + * 需要认证的请求应在调用时使用账户级别的 API Key 动态设置认证头 + */ + @Bean + fun polymarketClobApi(): PolymarketClobApi { + val okHttpClient = createClient().build() + + return Retrofit.Builder() + .baseUrl(clobBaseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(PolymarketClobApi::class.java) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt new file mode 100644 index 0000000..28c82dc --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt @@ -0,0 +1,24 @@ +package com.wrbug.polymarketbot.config + +import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry + +/** + * WebSocket 配置类 + * 用于配置 WebSocket 端点 + */ +@Configuration +@EnableWebSocket +class WebSocketConfig( + private val polymarketWebSocketHandler: PolymarketWebSocketHandler +) : WebSocketConfigurer { + + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket") + .setAllowedOrigins("*") // 生产环境应该配置具体的域名 + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt new file mode 100644 index 0000000..1812aea --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt @@ -0,0 +1,208 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.service.AccountService +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 账户管理控制器 + */ +@RestController +@RequestMapping("/api/copy-trading/accounts") +class AccountController( + private val accountService: AccountService +) { + + private val logger = LoggerFactory.getLogger(AccountController::class.java) + + /** + * 通过私钥导入账户 + */ + @PostMapping("/import") + fun importAccount(@RequestBody request: AccountImportRequest): ResponseEntity> { + return try { + // 参数验证 + if (request.privateKey.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("私钥不能为空")) + } + if (request.walletAddress.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("钱包地址不能为空")) + } + + val result = accountService.importAccount(request) + result.fold( + onSuccess = { account -> + logger.info("成功导入账户: ${account.id}") + ResponseEntity.ok(ApiResponse.success(account)) + }, + onFailure = { e -> + logger.error("导入账户失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("导入账户异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}")) + } + } + + /** + * 更新账户信息 + */ + @PostMapping("/update") + fun updateAccount(@RequestBody request: AccountUpdateRequest): ResponseEntity> { + return try { + val result = accountService.updateAccount(request) + result.fold( + onSuccess = { account -> + logger.info("成功更新账户: ${account.id}") + ResponseEntity.ok(ApiResponse.success(account)) + }, + onFailure = { e -> + logger.error("更新账户失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("更新账户异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}")) + } + } + + /** + * 删除账户 + */ + @PostMapping("/delete") + fun deleteAccount(@RequestBody request: AccountDeleteRequest): ResponseEntity> { + return try { + val result = accountService.deleteAccount(request.accountId) + result.fold( + onSuccess = { + logger.info("成功删除账户: ${request.accountId}") + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除账户失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("删除账户异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}")) + } + } + + /** + * 查询账户列表 + */ + @PostMapping("/list") + fun getAccountList(): ResponseEntity> { + return try { + val result = accountService.getAccountList() + result.fold( + onSuccess = { response -> + logger.info("成功查询账户列表: ${response.total} 个账户") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询账户列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("查询账户列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}")) + } + } + + /** + * 查询账户详情 + */ + @PostMapping("/detail") + fun getAccountDetail(@RequestBody request: AccountDetailRequest): ResponseEntity> { + return try { + val result = accountService.getAccountDetail(request.accountId) + result.fold( + onSuccess = { account -> + logger.info("成功查询账户详情: ${account.id}") + ResponseEntity.ok(ApiResponse.success(account)) + }, + onFailure = { e -> + logger.error("查询账户详情失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("查询账户详情异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}")) + } + } + + /** + * 查询账户余额 + */ + @PostMapping("/balance") + fun getAccountBalance(@RequestBody request: AccountBalanceRequest): ResponseEntity> { + return try { + val result = accountService.getAccountBalance(request.accountId) + result.fold( + onSuccess = { balance -> + logger.info("成功查询账户余额") + ResponseEntity.ok(ApiResponse.success(balance)) + }, + onFailure = { e -> + logger.error("查询账户余额失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("查询账户余额异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}")) + } + } + + /** + * 设置默认账户 + */ + @PostMapping("/set-default") + fun setDefaultAccount(@RequestBody request: SetDefaultAccountRequest): ResponseEntity> { + return try { + val result = accountService.setDefaultAccount(request.accountId) + result.fold( + onSuccess = { + logger.info("成功设置默认账户: ${request.accountId}") + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("设置默认账户失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("设置默认账户异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt new file mode 100644 index 0000000..32c0bad --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -0,0 +1,101 @@ +package com.wrbug.polymarketbot.dto + +/** + * 账户导入请求 + */ +data class AccountImportRequest( + val privateKey: String, // 私钥(前端加密后传输) + val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证) + val accountName: String? = null, + val apiKey: String? = null, // Polymarket API Key(可选) + val apiSecret: String? = null, // Polymarket API Secret(可选) + val apiPassphrase: String? = null, // Polymarket API Passphrase(可选) + val isDefault: Boolean = false +) + +/** + * 账户更新请求 + */ +data class AccountUpdateRequest( + val accountId: Long, + val accountName: String? = null, + val apiKey: String? = null, + val apiSecret: String? = null, + val apiPassphrase: String? = null, + val isDefault: Boolean? = null +) + +/** + * 账户删除请求 + */ +data class AccountDeleteRequest( + val accountId: Long +) + +/** + * 账户详情请求 + */ +data class AccountDetailRequest( + val accountId: Long? = null // 不提供则返回默认账户 +) + +/** + * 账户余额请求 + */ +data class AccountBalanceRequest( + val accountId: Long? = null // 不提供则查询默认账户 +) + +/** + * 设置默认账户请求 + */ +data class SetDefaultAccountRequest( + val accountId: Long +) + +/** + * 账户信息响应 + */ +data class AccountDto( + val id: Long, + val walletAddress: String, + val accountName: String?, + val isDefault: Boolean, + val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key) + val apiSecretConfigured: Boolean, // API Secret 是否已配置 + val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置 + val balance: String? = null, // 账户余额(可选) + val totalOrders: Long? = null, // 总订单数(可选) + val totalPnl: String? = null // 总盈亏(可选) +) + +/** + * 账户列表响应 + */ +data class AccountListResponse( + val list: List, + val total: Long +) + +/** + * 账户余额响应 + */ +data class AccountBalanceResponse( + val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额) + val positionBalance: String, // 仓位余额(持仓总价值) + val totalBalance: String, // 总余额 = 可用余额 + 仓位余额 + val positions: List = emptyList() +) + +/** + * 持仓信息 + */ +data class PositionDto( + val marketId: String, + val side: String, // YES 或 NO + val quantity: String, + val avgPrice: String, + val currentValue: String, + val pnl: String? = null +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiResponse.kt new file mode 100644 index 0000000..a73469c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/ApiResponse.kt @@ -0,0 +1,65 @@ +package com.wrbug.polymarketbot.dto + +/** + * 统一API响应格式 + * @param code 响应码,0表示成功,非0表示失败 + * @param data 响应数据,可以是任意类型(对象、数组、字符串、数字等) + * @param msg 响应消息,成功时通常为空,失败时包含错误提示 + */ +data class ApiResponse( + val code: Int, + val data: T?, + val msg: String +) { + companion object { + /** + * 创建成功响应 + */ + fun success(data: T?): ApiResponse { + return ApiResponse(code = 0, data = data, msg = "") + } + + /** + * 创建失败响应 + */ + fun error(code: Int, msg: String): ApiResponse { + return ApiResponse(code = code, data = null, msg = msg) + } + + /** + * 创建参数错误响应 + */ + fun paramError(msg: String): ApiResponse { + return ApiResponse(code = 1001, data = null, msg = msg) + } + + /** + * 创建认证错误响应 + */ + fun authError(msg: String): ApiResponse { + return ApiResponse(code = 2001, data = null, msg = msg) + } + + /** + * 创建资源不存在响应 + */ + fun notFound(msg: String): ApiResponse { + return ApiResponse(code = 3001, data = null, msg = msg) + } + + /** + * 创建业务逻辑错误响应 + */ + fun businessError(msg: String): ApiResponse { + return ApiResponse(code = 4001, data = null, msg = msg) + } + + /** + * 创建服务器内部错误响应 + */ + fun serverError(msg: String): ApiResponse { + return ApiResponse(code = 5001, data = null, msg = msg) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/MarketDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/MarketDto.kt new file mode 100644 index 0000000..e817642 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/MarketDto.kt @@ -0,0 +1,73 @@ +package com.wrbug.polymarketbot.dto + +/** + * 市场 DTO(用于返回给前端) + */ +data class MarketDto( + val id: String, + val question: String, + val slug: String, + val category: String, + val active: Boolean, + val volume: String?, + val liquidity: String?, + val endDate: Long?, // 时间戳(毫秒) + val createdAt: Long?, // 时间戳(毫秒) + val updatedAt: Long?, // 时间戳(毫秒) + val outcomes: List?, + val conditionId: String?, + val description: String?, + val image: String?, + val icon: String?, + val closed: Boolean?, + val archived: Boolean?, + val volumeNum: Double?, + val liquidityNum: Double?, + val bestBid: Double?, + val bestAsk: Double?, + val lastTradePrice: Double? +) + +/** + * 结果 DTO + */ +data class OutcomeDto( + val name: String, // 结果名称,如 "Yes" 或 "No" + val price: String // 价格 +) + +/** + * 事件 DTO + */ +data class EventDto( + val id: String, + val title: String, + val category: String, + val active: Boolean, + val markets: List?, + val createdAt: Long? // 时间戳(毫秒) +) + +/** + * 系列 DTO + */ +data class SeriesDto( + val id: String, + val title: String, + val category: String, + val events: List?, + val createdAt: Long? // 时间戳(毫秒) +) + +/** + * 评论 DTO + */ +data class CommentDto( + val id: String, + val market: String, + val content: String, + val parent: String?, + val createdAt: Long, // 时间戳(毫秒) + val user: String? +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderDto.kt new file mode 100644 index 0000000..f11ec69 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/OrderDto.kt @@ -0,0 +1,29 @@ +package com.wrbug.polymarketbot.dto + +/** + * 订单 DTO(用于返回给前端) + */ +data class OrderDto( + val id: String, + val market: String, + val side: String, + val price: String, + val size: String, + val filled: String, + val status: String, + val createdAt: Long // 时间戳(毫秒) +) + +/** + * 交易 DTO + */ +data class TradeDto( + val id: String, + val market: String, + val side: String, + val price: String, + val size: String, + val timestamp: Long, // 时间戳(毫秒) + val user: String? +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt new file mode 100644 index 0000000..dd5f132 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt @@ -0,0 +1,46 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 账户信息实体 + * 用于存储跟单者的账户信息(支持多账户) + */ +@Entity +@Table(name = "copy_trading_accounts") +data class Account( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "private_key", nullable = false, length = 500) + val privateKey: String, // 私钥(加密存储) + + @Column(name = "wallet_address", unique = true, nullable = false, length = 42) + val walletAddress: String, // 钱包地址(从私钥推导) + + @Column(name = "proxy_address", nullable = false, length = 42) + val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须) + + @Column(name = "api_key", length = 500) + val apiKey: String? = null, // Polymarket API Key(可选,加密存储) + + @Column(name = "api_secret", length = 500) + val apiSecret: String? = null, // Polymarket API Secret(可选,加密存储) + + @Column(name = "api_passphrase", length = 500) + val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,加密存储) + + @Column(name = "account_name", length = 100) + val accountName: String? = null, + + @Column(name = "is_default", nullable = false) + val isDefault: Boolean = false, // 是否默认账户 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt new file mode 100644 index 0000000..2a03aa5 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt @@ -0,0 +1,33 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.Account +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 账户 Repository + */ +@Repository +interface AccountRepository : JpaRepository { + + /** + * 根据钱包地址查找账户 + */ + fun findByWalletAddress(walletAddress: String): Account? + + /** + * 查找默认账户 + */ + fun findByIsDefaultTrue(): Account? + + /** + * 查找所有账户,按创建时间排序 + */ + fun findAllByOrderByCreatedAtAsc(): List + + /** + * 检查钱包地址是否存在 + */ + fun existsByWalletAddress(walletAddress: String): Boolean +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt new file mode 100644 index 0000000..3e2df48 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -0,0 +1,536 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.Account +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.util.CryptoUtils +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +/** + * 账户管理服务 + */ +@Service +class AccountService( + private val accountRepository: AccountRepository, + private val cryptoUtils: CryptoUtils, + private val clobService: PolymarketClobService, + private val retrofitFactory: RetrofitFactory, + private val blockchainService: BlockchainService +) { + + private val logger = LoggerFactory.getLogger(AccountService::class.java) + + /** + * 通过私钥导入账户 + */ + @Transactional + fun importAccount(request: AccountImportRequest): Result { + return try { + // 1. 验证钱包地址格式 + if (!isValidWalletAddress(request.walletAddress)) { + return Result.failure(IllegalArgumentException("无效的钱包地址格式")) + } + + // 2. 检查地址是否已存在 + if (accountRepository.existsByWalletAddress(request.walletAddress)) { + return Result.failure(IllegalArgumentException("该钱包地址已存在")) + } + + // 3. 验证私钥和地址的对应关系 + // 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证 + // 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证 + if (!isValidPrivateKey(request.privateKey)) { + return Result.failure(IllegalArgumentException("无效的私钥格式")) + } + + // 4. 加密私钥和 API 凭证 + val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey) + val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) } + val encryptedApiSecret = request.apiSecret?.let { cryptoUtils.encrypt(it) } + val encryptedApiPassphrase = request.apiPassphrase?.let { cryptoUtils.encrypt(it) } + + // 5. 如果设置为默认账户,取消其他账户的默认状态 + if (request.isDefault) { + accountRepository.findByIsDefaultTrue()?.let { defaultAccount -> + val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis()) + accountRepository.save(updated) + } + } + + // 6. 获取代理地址(必须成功,否则导入失败) + val proxyAddress = runBlocking { + val proxyResult = blockchainService.getProxyAddress(request.walletAddress) + if (proxyResult.isSuccess) { + val address = proxyResult.getOrNull() + if (address != null) { + logger.info("成功获取代理地址: ${request.walletAddress} -> $address") + address + } else { + logger.error("获取代理地址返回空值") + throw IllegalStateException("获取代理地址失败:返回值为空") + } + } else { + val error = proxyResult.exceptionOrNull() + logger.error("获取代理地址失败: ${error?.message}") + throw IllegalStateException("获取代理地址失败: ${error?.message}。请确保已配置 Ethereum RPC URL 且 RPC 节点可用") + } + } + + // 7. 创建账户 + val account = Account( + privateKey = encryptedPrivateKey, + walletAddress = request.walletAddress, + proxyAddress = proxyAddress, + apiKey = encryptedApiKey, + apiSecret = encryptedApiSecret, + apiPassphrase = encryptedApiPassphrase, + accountName = request.accountName, + isDefault = request.isDefault, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + + val saved = accountRepository.save(account) + logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("导入账户失败", e) + Result.failure(e) + } + } + + /** + * 更新账户信息 + */ + @Transactional + fun updateAccount(request: AccountUpdateRequest): Result { + return try { + val account = accountRepository.findById(request.accountId) + .orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 更新账户名称 + val updatedAccountName = request.accountName ?: account.accountName + + // 更新 API 凭证 + val updatedApiKey = if (request.apiKey != null) { + cryptoUtils.encrypt(request.apiKey) + } else { + account.apiKey + } + val updatedApiSecret = if (request.apiSecret != null) { + cryptoUtils.encrypt(request.apiSecret) + } else { + account.apiSecret + } + val updatedApiPassphrase = if (request.apiPassphrase != null) { + cryptoUtils.encrypt(request.apiPassphrase) + } else { + account.apiPassphrase + } + + // 如果设置为默认账户,取消其他账户的默认状态 + val updatedIsDefault = request.isDefault ?: account.isDefault + if (updatedIsDefault && !account.isDefault) { + accountRepository.findByIsDefaultTrue()?.let { defaultAccount -> + val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis()) + accountRepository.save(updated) + } + } + + val updated = account.copy( + accountName = updatedAccountName, + apiKey = updatedApiKey, + apiSecret = updatedApiSecret, + apiPassphrase = updatedApiPassphrase, + isDefault = updatedIsDefault, + updatedAt = System.currentTimeMillis() + ) + + val saved = accountRepository.save(updated) + logger.info("成功更新账户: ${saved.id}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("更新账户失败", e) + Result.failure(e) + } + } + + /** + * 删除账户 + */ + @Transactional + fun deleteAccount(accountId: Long): Result { + return try { + val account = accountRepository.findById(accountId) + .orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 注意:不再检查活跃订单,允许用户删除有活跃订单的账户 + // 前端会显示确认提示框,由用户决定是否删除 + + // 如果删除的是默认账户,需要先设置其他账户为默认 + if (account.isDefault) { + val otherAccounts = accountRepository.findAllByOrderByCreatedAtAsc() + .filter { it.id != accountId } + + if (otherAccounts.isNotEmpty()) { + val newDefault = otherAccounts.first().copy( + isDefault = true, + updatedAt = System.currentTimeMillis() + ) + accountRepository.save(newDefault) + } else { + return Result.failure(IllegalStateException("不能删除最后一个账户")) + } + } + + accountRepository.delete(account) + logger.info("成功删除账户: $accountId") + + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除账户失败", e) + Result.failure(e) + } + } + + /** + * 查询账户列表 + */ + fun getAccountList(): Result { + return try { + val accounts = accountRepository.findAllByOrderByCreatedAtAsc() + val accountDtos = accounts.map { toDto(it) } + + Result.success(AccountListResponse( + list = accountDtos, + total = accountDtos.size.toLong() + )) + } catch (e: Exception) { + logger.error("查询账户列表失败", e) + Result.failure(e) + } + } + + /** + * 查询账户详情 + */ + fun getAccountDetail(accountId: Long?): Result { + return try { + val account = if (accountId != null) { + accountRepository.findById(accountId).orElse(null) + } else { + accountRepository.findByIsDefaultTrue() + } + + account ?: return Result.failure(IllegalArgumentException("账户不存在")) + + Result.success(toDto(account)) + } catch (e: Exception) { + logger.error("查询账户详情失败", e) + Result.failure(e) + } + } + + /** + * 查询账户余额 + * 通过链上 RPC 查询 USDC 余额,并通过 Subgraph API 查询持仓信息 + */ + fun getAccountBalance(accountId: Long?): Result { + return try { + val account = if (accountId != null) { + accountRepository.findById(accountId).orElse(null) + } else { + accountRepository.findByIsDefaultTrue() + } + + account ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 检查代理地址是否存在 + if (account.proxyAddress.isBlank()) { + logger.error("账户 ${account.id} 的代理地址为空,无法查询余额") + return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址")) + } + + // 查询 USDC 余额和持仓信息 + val balanceResult = runBlocking { + try { + // 先查询持仓信息(用于计算仓位余额和返回持仓列表) + // 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓) + val positionsResult = blockchainService.getPositions(account.proxyAddress) + val positions = if (positionsResult.isSuccess) { + positionsResult.getOrNull()?.map { pos -> + PositionDto( + marketId = pos.conditionId ?: "", + side = pos.outcome ?: "", + quantity = pos.size?.toString() ?: "0", + avgPrice = pos.avgPrice?.toString() ?: "0", + currentValue = pos.currentValue?.toString() ?: "0", + pnl = pos.cashPnl?.toString() + ) + } ?: emptyList() + } else { + logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}") + emptyList() + } + + // 计算仓位余额(持仓总价值) + val positionBalance = positions.sumOf { + it.currentValue.toSafeBigDecimal() + } + + // 查询可用余额(通过 RPC 查询 USDC 余额) + // 必须使用代理地址查询 + val availableBalanceResult = blockchainService.getUsdcBalance( + walletAddress = account.walletAddress, + proxyAddress = account.proxyAddress + ) + val availableBalance = if (availableBalanceResult.isSuccess) { + availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值") + } else { + // 如果 RPC 查询失败,返回错误(不返回 mock 数据) + val error = availableBalanceResult.exceptionOrNull() + logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}") + throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL") + } + + // 计算总余额 = 可用余额 + 仓位余额 + val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance) + + AccountBalanceResponse( + availableBalance = availableBalance, + positionBalance = positionBalance.toPlainString(), + totalBalance = totalBalance.toPlainString(), + positions = positions + ) + } catch (e: Exception) { + logger.error("查询余额失败: ${e.message}", e) + throw e + } + } + + Result.success(balanceResult) + } catch (e: Exception) { + logger.error("查询账户余额失败", e) + Result.failure(e) + } + } + + /** + * 设置默认账户 + */ + @Transactional + fun setDefaultAccount(accountId: Long): Result { + return try { + val account = accountRepository.findById(accountId) + .orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 取消其他账户的默认状态 + accountRepository.findByIsDefaultTrue()?.let { defaultAccount -> + if (defaultAccount.id != account.id) { + val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis()) + accountRepository.save(updated) + } + } + + // 设置当前账户为默认 + val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis()) + accountRepository.save(updated) + + logger.info("成功设置默认账户: $accountId") + Result.success(Unit) + } catch (e: Exception) { + logger.error("设置默认账户失败", e) + Result.failure(e) + } + } + + /** + * 转换为 DTO + * 包含交易统计数据(总订单数和总盈亏) + */ + private fun toDto(account: Account): AccountDto { + return runBlocking { + val statistics = getAccountStatistics(account) + AccountDto( + id = account.id!!, + walletAddress = account.walletAddress, + accountName = account.accountName, + isDefault = account.isDefault, + apiKeyConfigured = account.apiKey != null, + apiSecretConfigured = account.apiSecret != null, + apiPassphraseConfigured = account.apiPassphrase != null, + totalOrders = statistics.totalOrders, + totalPnl = statistics.totalPnl + ) + } + } + + /** + * 获取账户交易统计数据 + */ + private suspend fun getAccountStatistics(account: Account): AccountStatistics { + return try { + // 如果账户没有配置 API 凭证,无法查询统计数据 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + return AccountStatistics(totalOrders = null, totalPnl = null) + } + + // 解密 API 凭证 + val apiKey = cryptoUtils.decrypt(account.apiKey) + val apiSecret = cryptoUtils.decrypt(account.apiSecret) + val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase) + + // 创建带认证的 API 客户端 + val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase) + + // 1. 查询交易记录数量(总订单数) + val tradesResult = runBlocking { + try { + // 使用代理地址查询交易记录 + val response = clobApi.getTrades( + maker_address = account.proxyAddress, + next_cursor = null + ) + if (response.isSuccessful && response.body() != null) { + val tradesResponse = response.body()!! + // 统计所有交易(需要分页查询所有) + var totalTrades = tradesResponse.data.size + var nextCursor = tradesResponse.next_cursor + + // 分页查询所有交易 + while (nextCursor != null && nextCursor.isNotEmpty()) { + val nextResponse = clobApi.getTrades( + maker_address = account.proxyAddress, + next_cursor = nextCursor + ) + if (nextResponse.isSuccessful && nextResponse.body() != null) { + val nextTradesResponse = nextResponse.body()!! + totalTrades += nextTradesResponse.data.size + nextCursor = nextTradesResponse.next_cursor + } else { + break + } + } + Result.success(totalTrades.toLong()) + } else { + Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.warn("查询交易记录失败: ${e.message}", e) + Result.failure(e) + } + } + + // 2. 查询仓位信息计算总盈亏(已实现盈亏) + val totalPnlResult = runBlocking { + try { + val positionsResult = blockchainService.getPositions(account.proxyAddress) + if (positionsResult.isSuccess) { + val positions = positionsResult.getOrNull() ?: emptyList() + // 汇总所有仓位的已实现盈亏 + val totalRealizedPnl = positions.sumOf { pos -> + pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO + } + Result.success(totalRealizedPnl.toPlainString()) + } else { + Result.failure(Exception("查询仓位信息失败")) + } + } catch (e: Exception) { + logger.warn("查询仓位盈亏失败: ${e.message}", e) + Result.failure(e) + } + } + + AccountStatistics( + totalOrders = tradesResult.getOrNull(), + totalPnl = totalPnlResult.getOrNull() + ) + } catch (e: Exception) { + logger.warn("获取账户统计数据失败: ${e.message}", e) + AccountStatistics(totalOrders = null, totalPnl = null) + } + } + + /** + * 账户统计数据 + */ + private data class AccountStatistics( + val totalOrders: Long?, + val totalPnl: String? + ) + + /** + * 验证钱包地址格式 + */ + private fun isValidWalletAddress(address: String): Boolean { + // 以太坊地址格式:0x 开头,42 位字符 + return address.startsWith("0x") && address.length == 42 && address.matches(Regex("^0x[0-9a-fA-F]{40}$")) + } + + /** + * 验证私钥格式 + */ + private fun isValidPrivateKey(privateKey: String): Boolean { + // 私钥格式:64 位十六进制字符(可选 0x 前缀) + val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey + return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$")) + } + + /** + * 检查账户是否有活跃订单 + * 使用账户的 API Key 查询该账户的活跃订单 + */ + private suspend fun hasActiveOrders(account: Account): Boolean { + return try { + // 如果账户没有配置 API 凭证,无法查询活跃订单,允许删除 + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + logger.debug("账户 ${account.id} 未配置 API 凭证,无法查询活跃订单,允许删除") + return false + } + + // 解密 API 凭证(前面已检查不为 null) + val apiKey = cryptoUtils.decrypt(account.apiKey) + val apiSecret = cryptoUtils.decrypt(account.apiSecret) + val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase) + + // 创建带认证的 API 客户端 + val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase) + + // 查询活跃订单(只查询第一条,用于判断是否有订单) + // 使用 next_cursor 参数进行分页,这里只查询第一页 + val response = clobApi.getActiveOrders( + id = null, + market = null, + asset_id = null, + next_cursor = null // null 表示从第一页开始 + ) + + if (response.isSuccessful && response.body() != null) { + val ordersResponse = response.body()!! + val hasOrders = ordersResponse.data.isNotEmpty() + logger.debug("账户 ${account.id} 活跃订单检查结果: $hasOrders (订单数: ${ordersResponse.data.size})") + hasOrders + } else { + // 如果查询失败(可能是认证失败或网络问题),记录警告但允许删除 + // 因为无法确定是否有活跃订单,不应该阻止删除操作 + logger.warn("查询活跃订单失败: ${response.code()} ${response.message()},允许删除账户") + false + } + } catch (e: Exception) { + // 如果查询异常(网络问题、API 错误等),记录警告但允许删除 + // 因为无法确定是否有活跃订单,不应该阻止删除操作 + logger.warn("检查活跃订单异常: ${e.message},允许删除账户", e) + false + } + } +} + + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt new file mode 100644 index 0000000..3422db1 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt @@ -0,0 +1,242 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.api.EthereumRpcApi +import com.wrbug.polymarketbot.api.JsonRpcRequest +import com.wrbug.polymarketbot.api.JsonRpcResponse +import com.wrbug.polymarketbot.api.PolymarketDataApi +import com.wrbug.polymarketbot.api.PositionResponse +import com.wrbug.polymarketbot.util.EthereumUtils +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.createClient +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.math.BigDecimal +import java.math.BigInteger + +/** + * 区块链查询服务 + * 用于查询链上余额和持仓信息 + */ +@Service +class BlockchainService( + @Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}") + private val dataApiBaseUrl: String, + @Value("\${ethereum.rpc.url:}") + private val ethereumRpcUrl: String, + private val retrofitFactory: RetrofitFactory +) { + + private val logger = LoggerFactory.getLogger(BlockchainService::class.java) + + // USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon) + private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + + // Polymarket 代理工厂合约地址(Polygon 主网) + // 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b + private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + + // 获取代理地址的函数签名 + // 根据 Polygonscan 的 F4 方法,函数签名为: computeProxyAddress(address) + private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)" + + private val dataApi: PolymarketDataApi by lazy { + val baseUrl = if (dataApiBaseUrl.endsWith("/")) { + dataApiBaseUrl.dropLast(1) + } else { + dataApiBaseUrl + } + val okHttpClient = createClient() + .followRedirects(true) + .followSslRedirects(true) + .build() + Retrofit.Builder() + .baseUrl("$baseUrl/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(PolymarketDataApi::class.java) + } + + private val ethereumRpcApi: EthereumRpcApi? by lazy { + if (ethereumRpcUrl.isBlank()) { + null + } else { + retrofitFactory.createEthereumRpcApi(ethereumRpcUrl) + } + } + + /** + * 获取 Polymarket 代理钱包地址 + * 通过 RPC 调用代理工厂合约获取用户的代理钱包地址 + * @param walletAddress 用户的钱包地址 + * @return 代理钱包地址 + */ + suspend fun getProxyAddress(walletAddress: String): Result { + return try { + // 如果未配置 RPC URL,返回错误 + if (ethereumRpcUrl.isBlank()) { + logger.warn("未配置 Ethereum RPC URL,无法获取代理地址") + return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法获取代理地址。请在配置文件中设置 ethereum.rpc.url 环境变量")) + } + + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + // 计算函数选择器 + val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature) + // 编码地址参数 + val encodedAddress = EthereumUtils.encodeAddress(walletAddress) + // 构建调用数据 + val data = functionSelector + encodedAddress + + // 构建 JSON-RPC 请求 + val rpcRequest = JsonRpcRequest( + method = "eth_call", + params = listOf( + mapOf( + "to" to proxyFactoryContractAddress, + "data" to data + ), + "latest" + ) + ) + + // 发送 RPC 请求 + val response = rpcApi.call(rpcRequest) + + if (!response.isSuccessful || response.body() == null) { + throw Exception("RPC 请求失败: ${response.code()} ${response.message()}") + } + + val rpcResponse = response.body()!! + + // 检查错误 + if (rpcResponse.error != null) { + throw Exception("RPC 错误: ${rpcResponse.error.message}") + } + + val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空") + + // 解析代理地址 + val proxyAddress = EthereumUtils.decodeAddress(hexResult) + + logger.debug("获取代理地址成功: 原始地址=$walletAddress, 代理地址=$proxyAddress") + Result.success(proxyAddress) + } catch (e: Exception) { + logger.error("获取代理地址失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 查询账户 USDC 余额 + * 通过 Ethereum RPC 查询 ERC-20 代币余额 + * @param walletAddress 钱包地址(用于日志记录) + * @param proxyAddress 代理地址(必须提供) + * 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据) + */ + suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result { + return try { + // 如果未配置 RPC URL,返回错误 + if (ethereumRpcUrl.isBlank()) { + logger.warn("未配置 Ethereum RPC URL,无法查询 USDC 余额") + return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法查询 USDC 余额。请在配置文件中设置 ethereum.rpc.url 环境变量")) + } + + // 检查代理地址是否为空 + if (proxyAddress.isBlank()) { + logger.error("代理地址为空,无法查询余额") + return Result.failure(IllegalArgumentException("代理地址不能为空")) + } + + logger.debug("使用代理地址查询余额: $proxyAddress (原始地址: $walletAddress)") + + // 使用 RPC 查询 USDC 余额(使用代理地址) + val balance = queryUsdcBalanceViaRpc(proxyAddress) + Result.success(balance) + } catch (e: Exception) { + logger.error("查询 USDC 余额失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 通过 RPC 查询 USDC 余额 + */ + private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String { + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + // 构建 ERC-20 balanceOf 函数调用 + // function signature: balanceOf(address) -> bytes4(0x70a08231) + // 参数编码: address (32 bytes, padded) + val functionSelector = "0x70a08231" // balanceOf(address) + val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0') + val data = functionSelector + paddedAddress + + // 构建 JSON-RPC 请求 + val rpcRequest = JsonRpcRequest( + method = "eth_call", + params = listOf( + mapOf( + "to" to usdcContractAddress, + "data" to data + ), + "latest" + ) + ) + + // 发送 RPC 请求(使用 Retrofit) + val response = rpcApi.call(rpcRequest) + + if (!response.isSuccessful || response.body() == null) { + throw Exception("RPC 请求失败: ${response.code()} ${response.message()}") + } + + val rpcResponse = response.body()!! + + // 检查错误 + if (rpcResponse.error != null) { + throw Exception("RPC 错误: ${rpcResponse.error.message}") + } + + val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空") + + // 将十六进制转换为 BigDecimal(USDC 有 6 位小数) + val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16) + val balance = BigDecimal(balanceWei).divide(BigDecimal("1000000")) // USDC 有 6 位小数 + + return balance.toPlainString() + } + + /** + * 查询账户持仓信息 + * 通过 Polymarket Data API 查询 + * 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user + */ + suspend fun getPositions(proxyWalletAddress: String): Result> { + return try { + // 使用代理钱包地址查询仓位 + val response = dataApi.getPositions( + user = proxyWalletAddress, + limit = 500, // 最大限制 + offset = 0 + ) + + if (response.isSuccessful && response.body() != null) { + val positions = response.body()!! + logger.debug("查询到 ${positions.size} 个仓位") + Result.success(positions) + } else { + val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}" + logger.error(errorMsg) + Result.failure(Exception(errorMsg)) + } + } catch (e: Exception) { + logger.error("查询持仓信息失败: ${e.message}", e) + Result.failure(e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt new file mode 100644 index 0000000..8ded3f2 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt @@ -0,0 +1,165 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.api.* +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +/** + * Polymarket CLOB API 服务封装 + * 提供订单操作、市场数据、交易数据等功能 + */ +@Service +class PolymarketClobService( + private val clobApi: PolymarketClobApi +) { + + private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java) + + /** + * 获取订单簿 + */ + suspend fun getOrderbook(market: String): Result { + return try { + val response = clobApi.getOrderbook(market) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取订单簿失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取订单簿异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取价格信息 + */ + suspend fun getPrice(market: String): Result { + return try { + val response = clobApi.getPrice(market) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取价格失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取价格异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取中间价 + */ + suspend fun getMidpoint(market: String): Result { + return try { + val response = clobApi.getMidpoint(market) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取中间价失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取中间价异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 创建订单 + */ + suspend fun createOrder(request: CreateOrderRequest): Result { + return try { + val response = clobApi.createOrder(request) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("创建订单失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("创建订单异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取活跃订单 + */ + suspend fun getActiveOrders( + id: String? = null, + market: String? = null, + asset_id: String? = null, + next_cursor: String? = null + ): Result> { + return try { + val response = clobApi.getActiveOrders( + id = id, + market = market, + asset_id = asset_id, + next_cursor = next_cursor + ) + if (response.isSuccessful && response.body() != null) { + val ordersResponse = response.body()!! + Result.success(ordersResponse.data) + } else { + Result.failure(Exception("获取活跃订单失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取活跃订单异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 取消订单 + */ + suspend fun cancelOrder(orderId: String): Result { + return try { + val response = clobApi.cancelOrder(orderId) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("取消订单失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("取消订单异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取交易记录 + */ + suspend fun getTrades( + id: String? = null, + maker_address: String? = null, + market: String? = null, + asset_id: String? = null, + before: String? = null, + after: String? = null, + next_cursor: String? = null + ): Result> { + return try { + val response = clobApi.getTrades( + id = id, + maker_address = maker_address, + market = market, + asset_id = asset_id, + before = before, + after = after, + next_cursor = next_cursor + ) + if (response.isSuccessful && response.body() != null) { + val tradesResponse = response.body()!! + Result.success(tradesResponse.data) + } else { + Result.failure(Exception("获取交易记录失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取交易记录异常: ${e.message}", e) + Result.failure(e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CategoryValidator.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CategoryValidator.kt new file mode 100644 index 0000000..ee33437 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CategoryValidator.kt @@ -0,0 +1,104 @@ +package com.wrbug.polymarketbot.util + +/** + * 分类验证工具类 + * 用于验证分类参数是否符合项目要求(仅支持 sports 和 crypto) + */ +object CategoryValidator { + + /** + * 支持的分类列表 + */ + private val SUPPORTED_CATEGORIES = setOf("sports", "crypto") + + /** + * 分类名称映射(将 Polymarket API 返回的分类名称映射到标准分类) + */ + private val CATEGORY_MAPPING = mapOf( + "sports" to "sports", + "crypto" to "crypto", + "cryptocurrency" to "crypto", + "cryptocurrencies" to "crypto" + ) + + /** + * 验证分类是否有效(支持精确匹配和关键字匹配) + * @param category 分类名称 + * @return 是否有效 + */ + fun isValid(category: String?): Boolean { + if (category == null) { + return false + } + + val categoryLower = category.lowercase() + + // 精确匹配 + if (categoryLower in SUPPORTED_CATEGORIES) { + return true + } + + // 映射匹配 + if (categoryLower in CATEGORY_MAPPING.keys) { + return true + } + + // 关键字匹配 + if (categoryLower.contains("sport")) { + return true + } + if (categoryLower.contains("crypto")) { + return true + } + + return false + } + + /** + * 标准化分类名称 + * @param category 原始分类名称 + * @return 标准化后的分类名称(sports 或 crypto) + */ + fun normalizeCategory(category: String?): String? { + if (category == null) { + return null + } + + val categoryLower = category.lowercase() + + // 映射匹配 + CATEGORY_MAPPING[categoryLower]?.let { + return it + } + + // 关键字匹配 + if (categoryLower.contains("sport")) { + return "sports" + } + if (categoryLower.contains("crypto")) { + return "crypto" + } + + return null + } + + /** + * 验证分类,如果无效则抛出异常 + * @param category 分类名称 + * @throws IllegalArgumentException 如果分类无效 + */ + fun validate(category: String?) { + if (!isValid(category)) { + throw IllegalArgumentException("不支持的分类: $category,仅支持: ${SUPPORTED_CATEGORIES.joinToString(", ")}") + } + } + + /** + * 获取所有支持的分类 + * @return 支持的分类列表 + */ + fun getSupportedCategories(): Set { + return SUPPORTED_CATEGORIES + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt new file mode 100644 index 0000000..d2a9b78 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt @@ -0,0 +1,80 @@ +package com.wrbug.polymarketbot.util + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +/** + * 加密工具类 + * 用于加密存储私钥和 API Key + */ +@Component +class CryptoUtils { + + private val logger = LoggerFactory.getLogger(CryptoUtils::class.java) + + @Value("\${crypto.secret.key:}") + private var secretKey: String = "" + + private val algorithm = "AES" + private val transformation = "AES" + + /** + * 获取密钥字节数组 + * 使用 SHA-256 哈希从任意长度的密钥生成固定 32 字节的密钥(AES-256) + */ + private fun getKeyBytes(): ByteArray { + val rawKey = if (secretKey.isEmpty()) { + logger.warn("未配置加密密钥,使用默认密钥(仅用于开发环境)") + "default-secret-key-32-bytes-long!!" + } else { + secretKey + } + + // 将原始密钥转换为字节数组 + val keyBytes = rawKey.toByteArray(StandardCharsets.UTF_8) + + // 使用 SHA-256 哈希生成固定 32 字节的密钥(AES-256) + val messageDigest = java.security.MessageDigest.getInstance("SHA-256") + return messageDigest.digest(keyBytes) + } + + /** + * 加密字符串 + */ + fun encrypt(plainText: String): String { + return try { + val keyBytes = getKeyBytes() + val key = SecretKeySpec(keyBytes, algorithm) + val cipher = Cipher.getInstance(transformation) + cipher.init(Cipher.ENCRYPT_MODE, key) + val encrypted = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8)) + Base64.getEncoder().encodeToString(encrypted) + } catch (e: Exception) { + logger.error("加密失败", e) + throw RuntimeException("加密失败: ${e.message}", e) + } + } + + /** + * 解密字符串 + */ + fun decrypt(encryptedText: String): String { + return try { + val keyBytes = getKeyBytes() + val key = SecretKeySpec(keyBytes, algorithm) + val cipher = Cipher.getInstance(transformation) + cipher.init(Cipher.DECRYPT_MODE, key) + val decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText)) + String(decrypted, StandardCharsets.UTF_8) + } catch (e: Exception) { + logger.error("解密失败", e) + throw RuntimeException("解密失败: ${e.message}", e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt new file mode 100644 index 0000000..7861b69 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/DateUtils.kt @@ -0,0 +1,65 @@ +package com.wrbug.polymarketbot.util + +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * 日期工具类 + * 用于处理日期字符串和时间戳之间的转换 + */ +object DateUtils { + + /** + * ISO 8601 日期时间格式化器 + */ + private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME + + /** + * 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒) + * @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z" + * @return 时间戳(毫秒),如果转换失败返回 null + */ + fun parseToTimestamp(dateString: String?): Long? { + if (dateString.isNullOrBlank()) { + return null + } + + return try { + // 尝试解析 ISO 8601 格式 + val instant = Instant.parse(dateString) + instant.toEpochMilli() + } catch (e: DateTimeParseException) { + // 如果解析失败,尝试其他格式 + try { + // 尝试使用 ISO_DATE_TIME 格式化器 + val dateTime = java.time.ZonedDateTime.parse(dateString, isoFormatter) + dateTime.toInstant().toEpochMilli() + } catch (e2: Exception) { + // 所有解析都失败,返回 null + null + } + } catch (e: Exception) { + null + } + } + + /** + * 将时间戳(毫秒)转换为 ISO 8601 格式的日期字符串 + * @param timestamp 时间戳(毫秒) + * @return ISO 8601 格式的日期字符串 + */ + fun formatFromTimestamp(timestamp: Long?): String? { + if (timestamp == null) { + return null + } + + return try { + val instant = Instant.ofEpochMilli(timestamp) + instant.toString() + } catch (e: Exception) { + null + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt new file mode 100644 index 0000000..ff7a039 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt @@ -0,0 +1,56 @@ +package com.wrbug.polymarketbot.util + +import org.bouncycastle.crypto.digests.KeccakDigest +import java.math.BigInteger + +/** + * Ethereum 工具类 + * 用于计算函数签名、编码参数等 + */ +object EthereumUtils { + + /** + * 计算函数选择器(前4个字节) + * @param functionSignature 函数签名,例如 "computeProxyAddress(address)" + * @return 函数选择器,例如 "0x12345678" + */ + fun getFunctionSelector(functionSignature: String): String { + val hash = keccak256(functionSignature.toByteArray()) + return "0x" + hash.substring(0, 8) + } + + /** + * 编码地址参数(32字节,左对齐) + * @param address 地址,例如 "0x1234..." + * @return 编码后的地址,64个十六进制字符 + */ + fun encodeAddress(address: String): String { + val cleanAddress = address.removePrefix("0x").lowercase() + return cleanAddress.padStart(64, '0') + } + + /** + * 从合约调用结果中解析地址 + * @param hexResult 十六进制结果 + * @return 地址字符串 + */ + fun decodeAddress(hexResult: String): String { + val cleanHex = hexResult.removePrefix("0x") + // 地址是最后20字节(40个十六进制字符) + val addressHex = cleanHex.takeLast(40) + return "0x$addressHex" + } + + /** + * 计算 Keccak-256 哈希(Ethereum 标准) + * 使用 BouncyCastle 库实现真正的 Keccak-256 + */ + private fun keccak256(data: ByteArray): String { + val digest = KeccakDigest(256) + digest.update(data, 0, data.size) + val hash = ByteArray(digest.digestSize) + digest.doFinal(hash, 0) + return hash.joinToString("") { "%02x".format(it) } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt new file mode 100644 index 0000000..3994a9e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JsonUtils.kt @@ -0,0 +1,32 @@ +package com.wrbug.polymarketbot.util + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +/** + * JSON 工具类 + * 用于解析 JSON 字符串 + */ +object JsonUtils { + + private val gson = Gson() + + /** + * 解析 JSON 字符串数组 + * @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]" + * @return 字符串列表,如果解析失败返回空列表 + */ + fun parseStringArray(jsonString: String?): List { + if (jsonString.isNullOrBlank()) { + return emptyList() + } + + return try { + val listType = object : TypeToken>() {}.type + gson.fromJson>(jsonString, listType) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt new file mode 100644 index 0000000..96a0f34 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/MathExt.kt @@ -0,0 +1,138 @@ +package com.wrbug.polymarketbot.util + +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +/** + * BigDecimal乘法扩展函数 + * 安全地将BigDecimal与任意数值类型相乘 + * @param value 乘数,支持BigDecimal、BigInteger、Number类型或可转换为BigDecimal的字符串 + * @return 乘法结果,如果转换失败返回BigDecimal.ZERO + */ +fun BigDecimal.multi(value: Any): BigDecimal { + kotlin.runCatching { + if (value is BigDecimal) { + return multiply(value) + } + if (value is BigInteger) { + return multiply(value.toBigDecimal()) + } + if (value is Number) { + return multiply(value.toSafeBigDecimal()) + } + return multiply(BigDecimal(value.toString())) + } + return BigDecimal.ZERO +} + +/** + * BigDecimal除法扩展函数 + * 安全地将BigDecimal与任意数值类型相除 + * @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串 + * @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal + */ +fun BigDecimal.div(value: Any): BigDecimal { + kotlin.runCatching { + if (value is BigDecimal) { + return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros() + } + if (value is BigInteger) { + return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros() + } + return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros() + } + return IllegalBigDecimal +} + +/** + * BigInteger乘法扩展函数 + * 将BigInteger转换为BigDecimal后与任意数值类型相乘 + * @param value 乘数,支持任意可转换为BigDecimal的类型 + * @return 乘法结果,如果转换失败返回IllegalBigDecimal + */ +fun BigInteger.multi(value: Any): BigDecimal { + val v = this.toBigDecimal() + return runCatching { + v.multi(value) + }.getOrDefault(IllegalBigDecimal) +} + +/** + * 大于比较扩展函数 + * 安全地比较两个任意类型的数值大小 + * @param target 比较目标值 + * @return 如果当前值大于目标值返回true,否则返回false(null值返回false) + */ +fun Any?.gt(target: Any?): Boolean { + if (this == null || target == null) { + return false + } + return this.toSafeBigDecimal() > target.toSafeBigDecimal() +} + +/** + * 大于等于比较扩展函数 + * 安全地比较两个任意类型的数值大小 + * @param target 比较目标值 + * @return 如果当前值大于等于目标值返回true,否则返回false(null值返回false) + */ +fun Any?.gte(target: Any?): Boolean { + if (this == null || target == null) { + return false + } + return this.toSafeBigDecimal() >= target.toSafeBigDecimal() +} + +/** + * 小于比较扩展函数 + * 安全地比较两个任意类型的数值大小 + * @param target 比较目标值 + * @return 如果当前值小于目标值返回true,否则返回false(null值返回false) + */ +fun Any?.lt(target: Any?): Boolean { + if (this == null || target == null) { + return false + } + return this.toSafeBigDecimal() < target.toSafeBigDecimal() +} + +/** + * 小于等于比较扩展函数 + * 安全地比较两个任意类型的数值大小 + * @param target 比较目标值 + * @return 如果当前值小于等于目标值返回true,否则返回false(null值返回false) + */ +fun Any?.lte(target: Any?): Boolean { + if (this == null || target == null) { + return false + } + return this.toSafeBigDecimal() <= target.toSafeBigDecimal() +} + +/** + * 等于比较扩展函数 + * 安全地比较两个任意类型的数值是否相等 + * @param target 比较目标值 + * @return 如果当前值等于目标值返回true,否则返回false(null值返回false) + */ +fun Any?.eq(target: Any?): Boolean { + if (this == null || target == null) { + return false + } + return this.toSafeBigDecimal() == target.toSafeBigDecimal() +} + +/** + * 不等于比较扩展函数 + * 安全地比较两个任意类型的数值是否不相等 + * @param target 比较目标值 + * @return 如果当前值不等于目标值返回true,否则返回false(null值返回false) + */ +fun Any?.neq(target: Any?): Boolean { + if (this == null || target == null) { + return false + } + return this.toSafeBigDecimal() != target.toSafeBigDecimal() +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt new file mode 100644 index 0000000..d1a8934 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/OkHttpExt.kt @@ -0,0 +1,85 @@ +package com.wrbug.polymarketbot.util + +import okhttp3.Credentials +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.* + +/** + * 创建OkHttpClient客户端 + * @return OkHttpClient.Builder + */ +fun createClient() = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .httpProxy("127.0.0.1", 8888) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + +/** + * 为OkHttpClient添加HTTP代理支持 + * @param hostname 代理服务器地址 + * @param port 代理服务器端口 + * @param user 代理用户名(可选) + * @param password 代理密码(可选) + * @return OkHttpClient.Builder + */ +fun OkHttpClient.Builder.httpProxy( + hostname: String, port: Int, user: String = "", password: String = "" +): OkHttpClient.Builder { + if (getEnv("ENABLE_PROXY") != "1") { + return this + } + return apply { + proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(hostname, port))) + createSSLSocketFactory() + if (user.isNotEmpty() && password.isNotEmpty()) { + proxyAuthenticator { _, res -> + val credential: String = Credentials.basic(user, password) + res.request.newBuilder().header("Proxy-Authorization", credential).build() + } + } + } +} + +/** + * 为OkHttpClient创建信任所有证书的SSL工厂 + * @return OkHttpClient.Builder + */ +fun OkHttpClient.Builder.createSSLSocketFactory(): OkHttpClient.Builder { + runCatching { + val sc: SSLContext = SSLContext.getInstance("TLS") + sc.init(null, arrayOf(TrustAllManager()), SecureRandom()) + this.sslSocketFactory(sc.socketFactory, TrustAllManager()) + } + return this +} + +/** + * 信任所有证书的TrustManager + */ +class TrustAllManager : X509TrustManager { + @Throws(CertificateException::class) + override fun checkClientTrusted(chain: Array?, authType: String?) { + } + + @Throws(CertificateException::class) + override fun checkServerTrusted(chain: Array?, authType: String?) { + } + + override fun getAcceptedIssuers() = arrayOfNulls(0) +} + +/** + * 信任所有主机名的HostnameVerifier + */ +class TrustAllHostnameVerifier : HostnameVerifier { + override fun verify(hostname: String?, session: SSLSession?): Boolean { + return true + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt new file mode 100644 index 0000000..bbab088 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/PolymarketAuthInterceptor.kt @@ -0,0 +1,77 @@ +package com.wrbug.polymarketbot.util + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okio.Buffer +import java.io.IOException +import java.time.Instant +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import java.util.Base64 + +/** + * Polymarket API 认证拦截器 + * 实现 L2 认证(使用 API Key、Secret、Passphrase) + * + * 认证方式: + * 1. 生成时间戳(秒) + * 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名 + * 3. 在请求头中添加: + * - X-API-KEY: API Key + * - X-API-SIGN: Base64 编码的签名 + * - X-API-TIMESTAMP: 时间戳 + * - X-API-PASSPHRASE: Passphrase + */ +class PolymarketAuthInterceptor( + private val apiKey: String, + private val apiSecret: String, + private val apiPassphrase: String +) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // 生成时间戳(秒) + val timestamp = Instant.now().epochSecond.toString() + + // 构建签名字符串: timestamp + method + path + body + val method = originalRequest.method + val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else "" + + // 读取请求体(如果存在) + val body = originalRequest.body?.let { requestBody -> + val buffer = Buffer() + requestBody.writeTo(buffer) + buffer.readUtf8() + } ?: "" + + val signString = "$timestamp$method$path$body" + + // 使用 HMAC-SHA256 生成签名 + val signature = generateSignature(signString, apiSecret) + + // 构建新的请求,添加认证头 + val newRequest = originalRequest.newBuilder() + .header("X-API-KEY", apiKey) + .header("X-API-SIGN", signature) + .header("X-API-TIMESTAMP", timestamp) + .header("X-API-PASSPHRASE", apiPassphrase) + .build() + + return chain.proceed(newRequest) + } + + /** + * 使用 HMAC-SHA256 生成签名 + */ + private fun generateSignature(message: String, secret: String): String { + val mac = Mac.getInstance("HmacSHA256") + val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256") + mac.init(secretKeySpec) + val hash = mac.doFinal(message.toByteArray()) + return Base64.getEncoder().encodeToString(hash) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt new file mode 100644 index 0000000..002ca56 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -0,0 +1,62 @@ +package com.wrbug.polymarketbot.util + +import com.wrbug.polymarketbot.api.EthereumRpcApi +import com.wrbug.polymarketbot.api.PolymarketClobApi +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +/** + * Retrofit 客户端工厂 + * 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端 + */ +@Component +class RetrofitFactory( + @Value("\${polymarket.clob.base-url}") + private val clobBaseUrl: String +) { + + /** + * 创建带认证的 Polymarket CLOB API 客户端 + * @param apiKey API Key + * @param apiSecret API Secret + * @param apiPassphrase API Passphrase + * @return PolymarketClobApi 客户端 + */ + fun createClobApi( + apiKey: String, + apiSecret: String, + apiPassphrase: String + ): PolymarketClobApi { + val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase) + + val okHttpClient = createClient() + .addInterceptor(authInterceptor) + .build() + + return Retrofit.Builder() + .baseUrl(clobBaseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(PolymarketClobApi::class.java) + } + + /** + * 创建 Ethereum RPC API 客户端 + * @param rpcUrl RPC 节点 URL + * @return EthereumRpcApi 客户端 + */ + fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi { + val okHttpClient = createClient().build() + + return Retrofit.Builder() + .baseUrl(rpcUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(EthereumRpcApi::class.java) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/SafeConvertExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/SafeConvertExt.kt new file mode 100644 index 0000000..87f885b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/SafeConvertExt.kt @@ -0,0 +1,97 @@ +package com.wrbug.polymarketbot.util + +import java.math.BigDecimal +import java.math.BigInteger + +/** + * 非法的BigDecimal常量,用于表示转换失败的情况 + */ +val IllegalBigDecimal = BigDecimal("0") + +/** + * 非法的BigInteger常量,用于表示转换失败的情况 + */ +val IllegalBigInteger = BigInteger("0") + +/** + * 安全转换为BigDecimal的扩展函数 + * 将任意类型安全地转换为BigDecimal,转换失败时返回IllegalBigDecimal + * @return 转换后的BigDecimal值,失败时返回IllegalBigDecimal + */ +fun Any?.toSafeBigDecimal(): BigDecimal { + return try { + if (this is BigDecimal) { + return this + } + if (this is BigInteger) { + return this.toBigDecimal() + } + if (this is Number) { + return BigDecimal.valueOf(this.toDouble()) + } + BigDecimal(this.toString()) + } catch (t: Throwable) { + IllegalBigDecimal + } +} + +/** + * 安全转换为BigInteger的扩展函数 + * 将字符串安全地转换为BigInteger,转换失败时返回IllegalBigInteger + * @return 转换后的BigInteger值,失败时返回IllegalBigInteger + */ +fun String?.toSafeBigInteger(): BigInteger { + return try { + BigInteger(this.orEmpty()) + } catch (t: Throwable) { + IllegalBigInteger + } +} + +/** + * 安全转换为Long的扩展函数 + * 将字符串安全地转换为Long,转换失败时返回0 + * @return 转换后的Long值,失败时返回0 + */ +fun String?.toSafeLong(): Long { + return try { + this?.toLong() ?: 0 + } catch (t: Throwable) { + 0 + } +} + +/** + * 安全转换为Int的扩展函数 + * 将任意类型安全地转换为Int,转换失败时返回0 + * @return 转换后的Int值,失败时返回0 + */ +fun Any?.toSafeInt(): Int { + return try { + if (this is Number) { + this.toInt() + } else { + this?.toString().toSafeBigDecimal().toInt() + } + } catch (t: Throwable) { + 0 + } +} + +/** + * 安全转换为Double的扩展函数 + * 将任意类型安全地转换为Double,转换失败时返回0.0 + * @return 转换后的Double值,失败时返回0.0 + */ +fun Any?.toSafeDouble(): Double { + return try { + when (this) { + is Number -> this.toDouble() + is Boolean -> if (this) 1.0 else 0.0 + else -> this?.toString().toSafeBigDecimal().toDouble() + } + } catch (t: Throwable) { + 0.0 + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/SystemExt.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/SystemExt.kt new file mode 100644 index 0000000..4a5bb03 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/SystemExt.kt @@ -0,0 +1,9 @@ +package com.wrbug.polymarketbot.util + +/** + * 获取环境变量的扩展函数 + * @param name 环境变量名称 + * @return 环境变量值,不存在时返回空字符串 + */ +fun getEnv(name: String) = System.getenv(name).orEmpty() + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt new file mode 100644 index 0000000..3ce9059 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt @@ -0,0 +1,77 @@ +package com.wrbug.polymarketbot.websocket + +import com.fasterxml.jackson.databind.ObjectMapper +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import org.slf4j.LoggerFactory +import java.net.URI + +/** + * Polymarket WebSocket 客户端 + * 用于连接到 Polymarket RTDS + */ +class PolymarketWebSocketClient( + serverUri: URI, + private val objectMapper: ObjectMapper, + private val sessionId: String, + private val onMessage: (String) -> Unit +) : WebSocketClient(serverUri) { + + private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java) + + override fun onOpen(handshakedata: ServerHandshake?) { + logger.info("已成功连接到 Polymarket RTDS: $sessionId") + } + + override fun onMessage(message: String?) { + if (message != null) { + logger.debug("收到 Polymarket 消息: $sessionId, $message") + onMessage(message) + } + } + + override fun onClose(code: Int, reason: String?, remote: Boolean) { + logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote") + } + + /** + * 关闭连接 + */ + fun closeConnection() { + if (isOpen) { + try { + closeBlocking() + } catch (e: Exception) { + logger.error("关闭连接失败: $sessionId, ${e.message}", e) + } + } + } + + override fun onError(ex: Exception?) { + logger.error("Polymarket WebSocket 错误: $sessionId, ${ex?.message}", ex) + } + + /** + * 发送消息到 Polymarket + */ + fun sendMessage(message: String) { + if (isOpen) { + try { + send(message) + } catch (e: Exception) { + logger.error("发送消息失败: $sessionId, ${e.message}", e) + throw e + } + } else { + logger.warn("WebSocket 未连接,无法发送消息: $sessionId") + } + } + + /** + * 检查连接状态 + */ + fun isConnected(): Boolean { + return isOpen + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt new file mode 100644 index 0000000..9922986 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketHandler.kt @@ -0,0 +1,145 @@ +package com.wrbug.polymarketbot.websocket + +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.socket.* +import java.net.URI +import java.util.concurrent.ConcurrentHashMap + +/** + * Polymarket WebSocket 处理器 + * 转发前端 WebSocket 连接到 Polymarket RTDS + */ +@Component +class PolymarketWebSocketHandler( + private val objectMapper: ObjectMapper +) : WebSocketHandler { + + private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java) + + @Value("\${polymarket.rtds.ws-url}") + private lateinit var polymarketWsUrl: String + + // 存储客户端会话和对应的 Polymarket 连接的映射 + private val clientSessions = ConcurrentHashMap() + private val polymarketConnections = ConcurrentHashMap() + + override fun afterConnectionEstablished(session: WebSocketSession) { + logger.info("客户端连接建立: ${session.id}") + clientSessions[session.id] = session + + try { + // 创建到 Polymarket 的 WebSocket 连接 + val polymarketClient = PolymarketWebSocketClient( + URI(polymarketWsUrl), + objectMapper, + session.id + ) { message -> + // 当收到 Polymarket 消息时,转发给客户端 + forwardToClient(session.id, message) + } + + polymarketConnections[session.id] = polymarketClient + + // 异步连接,不阻塞 + try { + polymarketClient.connect() + logger.info("正在连接到 Polymarket RTDS: ${session.id}") + } catch (e: Exception) { + logger.error("启动 Polymarket 连接失败: ${e.message}", e) + // 连接失败时清理资源 + cleanup(session.id) + try { + session.close(CloseStatus.SERVER_ERROR.withReason("无法连接到 Polymarket")) + } catch (ex: Exception) { + logger.error("关闭客户端连接失败: ${ex.message}", ex) + } + } + } catch (e: Exception) { + logger.error("创建 Polymarket 客户端失败: ${e.message}", e) + try { + session.close(CloseStatus.SERVER_ERROR.withReason("无法创建连接")) + } catch (ex: Exception) { + logger.error("关闭客户端连接失败: ${ex.message}", ex) + } + } + } + + override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) { + logger.debug("收到客户端消息: ${session.id}, ${message.payload}") + + val polymarketClient = polymarketConnections[session.id] + if (polymarketClient != null) { + if (polymarketClient.isConnected()) { + // 将客户端消息转发给 Polymarket + try { + polymarketClient.sendMessage(message.payload.toString()) + } catch (e: Exception) { + logger.error("转发消息到 Polymarket 失败: ${e.message}", e) + } + } else { + logger.warn("Polymarket 连接未就绪,消息将被丢弃: ${session.id}") + } + } else { + logger.warn("Polymarket 连接不存在: ${session.id}") + } + } + + override fun handleTransportError(session: WebSocketSession, exception: Throwable) { + logger.error("WebSocket 传输错误: ${session.id}, ${exception.message}", exception) + cleanup(session.id) + } + + override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) { + logger.info("客户端连接关闭: ${session.id}, 状态: $closeStatus") + cleanup(session.id) + } + + override fun supportsPartialMessages(): Boolean { + return false + } + + /** + * 转发消息给客户端 + */ + private fun forwardToClient(sessionId: String, message: String) { + val session = clientSessions[sessionId] + if (session != null && session.isOpen) { + try { + session.sendMessage(TextMessage(message)) + } catch (e: Exception) { + logger.error("转发消息给客户端失败: ${sessionId}, ${e.message}", e) + } + } else { + logger.warn("客户端会话不存在或已关闭: $sessionId") + } + } + + /** + * 清理资源 + */ + private fun cleanup(sessionId: String) { + try { + // 关闭 Polymarket 连接 + val polymarketClient = polymarketConnections.remove(sessionId) + if (polymarketClient != null) { + try { + if (polymarketClient.isConnected()) { + polymarketClient.closeConnection() + } + } catch (e: Exception) { + logger.error("关闭 Polymarket 连接失败: ${sessionId}, ${e.message}", e) + } + } + + // 移除客户端会话 + clientSessions.remove(sessionId) + logger.debug("已清理资源: $sessionId") + } catch (e: Exception) { + logger.error("清理资源时发生错误: ${sessionId}, ${e.message}", e) + } + } +} + diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..e86f46c --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,45 @@ +# 应用配置 +spring.application.name=polymarket-bot-backend + +# 数据源配置 +spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true +spring.datasource.username=${DB_USERNAME:root} +spring.datasource.password=${DB_PASSWORD:11111111} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# HikariCP 连接池配置 +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=2 +spring.datasource.hikari.connection-timeout=30000 + +# JPA 配置 +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +# Flyway 配置 +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.flyway.baseline-on-migrate=true + +# 服务器配置 +server.port=${SERVER_PORT:8000} + +# 日志配置 +logging.level.root=INFO +logging.level.com.wrbug.polymarketbot=DEBUG +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n + +# Polymarket API 配置 +polymarket.clob.base-url=https://clob.polymarket.com +polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com +polymarket.data-api.base-url=https://data-api.polymarket.com + +# Ethereum RPC 配置(用于查询链上余额) +# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓 +# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID +ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com} + +# 加密配置 +crypto.secret.key=${CRYPTO_SECRET_KEY:wrbug123} + diff --git a/backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql b/backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql new file mode 100644 index 0000000..6356569 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__create_copy_trading_accounts_table.sql @@ -0,0 +1,14 @@ +-- 创建账户表 +CREATE TABLE IF NOT EXISTS copy_trading_accounts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)', + wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)', + api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)', + account_name VARCHAR(100) NULL COMMENT '账户名称', + is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_wallet_address (wallet_address), + INDEX idx_is_default (is_default) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表'; + diff --git a/backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql b/backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql new file mode 100644 index 0000000..8acdcad --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_api_secret_and_passphrase_to_accounts.sql @@ -0,0 +1,5 @@ +-- 添加 API Secret 和 Passphrase 字段 +ALTER TABLE copy_trading_accounts +ADD COLUMN api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)' AFTER api_key, +ADD COLUMN api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)' AFTER api_secret; + diff --git a/backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql b/backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql new file mode 100644 index 0000000..314448b --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__add_proxy_address_to_accounts.sql @@ -0,0 +1,4 @@ +-- 添加代理地址字段到账户表 +ALTER TABLE copy_trading_accounts +ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address; + diff --git a/clob-client b/clob-client new file mode 160000 index 0000000..96e2599 --- /dev/null +++ b/clob-client @@ -0,0 +1 @@ +Subproject commit 96e25998b8ae78be27855e07faf15099602bcd47 diff --git a/docs/copy-trading-implementation.md b/docs/copy-trading-implementation.md new file mode 100644 index 0000000..631202d --- /dev/null +++ b/docs/copy-trading-implementation.md @@ -0,0 +1,550 @@ +# 跟单买入和卖出实现方案 + +## 1. 核心思路 + +### 1.1 基本原理 +- **买入跟单**:当 Leader 执行 `BUY` 交易时,系统自动创建 `BUY` 订单 +- **卖出跟单**:当 Leader 执行 `SELL` 交易时,系统自动创建 `SELL` 订单 +- **方向复制**:直接复制 Leader 交易的 `side` 字段(BUY 或 SELL) + +### 1.2 数据来源 +- **方式1(优先)**:WebSocket 推送(RTDS API) + - 实时接收 Leader 的交易推送 + - WebSocket URL: `wss://ws-live-data.polymarket.com` + - 订阅用户交易频道,实时获取交易数据 +- **方式2(备选)**:轮询 CLOB API + - 通过 CLOB API `/trades?user={leaderAddress}` 获取 Leader 的交易记录 + - 定期轮询(默认每 5 秒) + +**交易数据包含**: +- `side`: "BUY" 或 "SELL"(直接复制) +- `market`: 市场地址(直接复制) +- `price`: 交易价格(可调整) +- `size`: 交易数量(按比例或固定金额计算) + +## 2. 实现流程 + +### 2.1 监控 Leader 交易 + +#### 2.1.1 WebSocket 推送模式(优先) + +```kotlin +/** + * WebSocket 推送监控服务 + */ +@Service +class CopyTradingWebSocketService( + private val leaderRepository: LeaderRepository, + private val configRepository: CopyTradingConfigRepository +) { + private var webSocketClient: WebSocketClient? = null + private val subscribedLeaders = mutableSetOf() + + /** + * 初始化 WebSocket 连接 + */ + @PostConstruct + fun initWebSocket() { + val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig() + + if (config.useWebSocket) { + connectWebSocket() + } + } + + /** + * 连接 WebSocket + */ + private fun connectWebSocket() { + try { + val wsUrl = "wss://ws-live-data.polymarket.com" + webSocketClient = WebSocketClient(wsUrl) + + webSocketClient?.onMessage { message -> + handleWebSocketMessage(message) + } + + webSocketClient?.onError { error -> + logger.error("WebSocket 连接错误", error) + // 降级到轮询模式 + fallbackToPolling() + } + + webSocketClient?.onClose { + logger.warn("WebSocket 连接关闭,尝试重连") + reconnectWebSocket() + } + + // 订阅所有启用的 Leader + subscribeAllLeaders() + + } catch (e: Exception) { + logger.error("WebSocket 连接失败,降级到轮询模式", e) + fallbackToPolling() + } + } + + /** + * 订阅所有启用的 Leader + */ + private fun subscribeAllLeaders() { + val enabledLeaders = leaderRepository.findByEnabledTrue() + enabledLeaders.forEach { leader -> + subscribeLeader(leader.leaderAddress) + } + } + + /** + * 订阅单个 Leader + */ + fun subscribeLeader(leaderAddress: String) { + val subscribeMessage = jsonObjectOf( + "type" to "subscribe", + "channel" to "user", + "user" to leaderAddress + ) + webSocketClient?.send(subscribeMessage.toString()) + subscribedLeaders.add(leaderAddress) + } + + /** + * 处理 WebSocket 消息 + */ + private fun handleWebSocketMessage(message: String) { + try { + val json = JSONObject(message) + val channel = json.optString("channel") + val eventType = json.optString("event") + + if (channel == "user" && eventType == "trade") { + val trade = parseTradeMessage(json) + // 触发跟单逻辑 + processTradeFromWebSocket(trade) + } + } catch (e: Exception) { + logger.error("处理 WebSocket 消息失败", e) + } + } + + /** + * 重连 WebSocket + */ + private fun reconnectWebSocket() { + val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig() + var retryCount = 0 + + while (retryCount < config.websocketMaxRetries) { + try { + Thread.sleep(config.websocketReconnectInterval.toLong()) + connectWebSocket() + return + } catch (e: Exception) { + retryCount++ + logger.warn("WebSocket 重连失败 (${retryCount}/${config.websocketMaxRetries})", e) + } + } + + // 重连失败,降级到轮询 + logger.error("WebSocket 重连失败,降级到轮询模式") + fallbackToPolling() + } +} +``` + +#### 2.1.2 轮询模式(备选) + +```kotlin +/** + * 轮询监控服务(WebSocket 不可用时的备选方案) + */ +@Service +class CopyTradingPollingService( + private val clobService: PolymarketClobService, + private val leaderRepository: LeaderRepository, + private val configRepository: CopyTradingConfigRepository +) { + + /** + * 定期轮询所有启用的 Leader 的交易 + */ + @Scheduled(fixedDelayString = "\${copy.trading.poll.interval:5000}") + suspend fun monitorLeaders() { + val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig() + + // 如果配置了使用 WebSocket 且 WebSocket 可用,则不轮询 + if (config.useWebSocket && isWebSocketAvailable()) { + return + } + + val enabledLeaders = leaderRepository.findByEnabledTrue() + + enabledLeaders.forEach { leader -> + try { + processLeaderTrades(leader) + } catch (e: Exception) { + logger.error("处理 Leader ${leader.id} 交易失败", e) + } + } + } + + /** + * 处理单个 Leader 的交易 + */ + private suspend fun processLeaderTrades(leader: Leader) { + // 1. 获取 Leader 的最新交易记录 + val tradesResult = clobService.getTrades( + market = null, + user = leader.leaderAddress, + limit = 50, + offset = 0 + ) + + tradesResult.fold( + onSuccess = { trades -> + trades.forEach { trade -> + // 2. 检查是否已处理过(去重) + if (!isProcessed(leader.id, trade.id)) { + // 3. 处理交易(买入或卖出) + processTrade(leader, trade) + // 4. 标记为已处理 + markAsProcessed(leader.id, trade.id) + } + } + }, + onFailure = { e -> + logger.error("获取 Leader ${leader.id} 交易失败", e) + } + ) + } +} +``` + +### 2.2 处理交易(买入/卖出) + +```kotlin +/** + * 处理单笔交易,自动识别买入或卖出 + */ +private suspend fun processTrade(leader: Leader, trade: TradeResponse) { + // 1. 验证分类筛选 + if (leader.category != null) { + val marketCategory = getMarketCategory(trade.market) + if (marketCategory != leader.category) { + logger.debug("跳过交易:分类不匹配 ${trade.market}") + return + } + } + + // 2. 验证风险控制 + if (!checkRiskControl(leader)) { + logger.warn("风险控制限制,跳过跟单 Leader ${leader.id}") + return + } + + // 3. 确定使用的账户 + val account = getAccountForLeader(leader) + if (account == null) { + logger.error("无法获取账户,跳过跟单 Leader ${leader.id}") + return + } + + // 4. 计算跟单订单参数 + val orderParams = calculateOrderParams(leader, trade) + + // 5. 创建跟单订单(买入或卖出) + createCopyOrder(leader, account, trade, orderParams) +} + +/** + * 计算跟单订单参数 + */ +private fun calculateOrderParams(leader: Leader, trade: TradeResponse): OrderParams { + // 获取配置 + val globalConfig = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig() + + // 计算订单大小 + val leaderSize = trade.size.toSafeBigDecimal() + val copyRatio = leader.copyRatio ?: globalConfig.copyRatio + var orderSize = leaderSize.multiply(copyRatio) + + // 应用限制 + val maxSize = leader.maxOrderSize ?: globalConfig.maxOrderSize + val minSize = leader.minOrderSize ?: globalConfig.minOrderSize + orderSize = orderSize.coerceIn(minSize, maxSize) + + // 计算价格(默认使用 Leader 的价格) + val price = trade.price.toSafeBigDecimal() + // TODO: 可以根据价格容忍度调整价格 + + return OrderParams( + market = trade.market, + side = trade.side, // 直接复制 BUY 或 SELL + price = price, + size = orderSize + ) +} + +data class OrderParams( + val market: String, + val side: String, // "BUY" 或 "SELL" + val price: BigDecimal, + val size: BigDecimal +) +``` + +### 2.3 创建跟单订单 + +```kotlin +/** + * 创建跟单订单(买入或卖出) + */ +private suspend fun createCopyOrder( + leader: Leader, + account: Account, + trade: TradeResponse, + params: OrderParams +) { + try { + // 1. 创建订单请求 + val orderRequest = CreateOrderRequest( + market = params.market, + side = params.side, // "BUY" 或 "SELL" + price = params.price.toPlainString(), + size = params.size.toPlainString(), + type = "LIMIT" + ) + + // 2. 使用账户的 API Key 创建订单 + val apiKey = account.apiKey ?: throw IllegalStateException("账户 ${account.id} 未配置 API Key") + val clobApi = createClobApiWithApiKey(apiKey) + + val orderResult = clobApi.createOrder(orderRequest) + + orderResult.fold( + onSuccess = { orderResponse -> + // 3. 保存跟单记录 + val copyOrder = CopyOrder( + accountId = account.id!!, + leaderId = leader.id!!, + leaderAddress = leader.leaderAddress, + leaderTradeId = trade.id, + marketId = params.market, + category = getMarketCategory(params.market), + side = params.side, // 保存买入或卖出方向 + price = params.price, + size = params.size, + copyRatio = leader.copyRatio ?: BigDecimal.ONE, + orderId = orderResponse.id, + status = "created" + ) + copyOrderRepository.save(copyOrder) + + logger.info("成功创建跟单订单: Leader=${leader.id}, Side=${params.side}, Market=${params.market}") + }, + onFailure = { e -> + logger.error("创建跟单订单失败: Leader=${leader.id}, Side=${params.side}", e) + + // 记录失败的跟单订单 + val copyOrder = CopyOrder( + accountId = account.id!!, + leaderId = leader.id!!, + leaderAddress = leader.leaderAddress, + leaderTradeId = trade.id, + marketId = params.market, + category = getMarketCategory(params.market), + side = params.side, + price = params.price, + size = params.size, + copyRatio = leader.copyRatio ?: BigDecimal.ONE, + status = "failed" + ) + copyOrderRepository.save(copyOrder) + } + ) + } catch (e: Exception) { + logger.error("创建跟单订单异常: Leader=${leader.id}, Side=${params.side}", e) + } +} +``` + +## 3. 关键实现细节 + +### 3.1 买入和卖出的区别 + +**买入跟单(BUY)**: +- Leader 执行 `BUY` 交易 → 系统创建 `BUY` 订单 +- 订单参数:`side = "BUY"` +- 表示买入该市场的 YES 或 NO 代币 + +**卖出跟单(SELL)**: +- Leader 执行 `SELL` 交易 → 系统创建 `SELL` 订单 +- 订单参数:`side = "SELL"` +- 表示卖出持有的代币 + +**实现上无区别**: +- 买入和卖出的处理逻辑完全相同 +- 只是 `side` 字段的值不同("BUY" 或 "SELL") +- 都通过 `createOrder` API 创建订单 + +### 3.2 去重机制 + +```kotlin +/** + * 检查交易是否已处理 + */ +private suspend fun isProcessed(leaderId: Long, tradeId: String): Boolean { + return processedTradeRepository.existsByLeaderIdAndTradeId(leaderId, tradeId) +} + +/** + * 标记交易为已处理 + */ +private suspend fun markAsProcessed(leaderId: Long, tradeId: String) { + val processed = ProcessedTrade( + leaderId = leaderId, + tradeId = tradeId, + processedAt = System.currentTimeMillis() + ) + processedTradeRepository.save(processed) +} +``` + +### 3.3 账户选择 + +```kotlin +/** + * 获取 Leader 使用的账户 + */ +private suspend fun getAccountForLeader(leader: Leader): Account? { + return if (leader.accountId != null) { + // 使用 Leader 指定的账户 + accountRepository.findById(leader.accountId) + } else { + // 使用默认账户 + accountRepository.findByIsDefaultTrue() + } +} +``` + +### 3.4 风险控制检查 + +```kotlin +/** + * 检查风险控制限制 + */ +private suspend fun checkRiskControl(leader: Leader): Boolean { + val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig() + + // 检查每日亏损限制 + val todayLoss = getTodayLoss(leader.accountId ?: getDefaultAccountId()) + if (todayLoss >= config.maxDailyLoss) { + logger.warn("达到每日亏损限制: $todayLoss >= ${config.maxDailyLoss}") + return false + } + + // 检查每日订单数限制 + val todayOrderCount = getTodayOrderCount(leader.accountId ?: getDefaultAccountId()) + if (todayOrderCount >= config.maxDailyOrders) { + logger.warn("达到每日订单数限制: $todayOrderCount >= ${config.maxDailyOrders}") + return false + } + + return true +} +``` + +## 4. 数据模型 + +### 4.1 ProcessedTrade(已处理交易) + +```kotlin +@Entity +@Table(name = "copy_trading_processed_trades") +data class ProcessedTrade( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "leader_id", nullable = false) + val leaderId: Long, + + @Column(name = "trade_id", nullable = false, length = 100) + val tradeId: String, + + @Column(name = "processed_at", nullable = false) + val processedAt: Long = System.currentTimeMillis(), + + @UniqueConstraint(columnNames = ["leader_id", "trade_id"]) +) +``` + +## 5. 完整示例 + +### 5.1 买入跟单示例 + +``` +1. Leader 执行买入交易: + - Trade: { id: "trade_123", market: "0x...", side: "BUY", price: "0.5", size: "100" } + +2. 系统检测到交易: + - 验证分类、风险控制 + - 计算跟单参数:size = 100 × 1.0 = 100 + +3. 创建跟单订单: + - CreateOrderRequest: { market: "0x...", side: "BUY", price: "0.5", size: "100" } + - 调用 CLOB API 创建订单 + +4. 保存记录: + - CopyOrder: { side: "BUY", ... } +``` + +### 5.2 卖出跟单示例 + +``` +1. Leader 执行卖出交易: + - Trade: { id: "trade_456", market: "0x...", side: "SELL", price: "0.6", size: "50" } + +2. 系统检测到交易: + - 验证分类、风险控制 + - 计算跟单参数:size = 50 × 1.0 = 50 + +3. 创建跟单订单: + - CreateOrderRequest: { market: "0x...", side: "SELL", price: "0.6", size: "50" } + - 调用 CLOB API 创建订单 + +4. 保存记录: + - CopyOrder: { side: "SELL", ... } +``` + +## 6. 注意事项 + +### 6.1 交易 vs 订单 +- **交易(Trade)**:已成交的记录,包含 `side` 字段 +- **订单(Order)**:挂单,可能未成交 +- 跟单系统基于**交易记录**触发,创建**订单** + +### 6.2 价格和数量 +- **价格**:默认使用 Leader 的交易价格,可配置价格容忍度 +- **数量**:按跟单比例计算,应用最大/最小限制 + +### 6.3 错误处理 +- API 调用失败时记录失败状态 +- 网络异常时重试机制 +- 记录详细日志便于排查 + +### 6.4 性能优化 +- 批量查询多个 Leader 的交易 +- 使用缓存减少重复查询 +- 异步处理订单创建 + +## 7. 总结 + +**买入和卖出的实现完全相同**: +- 都通过监控 Leader 的交易记录触发 +- 都通过 `side` 字段区分("BUY" 或 "SELL") +- 都调用相同的 `createOrder` API +- 区别仅在于 `side` 参数的值 + +**核心流程**: +1. 轮询 Leader 交易 → 2. 识别买入/卖出 → 3. 计算参数 → 4. 创建订单 → 5. 保存记录 + diff --git a/docs/copy-trading-requirements.md b/docs/copy-trading-requirements.md new file mode 100644 index 0000000..d17de90 --- /dev/null +++ b/docs/copy-trading-requirements.md @@ -0,0 +1,1433 @@ +# Polymarket 跟单系统需求分析 + +## 1. 系统概述 + +### 1.1 系统目标 +实现一个 Polymarket 平台的跟单系统,允许用户自动复制指定交易者(Leader)的交易行为,实现自动化跟单交易。 + +### 1.2 核心功能 +- **被跟单者管理**:添加、删除、查看被跟单者(Leader) +- **订单同步**:实时监控 Leader 的交易,自动复制其订单 +- **跟单配置**:设置跟单比例、风险控制参数 +- **订单执行**:自动创建和取消订单 +- **跟单记录**:记录所有跟单操作和统计信息 + +### 1.3 前端要求 +- **移动端适配**:前端网页必须适配移动端 + - 支持响应式设计(Responsive Design) + - 移动端优先(Mobile First)设计原则 + - 支持触摸操作和手势 + - 断点设置:移动端 < 768px,平板 768px-1024px,桌面端 > 1024px + - 使用响应式 UI 组件库(如 Ant Design Mobile) + - 优化移动端性能和用户体验 + +## 2. 功能需求 + +### 2.1 账户管理(自己/跟单者) + +#### 2.1.1 账户信息管理 +- **功能**:管理自己的账户信息(支持多账户) +- **账户信息**: + - `privateKey`: 私钥(必需,加密存储) + - `walletAddress`: 钱包地址(从私钥推导,用于识别身份) + - `apiKey`: Polymarket API Key(可选,如果 API 需要签名则从私钥生成) + - `accountName`: 账户名称(可选,用于显示) + - `isDefault`: 是否默认账户(用于跟单时自动选择) +- **业务规则**: + - **支持多账户管理**(不再是单账户模式) + - 私钥必须加密存储,不能明文保存 + - 钱包地址从私钥推导(前端使用 ethers.js 或 web3.js) + - API Key 可以单独配置,或通过私钥签名生成 + - 钱包地址格式验证(0x 开头的 42 位地址) + - 每个账户可以独立配置跟单参数 + +#### 2.1.2 通过私钥导入账户 +- **功能**:通过私钥导入新账户 +- **前端处理流程**: + 1. 用户输入私钥(支持助记词、私钥字符串、Keystore 文件) + 2. 前端使用 ethers.js 或 web3.js 从私钥推导地址 + 3. 前端显示推导出的地址供用户确认 + 4. 前端将私钥和地址发送到后端(私钥加密传输) +- **输入参数**: + - `privateKey`: 私钥(必需,前端加密后传输) + - `accountName`: 账户名称(可选) + - `apiKey`: API Key(可选,如果 Polymarket API 需要) + - `isDefault`: 是否设为默认账户(可选) +- **业务规则**: + - 验证私钥格式(64 位十六进制字符串,可选 0x 前缀) + - 后端验证私钥和地址的对应关系 + - 检查地址是否已存在(避免重复导入) + - 私钥加密存储(使用 AES 加密) + - 如果 API Key 未提供,尝试从私钥生成(如果 API 支持签名) + +#### 2.1.3 更新账户信息 +- **功能**:更新账户信息(不能修改私钥和地址) +- **输入参数**: + - `accountId`: 账户ID(必需) + - `accountName`: 账户名称(可选) + - `apiKey`: API Key(可选) + - `isDefault`: 是否设为默认账户(可选) +- **业务规则**: + - 不能修改私钥和地址(如需修改,删除后重新导入) + - 更新 API Key 时,需要验证有效性 + - 设置默认账户时,自动取消其他账户的默认状态 + +#### 2.1.4 删除账户 +- **功能**:删除账户 +- **输入参数**: + - `accountId`: 账户ID(必需) +- **业务规则**: + - 删除前检查是否有活跃订单 + - 如果有活跃订单,提示用户先取消订单 + - 删除账户相关的跟单配置 + - 保留历史订单记录(用于统计) + +#### 2.1.5 查询账户列表 +- **功能**:获取所有账户列表 +- **返回数据**: + - 账户列表(每个账户包含): + - 账户ID + - 钱包地址(脱敏显示,只显示前6位和后4位) + - 账户名称 + - 是否默认账户 + - API Key 状态(是否已配置) + - 账户余额(通过 API 查询) + - 账户统计信息(总订单数、总盈亏等) + +#### 2.1.6 查询账户详情 +- **功能**:获取指定账户的详细信息 +- **输入参数**: + - `accountId`: 账户ID(可选,不提供则返回默认账户) +- **返回数据**: + - 钱包地址(脱敏显示) + - 账户名称 + - 是否默认账户 + - API Key 状态(是否已配置,不返回实际 Key) + - 账户余额(通过 API 查询) + - 账户统计信息(总订单数、总盈亏等) + +#### 2.1.7 账户余额查询 +- **功能**:查询指定账户的余额和持仓 +- **输入参数**: + - `accountId`: 账户ID(可选,不提供则查询默认账户) +- **实现方式**: + - 使用账户的 API Key 调用 Polymarket API 查询余额 + - 或通过订单记录计算持仓 +- **返回数据**: + - USDC 余额 + - 持仓列表(市场、方向、数量、价值) + +#### 2.1.8 设置默认账户 +- **功能**:设置默认账户(用于跟单时自动选择) +- **输入参数**: + - `accountId`: 账户ID(必需) +- **业务规则**: + - 设置新默认账户时,自动取消其他账户的默认状态 + - 至少需要有一个默认账户 + +#### 2.1.9 账户切换 +- **功能**:在多个账户间切换(前端功能) +- **实现方式**: + - 前端维护当前选中的账户ID + - 所有操作基于当前选中的账户 + - 可以临时切换账户,不影响默认账户设置 + +#### 2.1.10 身份识别机制 +- **功能**:系统如何区分"自己"和"Leader" +- **实现方式**: + - 系统存储多个自己的账户(通过私钥导入) + - 创建订单时,使用指定账户的 API Key 或私钥签名 + - 查询订单时,过滤掉 Leader 的订单 + - 跟单订单记录中标记为"自己的订单"和使用的账户ID +- **业务规则**: + - Leader 地址不能与任何自己的账户地址相同 + - 添加 Leader 时,验证地址不是自己的任何账户地址 + - 跟单时可以指定使用哪个账户(默认使用默认账户) + +### 2.2 被跟单者(Leader)管理 + +#### 2.1.1 添加被跟单者 +- **功能**:添加新的被跟单者 +- **输入参数**: + - `leaderAddress`: 被跟单者的钱包地址(必需) + - `leaderName`: 被跟单者名称(可选,用于显示) + - `category`: 分类筛选(sports/crypto,可选,仅跟单该分类的交易) + - `enabled`: 是否启用跟单(默认 true) +- **业务规则**: + - 验证地址格式 + - 检查是否已存在 + - 支持分类筛选 + - 验证 Leader 地址不能与自己的地址相同 + +#### 2.2.2 删除被跟单者 +- **功能**:删除被跟单者,停止跟单 +- **输入参数**: + - `leaderId`: 被跟单者ID +- **业务规则**: + - 删除前取消所有相关的跟单订单 + - 保留历史跟单记录 + +#### 2.1.3 更新被跟单者 +- **功能**:更新被跟单者配置 +- **输入参数**: + - `leaderId`: 被跟单者ID + - `leaderName`: 名称(可选) + - `category`: 分类筛选(可选) + - `enabled`: 是否启用(可选) + +#### 2.1.4 查询被跟单者列表 +- **功能**:获取所有被跟单者列表 +- **输入参数**: + - `enabled`: 是否只返回启用的(可选) + - `category`: 分类筛选(可选) +- **返回数据**: + - 被跟单者列表 + - 每个 Leader 的统计信息(跟单订单数、盈亏等) + +### 2.3 跟单配置管理 + +#### 2.2.1 全局跟单配置 +- **功能**:设置全局跟单参数 +- **配置项**: + - `copyMode`: 跟单金额模式("RATIO" 或 "FIXED",默认 "RATIO") + - `RATIO`: 比例模式,跟单金额 = Leader 订单金额 × copyRatio + - `FIXED`: 固定金额模式,跟单金额 = fixedAmount(固定值) + - `copyRatio`: 跟单比例(0.1-10.0,默认 1.0,仅在 copyMode="RATIO" 时生效) + - `fixedAmount`: 固定跟单金额(USDC,仅在 copyMode="FIXED" 时生效) + - `maxOrderSize`: 单笔订单最大金额(USDC) + - `minOrderSize`: 单笔订单最小金额(USDC) + - `maxDailyLoss`: 每日最大亏损限制(USDC) + - `maxDailyOrders`: 每日最大跟单订单数 + - `priceTolerance`: 价格容忍度(百分比,0-100,默认 5%) + - `delaySeconds`: 跟单延迟(秒,默认 0,立即跟单) + - `useWebSocket`: 是否优先使用 WebSocket 推送(默认 true) + - `websocketReconnectInterval`: WebSocket 重连间隔(毫秒,默认 5000) + - `websocketMaxRetries`: WebSocket 最大重试次数(默认 10) + - `enabled`: 是否启用全局跟单(默认 true) + +#### 2.2.2 单个 Leader 的跟单配置 +- **功能**:为每个 Leader 设置独立的跟单参数 +- **配置项**: + - `leaderId`: 被跟单者ID + - `accountId`: 指定使用的账户ID(可选,不指定则使用默认账户) + - `copyMode`: 跟单金额模式("RATIO" 或 "FIXED",覆盖全局配置) + - `copyRatio`: 跟单比例(覆盖全局配置,仅在 copyMode="RATIO" 时生效) + - `fixedAmount`: 固定跟单金额(覆盖全局配置,仅在 copyMode="FIXED" 时生效) + - `maxOrderSize`: 单笔订单最大金额(覆盖全局配置) + - `minOrderSize`: 单笔订单最小金额(覆盖全局配置) + - `enabled`: 是否启用该 Leader 的跟单(覆盖全局配置) + - `category`: 分类筛选(sports/crypto) + +### 2.4 订单同步与执行 + +#### 2.3.1 监控 Leader 交易 +- **功能**:实时监控 Leader 的交易活动 +- **实现方式**(优先级从高到低): + - **方式1(优先)**:使用 WebSocket 推送(RTDS API) + - 如果 Polymarket RTDS API 支持订阅指定用户的交易,优先使用 WebSocket + - WebSocket URL: `wss://ws-live-data.polymarket.com` + - 订阅用户交易频道,实时接收交易推送 + - 优点:实时性强、延迟低、资源消耗少 + - **方式2(备选)**:定期轮询 CLOB API + - 当 WebSocket 不可用或不支持时,使用轮询方式 + - 调用 CLOB API `/trades?user={leaderAddress}` 获取最新交易 + - 默认每 5 秒轮询一次(可配置轮询间隔) +- **实现策略**: + - 系统启动时尝试连接 WebSocket + - 如果 WebSocket 连接成功且支持订阅用户交易,使用推送模式 + - 如果 WebSocket 不可用或不支持,自动降级到轮询模式 + - 支持运行时切换(WebSocket 断开时自动切换到轮询) +- **业务规则**: + - 只监控已启用的 Leader + - 根据分类筛选(如果设置了 category) + - 去重处理(避免重复跟单同一笔交易) + - WebSocket 模式下,每个 Leader 需要单独订阅 + - 轮询模式下,批量查询多个 Leader 的交易 + +#### 2.3.2 订单复制逻辑 +- **触发条件**: + - Leader 创建新订单(通过交易记录判断) + - Leader 取消订单(需要监控订单状态变化) +- **复制流程**: + 1. 检测到 Leader 的新交易 + 2. 验证跟单配置(是否启用、分类筛选、金额限制等) + 3. 确定使用的账户: + - 如果 Leader 配置了指定账户,使用指定账户 + - 否则使用默认账户 + 4. 计算跟单订单参数: + - `market`: 与 Leader 相同 + - `side`: 与 Leader 相同(BUY/SELL) + - `price`: 根据价格容忍度调整(可选) + - `size`: 根据跟单比例计算 + 5. 使用指定账户的 API Key 或私钥签名创建订单 + 6. 记录跟单记录(包含使用的账户ID) + +#### 2.3.3 价格调整策略 +- **固定价格**:完全复制 Leader 的价格 +- **市场价**:使用当前市场最优价格 +- **价格容忍度**:在 Leader 价格 ± 容忍度范围内调整 +- **默认策略**:固定价格(完全复制) + +#### 2.3.4 订单大小计算 +- **计算模式**: + - **比例模式(copyMode = "RATIO")**: + ``` + 跟单订单大小 = Leader 订单大小 × copyRatio + ``` + - **固定金额模式(copyMode = "FIXED")**: + ``` + 跟单订单大小 = fixedAmount(固定值,不随 Leader 订单大小变化) + ``` +- **限制检查**: + - 不能超过 `maxOrderSize` + - 不能低于 `minOrderSize` + - 如果超出限制,调整到边界值 +- **业务规则**: + - 如果 Leader 配置了 `copyMode`,使用 Leader 的配置 + - 否则使用全局配置的 `copyMode` + - 固定金额模式下,无论 Leader 订单大小如何,跟单金额都固定 + - 比例模式下,跟单金额随 Leader 订单大小按比例变化 + +#### 2.3.5 订单取消同步 +- **功能**:当 Leader 取消订单时,同步取消对应的跟单订单 +- **实现方式**: + - 监控 Leader 的活跃订单列表 + - 检测到订单消失或状态变为 cancelled + - 查找对应的跟单订单并取消 + +### 2.4 风险控制 + +#### 2.4.1 每日亏损限制 +- **功能**:当日累计亏损达到限制时,停止跟单 +- **计算方式**: + - 统计当日所有已平仓订单的盈亏 + - 如果累计亏损 >= `maxDailyLoss`,暂停跟单 +- **恢复机制**: + - 次日自动恢复 + - 或手动重置 + +#### 2.4.2 每日订单数限制 +- **功能**:限制每日跟单订单数量 +- **规则**: + - 当日跟单订单数 >= `maxDailyOrders` 时,停止跟单 + - 次日自动重置 + +#### 2.4.3 单笔订单金额限制 +- **功能**:限制单笔跟单订单的最大和最小金额 +- **规则**: + - 订单金额必须在 `minOrderSize` 和 `maxOrderSize` 之间 + - 超出范围时,调整到边界值或跳过 + +#### 2.4.4 市场状态检查 +- **功能**:在跟单前检查市场状态 +- **检查项**: + - 市场是否活跃(active) + - 市场是否已关闭(closed) + - 订单簿是否有足够流动性 +- **规则**: + - 市场不活跃或已关闭时,跳过跟单 + +### 2.6 自己的订单管理 + +#### 2.6.1 查询自己的所有订单 +- **功能**:查询自己在 Polymarket 上的所有订单(包括跟单订单和手动订单) +- **实现方式**: + - 调用 CLOB API `/orders/active` 获取活跃订单 + - 从数据库查询跟单订单记录 + - 合并显示 +- **筛选条件**: + - `marketId`: 按市场筛选 + - `status`: 按订单状态筛选(active, filled, cancelled) + - `side`: 按方向筛选(BUY/SELL) + - `category`: 按分类筛选 + - `isCopyOrder`: 是否只显示跟单订单(true/false) +- **返回数据**: + - 订单列表(包括订单ID、市场、方向、价格、数量、状态等) + - 区分跟单订单和手动订单 + +#### 2.6.2 查询自己的持仓 +- **功能**:查询自己的当前持仓 +- **实现方式**: + - 通过订单记录计算持仓(买入-卖出) + - 或调用 API 查询持仓(如果支持) +- **返回数据**: + - 持仓列表(市场、方向、数量、平均成本、当前价值、盈亏) + +#### 2.6.3 手动创建订单 +- **功能**:手动创建订单(非跟单订单) +- **输入参数**: + - `marketId`: 市场ID + - `side`: 方向(BUY/SELL) + - `price`: 价格 + - `size`: 数量 +- **业务规则**: + - 使用自己的 API Key 创建订单 + - 验证订单参数 + - 记录为手动订单(非跟单订单) + +#### 2.6.4 手动取消订单 +- **功能**:手动取消自己的订单 +- **输入参数**: + - `orderId`: 订单ID +- **业务规则**: + - 只能取消自己的订单 + - 如果是跟单订单,记录取消原因 + - 调用 CLOB API 取消订单 + +### 2.7 跟单记录与统计 + +#### 2.5.1 跟单记录 +- **记录内容**: + - 跟单订单ID + - Leader 订单ID/交易ID + - Leader 地址 + - 市场ID + - 订单方向(BUY/SELL) + - 价格 + - 数量 + - 跟单比例 + - 创建时间 + - 订单状态 + - 盈亏(订单平仓后计算) + +#### 2.5.2 统计信息 +- **全局统计**: + - 总跟单订单数 + - 总盈亏 + - 胜率 + - 平均盈亏 + - 最大单笔盈利/亏损 +- **按 Leader 统计**: + - 每个 Leader 的跟单订单数 + - 每个 Leader 的盈亏 + - 每个 Leader 的胜率 +- **按分类统计**: + - sports 分类的跟单统计 + - crypto 分类的跟单统计 +- **时间维度统计**: + - 今日统计 + - 本周统计 + - 本月统计 + - 历史统计 + +### 2.8 订单管理(跟单订单) + +#### 2.6.1 查询跟单订单 +- **功能**:查询所有跟单订单 +- **筛选条件**: + - `leaderId`: 按 Leader 筛选 + - `marketId`: 按市场筛选 + - `status`: 按订单状态筛选(active, filled, cancelled) + - `category`: 按分类筛选 + - `startTime`: 开始时间 + - `endTime`: 结束时间 +- **分页支持**: + - `page`: 页码 + - `limit`: 每页数量 + +#### 2.6.2 手动取消跟单订单 +- **功能**:手动取消指定的跟单订单 +- **输入参数**: + - `copyOrderId`: 跟单订单ID +- **业务规则**: + - 只能取消自己的跟单订单 + - 记录取消原因 + +## 3. 数据模型 + +### 3.1 Account(账户信息) +```kotlin +@Entity +@Table(name = "copy_trading_accounts") +data class Account( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "private_key", nullable = false, length = 500) + val privateKey: String, // 私钥(加密存储) + + @Column(name = "wallet_address", unique = true, nullable = false, length = 42) + val walletAddress: String, // 钱包地址(从私钥推导) + + @Column(name = "api_key", length = 200) + val apiKey: String? = null, // Polymarket API Key(可选,加密存储) + + @Column(name = "account_name", length = 100) + val accountName: String? = null, + + @Column(name = "is_default", nullable = false) + val isDefault: Boolean = false, // 是否默认账户 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) +``` + +## 4. 数据模型(原有) + +### 4.1 Leader(被跟单者) +```kotlin +@Entity +@Table(name = "copy_trading_leaders") +data class Leader( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "leader_address", unique = true, nullable = false, length = 42) + val leaderAddress: String, // 钱包地址 + + @Column(name = "leader_name", length = 100) + val leaderName: String? = null, + + @Column(name = "account_id") + val accountId: Long? = null, // 指定使用的账户ID(null 表示使用默认账户) + + @Column(name = "category", length = 20) + val category: String? = null, // sports 或 crypto,null 表示不筛选 + + @Column(name = "enabled", nullable = false) + val enabled: Boolean = true, + + @Column(name = "copy_mode", length = 10) + val copyMode: String? = null, // "RATIO" 或 "FIXED",null 表示使用全局配置 + + @Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2) + val copyRatio: BigDecimal = BigDecimal.ONE, // 跟单比例(仅在 copyMode="RATIO" 时生效) + + @Column(name = "fixed_amount", precision = 20, scale = 8) + val fixedAmount: BigDecimal? = null, // 固定跟单金额(仅在 copyMode="FIXED" 时生效) + + @Column(name = "max_order_size", precision = 20, scale = 8) + val maxOrderSize: BigDecimal? = null, // 单笔最大金额 + + @Column(name = "min_order_size", precision = 20, scale = 8) + val minOrderSize: BigDecimal? = null, // 单笔最小金额 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) +``` + +### 4.2 CopyTradingConfig(全局跟单配置) +```kotlin +@Entity +@Table(name = "copy_trading_config") +data class CopyTradingConfig( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "copy_mode", nullable = false, length = 10) + val copyMode: String = "RATIO", // "RATIO" 或 "FIXED" + + @Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2) + val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效 + + @Column(name = "fixed_amount", precision = 20, scale = 8) + val fixedAmount: BigDecimal? = null, // 仅在 copyMode="FIXED" 时生效 + + @Column(name = "max_order_size", nullable = false, precision = 20, scale = 8) + val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(), + + @Column(name = "min_order_size", nullable = false, precision = 20, scale = 8) + val minOrderSize: BigDecimal = "1".toSafeBigDecimal(), + + @Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8) + val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(), + + @Column(name = "max_daily_orders", nullable = false) + val maxDailyOrders: Int = 100, + + @Column(name = "price_tolerance", nullable = false, precision = 5, scale = 2) + val priceTolerance: BigDecimal = "5".toSafeBigDecimal(), // 百分比 + + @Column(name = "delay_seconds", nullable = false) + val delaySeconds: Int = 0, + + @Column(name = "poll_interval_seconds", nullable = false) + val pollIntervalSeconds: Int = 5, // 轮询间隔(仅在 WebSocket 不可用时使用) + + @Column(name = "use_websocket", nullable = false) + val useWebSocket: Boolean = true, // 是否优先使用 WebSocket 推送 + + @Column(name = "websocket_reconnect_interval", nullable = false) + val websocketReconnectInterval: Int = 5000, // WebSocket 重连间隔(毫秒) + + @Column(name = "websocket_max_retries", nullable = false) + val websocketMaxRetries: Int = 10, // WebSocket 最大重试次数 + + @Column(name = "enabled", nullable = false) + val enabled: Boolean = true, + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) +``` + +### 4.3 CopyOrder(跟单订单) +```kotlin +@Entity +@Table(name = "copy_orders") +data class CopyOrder( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "account_id", nullable = false) + val accountId: Long, // 使用的账户ID + + @Column(name = "leader_id", nullable = false) + val leaderId: Long, + + @Column(name = "leader_address", nullable = false, length = 42) + val leaderAddress: String, + + @Column(name = "leader_trade_id", length = 100) + val leaderTradeId: String? = null, // Leader 的交易ID + + @Column(name = "leader_order_id", length = 100) + val leaderOrderId: String? = null, // Leader 的订单ID(如果有) + + @Column(name = "market_id", nullable = false, length = 100) + val marketId: String, + + @Column(name = "category", nullable = false, length = 20) + val category: String, // sports 或 crypto + + @Column(name = "side", nullable = false, length = 10) + val side: String, // BUY 或 SELL + + @Column(name = "price", nullable = false, precision = 20, scale = 8) + val price: BigDecimal, + + @Column(name = "size", nullable = false, precision = 20, scale = 8) + val size: BigDecimal, + + @Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2) + val copyRatio: BigDecimal, + + @Column(name = "order_id", length = 100) + var orderId: String? = null, // Polymarket 订单ID + + @Column(name = "status", nullable = false, length = 20) + var status: String = "pending", // pending, created, filled, cancelled, failed + + @Column(name = "filled_size", nullable = false, precision = 20, scale = 8) + var filledSize: BigDecimal = BigDecimal.ZERO, + + @Column(name = "pnl", precision = 20, scale = 8) + var pnl: BigDecimal? = null, // 盈亏(订单平仓后计算) + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) +``` + +### 4.4 DailyStatistics(每日统计) +```kotlin +@Entity +@Table(name = "copy_trading_daily_stats") +data class DailyStatistics( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "date", nullable = false, unique = true) + val date: String, // YYYY-MM-DD 格式 + + @Column(name = "total_orders", nullable = false) + val totalOrders: Int = 0, + + @Column(name = "total_pnl", nullable = false, precision = 20, scale = 8) + val totalPnl: BigDecimal = BigDecimal.ZERO, + + @Column(name = "win_count", nullable = false) + val winCount: Int = 0, + + @Column(name = "loss_count", nullable = false) + val lossCount: Int = 0, + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) +``` + +## 5. API 接口设计 + +### 5.1 账户管理接口 + +#### 5.1.1 通过私钥导入账户 +- **接口**: `POST /api/copy-trading/accounts/import` +- **请求体**: +```json +{ + "privateKey": "encrypted_private_key", // 前端加密后的私钥 + "walletAddress": "0x...", // 前端从私钥推导的地址(用于验证) + "accountName": "Account 1", + "apiKey": "your_api_key", // 可选 + "isDefault": false +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "id": 1, + "walletAddress": "0x1234...5678", + "accountName": "Account 1", + "isDefault": false, + "apiKeyConfigured": true + }, + "msg": "" +} +``` +- **前端处理**: + - 使用 ethers.js: `new ethers.Wallet(privateKey).address` + - 或使用 web3.js: `web3.eth.accounts.privateKeyToAccount(privateKey).address` + - 私钥加密后传输(使用 HTTPS + 前端加密) + +#### 5.1.2 更新账户信息 +- **接口**: `POST /api/copy-trading/accounts/update` +- **请求体**: +```json +{ + "accountId": 1, + "accountName": "Updated Name", + "apiKey": "new_api_key", + "isDefault": true +} +``` +- **注意**:不能修改私钥和地址 + +#### 5.1.3 删除账户 +- **接口**: `POST /api/copy-trading/accounts/delete` +- **请求体**: +```json +{ + "accountId": 1 +} +``` +- **业务规则**: + - 如果账户有活跃订单,返回错误提示 + - 如果删除的是默认账户,需要先设置其他账户为默认 + +#### 5.1.4 查询账户列表 +- **接口**: `POST /api/copy-trading/accounts/list` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "walletAddress": "0x1234...5678", + "accountName": "Account 1", + "isDefault": true, + "apiKeyConfigured": true, + "balance": "1000.5", + "totalOrders": 100, + "totalPnl": "50.5" + }, + { + "id": 2, + "walletAddress": "0xabcd...efgh", + "accountName": "Account 2", + "isDefault": false, + "apiKeyConfigured": true, + "balance": "500.0", + "totalOrders": 50, + "totalPnl": "20.0" + } + ], + "total": 2 + }, + "msg": "" +} +``` + +#### 5.1.5 查询账户详情 +- **接口**: `POST /api/copy-trading/accounts/detail` +- **请求体**: +```json +{ + "accountId": 1 +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "id": 1, + "walletAddress": "0x1234...5678", + "accountName": "Account 1", + "isDefault": true, + "apiKeyConfigured": true, + "balance": "1000.5", + "totalOrders": 100, + "totalPnl": "50.5" + }, + "msg": "" +} +``` + +#### 5.1.6 查询账户余额 +- **接口**: `POST /api/copy-trading/accounts/balance` +- **请求体**: +```json +{ + "accountId": 1 +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "usdcBalance": "1000.5", + "positions": [ + { + "marketId": "0x...", + "side": "YES", + "quantity": "100", + "avgPrice": "0.5", + "currentValue": "50", + "pnl": "0" + } + ] + }, + "msg": "" +} +``` + +#### 5.1.7 设置默认账户 +- **接口**: `POST /api/copy-trading/accounts/set-default` +- **请求体**: +```json +{ + "accountId": 1 +} +``` + +### 5.2 Leader 管理接口(原有) + +#### 5.2.1 添加被跟单者 +- **接口**: `POST /api/copy-trading/leaders/add` +- **请求体**: +```json +{ + "leaderAddress": "0x...", + "leaderName": "Trader A", + "accountId": 1, + "category": "sports", + "enabled": true +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "id": 1, + "leaderAddress": "0x...", + "leaderName": "Trader A", + "accountId": 1, + "category": "sports", + "enabled": true + }, + "msg": "" +} +``` +- **说明**: + - `accountId`: 可选,指定使用哪个账户跟单该 Leader,不提供则使用默认账户 + +#### 5.2.2 删除被跟单者 +- **接口**: `POST /api/copy-trading/leaders/delete` +- **请求体**: +```json +{ + "leaderId": 1 +} +``` + +#### 5.2.3 更新被跟单者 +- **接口**: `POST /api/copy-trading/leaders/update` +- **请求体**: +```json +{ + "leaderId": 1, + "leaderName": "Trader A Updated", + "accountId": 2, + "category": "crypto", + "enabled": false, + "copyRatio": "2.0", + "maxOrderSize": "500" +} +``` +- **说明**: + - `accountId`: 可选,更新该 Leader 使用的账户 + +#### 5.2.4 查询被跟单者列表 +- **接口**: `POST /api/copy-trading/leaders/list` +- **请求体**: +```json +{ + "enabled": true, + "category": "sports" +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "leaderAddress": "0x...", + "leaderName": "Trader A", + "category": "sports", + "enabled": true, + "copyRatio": "1.0", + "totalOrders": 10, + "totalPnl": "50.5" + } + ], + "total": 1 + }, + "msg": "" +} +``` + +### 5.3 配置管理接口 + +#### 5.3.1 获取全局配置 +- **接口**: `POST /api/copy-trading/config/get` +- **响应**: +```json +{ + "code": 0, + "data": { + "copyMode": "RATIO", + "copyRatio": "1.0", + "fixedAmount": null, + "maxOrderSize": "1000", + "minOrderSize": "1", + "maxDailyLoss": "10000", + "maxDailyOrders": 100, + "priceTolerance": "5", + "delaySeconds": 0, + "pollIntervalSeconds": 5, + "useWebSocket": true, + "websocketReconnectInterval": 5000, + "websocketMaxRetries": 10, + "enabled": true + }, + "msg": "" +} +``` + +#### 5.3.2 更新全局配置 +- **接口**: `POST /api/copy-trading/config/update` +- **请求体**(比例模式示例): +```json +{ + "copyMode": "RATIO", + "copyRatio": "1.5", + "fixedAmount": null, + "maxOrderSize": "2000", + "minOrderSize": "5", + "maxDailyLoss": "20000", + "maxDailyOrders": 200, + "priceTolerance": "3", + "delaySeconds": 2, + "pollIntervalSeconds": 3, + "useWebSocket": true, + "websocketReconnectInterval": 5000, + "websocketMaxRetries": 10, + "enabled": true +} +``` +- **请求体**(固定金额模式示例): +```json +{ + "copyMode": "FIXED", + "copyRatio": null, + "fixedAmount": "100", + "maxOrderSize": "2000", + "minOrderSize": "5", + "maxDailyLoss": "20000", + "maxDailyOrders": 200, + "priceTolerance": "3", + "delaySeconds": 2, + "pollIntervalSeconds": 3, + "useWebSocket": true, + "websocketReconnectInterval": 5000, + "websocketMaxRetries": 10, + "enabled": true +} +``` + +### 5.4 自己的订单管理接口 + +#### 5.4.1 查询自己的所有订单 +- **接口**: `POST /api/copy-trading/my-orders/list` +- **请求体**: +```json +{ + "marketId": "0x...", + "status": "active", + "side": "BUY", + "category": "sports", + "isCopyOrder": false, + "page": 1, + "limit": 20 +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "orderId": "order_123", + "marketId": "0x...", + "category": "sports", + "side": "BUY", + "price": "0.5", + "size": "100", + "filled": "50", + "status": "active", + "isCopyOrder": true, + "leaderName": "Trader A", + "createdAt": 1234567890000 + } + ], + "total": 100, + "page": 1, + "limit": 20 + }, + "msg": "" +} +``` + +#### 5.4.2 查询自己的持仓 +- **接口**: `POST /api/copy-trading/my-orders/positions` +- **响应**: +```json +{ + "code": 0, + "data": { + "positions": [ + { + "marketId": "0x...", + "marketTitle": "Market Title", + "category": "sports", + "side": "YES", + "quantity": "100", + "avgPrice": "0.5", + "currentPrice": "0.6", + "currentValue": "60", + "cost": "50", + "pnl": "10", + "pnlPercent": "20" + } + ] + }, + "msg": "" +} +``` + +#### 5.4.3 手动创建订单 +- **接口**: `POST /api/copy-trading/my-orders/create` +- **请求体**: +```json +{ + "marketId": "0x...", + "side": "BUY", + "price": "0.5", + "size": "100" +} +``` + +#### 5.4.4 手动取消订单 +- **接口**: `POST /api/copy-trading/my-orders/cancel` +- **请求体**: +```json +{ + "orderId": "order_123" +} +``` + +### 5.5 跟单订单管理接口 + +#### 5.5.1 查询跟单订单 +- **接口**: `POST /api/copy-trading/orders/list` +- **请求体**: +```json +{ + "leaderId": 1, + "marketId": "0x...", + "status": "active", + "category": "sports", + "startTime": 1234567890000, + "endTime": 1234567890000, + "page": 1, + "limit": 20 +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "leaderId": 1, + "leaderAddress": "0x...", + "leaderName": "Trader A", + "marketId": "0x...", + "category": "sports", + "side": "BUY", + "price": "0.5", + "size": "100", + "copyRatio": "1.0", + "orderId": "order_123", + "status": "filled", + "filledSize": "100", + "pnl": "10.5", + "createdAt": 1234567890000 + } + ], + "total": 100, + "page": 1, + "limit": 20 + }, + "msg": "" +} +``` + +#### 5.5.2 取消跟单订单 +- **接口**: `POST /api/copy-trading/orders/cancel` +- **请求体**: +```json +{ + "copyOrderId": 1 +} +``` + +### 5.6 统计接口 + +#### 5.6.1 获取全局统计 +- **接口**: `POST /api/copy-trading/statistics/global` +- **请求体**: +```json +{ + "startTime": 1234567890000, + "endTime": 1234567890000 +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "totalOrders": 100, + "totalPnl": "500.5", + "winRate": "60.5", + "avgPnl": "5.005", + "maxProfit": "50.0", + "maxLoss": "-30.0" + }, + "msg": "" +} +``` + +#### 5.6.2 获取 Leader 统计 +- **接口**: `POST /api/copy-trading/statistics/leader` +- **请求体**: +```json +{ + "leaderId": 1, + "startTime": 1234567890000, + "endTime": 1234567890000 +} +``` + +#### 5.6.3 获取分类统计 +- **接口**: `POST /api/copy-trading/statistics/category` +- **请求体**: +```json +{ + "category": "sports", + "startTime": 1234567890000, + "endTime": 1234567890000 +} +``` + +## 6. 技术实现要点 + +### 6.0 前端私钥处理(重要) + +#### 6.0.1 私钥导入方式 +前端支持多种私钥导入方式: +1. **私钥字符串**:直接输入 64 位十六进制私钥(可选 0x 前缀) +2. **助记词(Mnemonic)**:12 或 24 个单词的助记词 +3. **Keystore 文件**:JSON 格式的加密钱包文件(需要密码) + +#### 6.0.2 地址推导实现 +前端使用以下库从私钥推导地址: + +**使用 ethers.js(推荐)**: +```typescript +import { ethers } from 'ethers'; + +// 从私钥创建钱包 +const wallet = new ethers.Wallet(privateKey); +const address = wallet.address; + +// 从助记词创建钱包 +const walletFromMnemonic = ethers.Wallet.fromMnemonic(mnemonic); +const addressFromMnemonic = walletFromMnemonic.address; +``` + +**使用 web3.js**: +```typescript +import Web3 from 'web3'; + +const web3 = new Web3(); +const account = web3.eth.accounts.privateKeyToAccount(privateKey); +const address = account.address; +``` + +#### 6.0.3 私钥安全传输 +- **前端加密**:私钥在传输前使用 AES 加密(可选,因为 HTTPS 已加密) +- **HTTPS 传输**:必须使用 HTTPS 协议 +- **后端验证**:后端验证私钥和地址的对应关系 + +#### 6.0.4 前端存储策略 +- **不保存明文**:前端不将私钥明文保存到 localStorage 或 sessionStorage +- **内存处理**:私钥只在内存中处理,用完即清除 +- **用户选择**:如果用户需要记住私钥,可以保存加密后的私钥(需要密码) + +### 6.1 账户管理实现 +- **私钥存储**: + - 私钥必须加密存储(使用 AES-256 加密) + - 加密密钥存储在环境变量或配置文件中 + - 私钥解密只在需要签名时进行,用完即清除内存 +- **私钥验证**: + - 后端验证私钥和地址的对应关系 + - 使用以太坊库验证私钥格式 +- **多账户支持**: + - 系统支持多个账户同时存在 + - 每个账户可以独立配置 + - 跟单时可以指定使用哪个账户 +- **身份识别**: + - 系统从数据库加载所有账户信息 + - 创建订单时使用指定账户的 API Key 或私钥签名 + - 查询订单时过滤自己的所有账户地址 +- **前端私钥处理**: + - 前端使用 ethers.js 或 web3.js 从私钥推导地址 + - 私钥在传输前加密(使用 HTTPS + 前端加密) + - 前端不保存私钥明文(除非用户明确要求) + +### 6.2 订单监控实现 + +#### 6.2.1 WebSocket 推送模式(优先) +- **连接方式**: + - 使用 WebSocket 客户端连接到 `wss://ws-live-data.polymarket.com` + - 为每个 Leader 订阅用户交易频道 + - 订阅消息格式: + ```json + { + "type": "subscribe", + "channel": "user", + "user": "leader_address", + "apiKey": "your_api_key" // 如果需要认证 + } + ``` +- **消息处理**: + - 监听 WebSocket 消息,接收交易推送 + - 解析交易数据,提取 `side`(BUY/SELL)、`market`、`price`、`size` 等信息 + - 实时触发跟单逻辑 +- **连接管理**: + - 实现自动重连机制 + - 连接断开时自动降级到轮询模式 + - 支持动态添加/移除 Leader 订阅 + +#### 6.2.2 轮询模式(备选) +- **实现方式**: + - 使用定时任务(ScheduledExecutorService 或 Spring @Scheduled) + - 定期调用 CLOB API `/trades?user={leaderAddress}` 获取最新交易 + - 记录上次查询的时间戳,只处理新交易 +- **优化策略**: + - 批量查询多个 Leader 的交易(如果 API 支持) + - 根据 Leader 活跃度调整轮询频率 + - 使用缓存减少重复查询 + +#### 6.2.3 模式切换 +- **自动切换**: + - 系统启动时优先尝试 WebSocket 连接 + - WebSocket 连接成功且可用时,使用推送模式 + - WebSocket 不可用或断开时,自动切换到轮询模式 +- **配置控制**: + - 支持配置强制使用轮询模式(用于调试或测试) + - 支持配置 WebSocket 重连间隔和最大重试次数 + +#### 6.2.4 去重机制 +- **实现方式**: + - 使用 Redis 或数据库记录已处理的交易ID + - 记录格式:`leader_id + trade_id` 作为唯一键 + - 避免重复跟单同一笔交易 +- **清理策略**: + - 定期清理过期的已处理记录(如 24 小时前) + - 使用 TTL 自动过期机制 + +### 6.3 订单执行流程 +1. 检测到 Leader 新交易 +2. 验证配置和风险控制 +3. 计算跟单订单参数 +4. 调用 CLOB API 创建订单 +5. 保存跟单记录 +6. 更新统计信息 + +### 6.4 订单状态同步 +- 定期查询活跃订单状态 +- 检测订单状态变化(filled, cancelled) +- 更新跟单记录状态 +- 计算盈亏(订单平仓后) + +### 6.5 错误处理 +- API 调用失败重试机制 +- 订单创建失败记录日志 +- 异常情况告警(可选) + +### 6.6 性能优化 +- 批量查询多个 Leader 的交易 +- 使用缓存减少 API 调用 +- 异步处理订单创建 + +## 7. 数据库设计 + +### 7.1 表结构 +- `copy_trading_leaders`: 被跟单者表 +- `copy_trading_config`: 全局配置表(单条记录) +- `copy_orders`: 跟单订单表 +- `copy_trading_daily_stats`: 每日统计表 +- `copy_trading_processed_trades`: 已处理交易表(用于去重) +- `copy_trading_account`: 账户信息表(单条记录) + +### 7.2 索引设计 +- `copy_trading_leaders.leader_address`: UNIQUE 索引 +- `copy_orders.leader_id`: 索引 +- `copy_orders.market_id`: 索引 +- `copy_orders.created_at`: 索引 +- `copy_trading_processed_trades.leader_address + trade_id`: 联合唯一索引 + +## 8. 安全考虑 + +### 8.1 API 认证 +- 所有接口需要 API Key 认证 +- 验证用户权限 + +### 8.2 风险控制 +- 严格的金额限制 +- 每日亏损限制 +- 订单数量限制 + +### 8.3 数据验证 +- 验证 Leader 地址格式 +- 验证订单参数 +- 验证配置参数范围 + +### 8.4 私钥和 API Key 安全 +- **私钥安全**: + - 私钥必须加密存储,不能明文保存 + - 使用 AES-256 加密算法 + - 加密密钥存储在环境变量或配置文件中 + - 私钥解密只在需要签名时进行,用完即清除内存 + - 前端传输私钥时使用 HTTPS + 前端加密 +- **API Key 安全**: + - API Key 加密存储(如果提供) + - 使用 AES 加密算法 + - 加密密钥存储在环境变量或配置文件中 +- **访问控制**: + - 私钥只能通过导入接口设置,不能修改 + - 查询接口不返回私钥和完整的 API Key + - 更新 API Key 时需要验证旧 Key +- **前端安全**: + - 前端使用 ethers.js 或 web3.js 处理私钥 + - 私钥在内存中处理,不保存到 localStorage(除非用户明确要求) + - 支持助记词导入(前端转换为私钥) + +## 9. 后续扩展功能(可选) + +### 9.1 高级功能 +- 智能跟单(根据 Leader 历史表现筛选) +- 反向跟单(反向操作 Leader 的订单) +- 部分跟单(只跟单特定市场或条件) +- 跟单延迟策略(延迟 N 秒后跟单) + +### 9.2 分析功能 +- Leader 表现分析 +- 市场分析 +- 盈亏分析报告 + +### 9.3 通知功能 +- 跟单订单通知 +- 风险告警通知 +- 每日统计报告 + +## 10. 开发优先级 + +### Phase 1: 核心功能(MVP) +1. **账户管理**(新增) + - 通过私钥导入账户(前端从私钥推导地址) + - 多账户管理(增删改查) + - 默认账户设置 + - 账户信息查询 + - API Key 管理(可选) +2. Leader 管理(增删改查) +3. 全局配置管理 +4. 订单监控和同步(基础版本) +5. 跟单订单记录(包含账户ID) +6. **前端移动端适配**(必须) + - 响应式布局设计 + - 移动端 UI 组件适配 + - 触摸操作优化 + - 移动端性能优化 + +### Phase 2: 完善功能 +1. 自己的订单管理 + - 查询所有订单 + - 查询持仓 + - 手动创建/取消订单 +2. 风险控制完善 +3. 统计功能 +4. 错误处理和重试 +5. **前端移动端优化** + - 移动端交互优化 + - 手势操作支持 + - 离线功能支持(可选) + +### Phase 3: 优化和扩展 +1. 性能优化 +2. 高级功能 +3. 分析和报告 +4. 通知功能 + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..d600b6c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..4b8bac0 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,74 @@ +# Polymarket 跟单系统前端 + +## 技术栈 + +- **框架**: React 18 + TypeScript +- **构建工具**: Vite +- **UI 库**: Ant Design + Ant Design Mobile +- **HTTP 客户端**: axios +- **状态管理**: Zustand +- **路由**: React Router +- **以太坊库**: ethers.js + +## 功能特性 + +- ✅ 账户管理(通过私钥导入,支持多账户) +- ✅ Leader 管理(被跟单者管理) +- ✅ 跟单配置管理 +- ✅ 订单管理 +- ✅ 统计信息 +- ✅ 移动端适配 + +## 开发 + +```bash +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev + +# 构建生产版本 +npm run build +``` + +## 项目结构 + +``` +frontend/ +├── src/ +│ ├── components/ # 公共组件 +│ │ └── Layout.tsx # 布局组件(支持移动端) +│ ├── pages/ # 页面组件 +│ │ ├── AccountList.tsx +│ │ ├── AccountImport.tsx +│ │ ├── LeaderList.tsx +│ │ ├── LeaderAdd.tsx +│ │ ├── ConfigPage.tsx +│ │ ├── OrderList.tsx +│ │ └── Statistics.tsx +│ ├── services/ # API 服务 +│ │ └── api.ts +│ ├── store/ # 状态管理 +│ │ └── accountStore.ts +│ ├── types/ # TypeScript 类型定义 +│ │ └── index.ts +│ ├── utils/ # 工具函数 +│ │ └── ethers.ts +│ ├── styles/ # 样式文件 +│ │ └── index.css +│ ├── App.tsx # 根组件 +│ └── main.tsx # 入口文件 +├── index.html +├── package.json +├── tsconfig.json +└── vite.config.ts +``` + +## 移动端适配 + +- 使用 `react-responsive` 检测设备类型 +- 移动端使用 Ant Design Mobile 组件 +- 响应式布局(移动端 < 768px,桌面端 >= 768px) +- 触摸友好的按钮尺寸(>= 44x44px) + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8c6abc6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Polymarket 跟单系统 + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..69398b4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4917 @@ +{ + "name": "polymarket-bot-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polymarket-bot-frontend", + "version": "1.0.0", + "dependencies": { + "antd": "^5.12.0", + "antd-mobile": "^5.34.0", + "axios": "^1.6.2", + "ethers": "^6.9.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-responsive": "^9.0.2", + "react-router-dom": "^6.20.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.0.tgz", + "integrity": "sha512-ABA80Yer0c6I2+moqNY0kF3Y1NxIT6wDP/EINIqbiRbfZKP1HtHpKMh8WuTXLgVGYsoWG2g9/n0PgM8KdnJb4Q==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.0.tgz", + "integrity": "sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@react-spring/animated": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz", + "integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==", + "dependencies": { + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz", + "integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/rafz": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz", + "integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==" + }, + "node_modules/@react-spring/shared": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz", + "integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==", + "dependencies": { + "@react-spring/rafz": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz", + "integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==" + }, + "node_modules/@react-spring/web": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.6.1.tgz", + "integrity": "sha512-X2zR6q2Z+FjsWfGAmAXlQaoUHbPmfuCaXpuM6TcwXPpLE1ZD4A1eys/wpXboFQmDkjnrlTmKvpVna1MjWpZ5Hw==", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/core": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "node_modules/@use-gesture/core": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.0.tgz", + "integrity": "sha512-rh+6MND31zfHcy9VU3dOZCqGY511lvGcfyJenN4cWZe0u1BH6brBpBddLVXhF2r4BMqWbvxfsbL7D287thJU2A==" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.0.tgz", + "integrity": "sha512-3zc+Ve99z4usVP6l9knYVbVnZgfqhKah7sIG+PS2w+vpig2v2OLct05vs+ZXMzwxdNCMka8B+8WlOo0z6Pn6DA==", + "dependencies": { + "@use-gesture/core": "10.3.0" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==" + }, + "node_modules/ahooks": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.6.tgz", + "integrity": "sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@types/js-cookie": "^3.0.6", + "dayjs": "^1.9.1", + "intersection-observer": "^0.12.0", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "react-fast-compare": "^3.2.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.0.0", + "tslib": "^2.4.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "5.29.1", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.1.tgz", + "integrity": "sha512-TTFVbpKbyL6cPfEoKq6Ya3BIjTUr7uDW9+7Z+1oysRv1gpcN7kQ4luH8r/+rXXwz4n6BIz1iBJ1ezKCdsdNW0w==", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd-mobile": { + "version": "5.41.1", + "resolved": "https://registry.npmjs.org/antd-mobile/-/antd-mobile-5.41.1.tgz", + "integrity": "sha512-fS5sTRLKHca5qryEYLGiPDLANK0rbhx8f8xk0Olu6ef00tLe0P9iqHQm0U3UtEBd8S454cilw5uv2J3I79Tbgg==", + "dependencies": { + "@floating-ui/dom": "^1.4.2", + "@rc-component/mini-decimal": "^1.1.0", + "@react-spring/web": "~9.6.1", + "@use-gesture/react": "10.3.0", + "ahooks": "^3.7.6", + "antd-mobile-icons": "^0.3.0", + "antd-mobile-v5-count": "^1.0.1", + "classnames": "^2.3.2", + "dayjs": "^1.11.7", + "deepmerge": "^4.3.1", + "nano-memoize": "^3.0.16", + "rc-field-form": "^1.34.2", + "rc-segmented": "~2.4.1", + "rc-util": "^5.44.4", + "react-fast-compare": "^3.2.2", + "react-is": "^18.2.0", + "runes2": "^1.1.2", + "staged-components": "^1.1.3", + "tslib": "^2.5.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/antd-mobile-icons": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/antd-mobile-icons/-/antd-mobile-icons-0.3.0.tgz", + "integrity": "sha512-rqINQpJWZWrva9moCd1Ye695MZYWmqLPE+bY8d2xLRy7iSQwPsinCdZYjpUPp2zL/LnKYSyXxP2ut2A+DC+whQ==" + }, + "node_modules/antd-mobile-v5-count": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/antd-mobile-v5-count/-/antd-mobile-v5-count-1.0.1.tgz", + "integrity": "sha512-YGsiEDCPUDz3SzfXi6gLZn/HpeSMW+jgPc4qiYUr1fSopg3hkUie2TnooJdExgfiETHefH3Ggs58He0OVfegLA==" + }, + "node_modules/antd-mobile/node_modules/rc-field-form": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-1.44.0.tgz", + "integrity": "sha512-el7w87fyDUsca63Y/s8qJcq9kNkf/J5h+iTdqG5WsSHLH0e6Usl7QuYSmSVzJMgtp40mOVZIY/W/QP9zwrp1FA==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "async-validator": "^4.1.0", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd-mobile/node_modules/rc-segmented": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.4.1.tgz", + "integrity": "sha512-KUi+JJFdKnumV9iXlm+BJ00O4NdVBp2TEexLCk6bK1x/RH83TvYKQMzIz/7m3UTRPD08RM/8VG/JNjWgWbd4cw==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", + "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.258", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.258.tgz", + "integrity": "sha512-rHUggNV5jKQ0sSdWwlaRDkFc3/rRJIVnOSe9yR4zrR07m3ZxhP4N27Hlg8VeJGGYgFTxK5NqDmWI4DSH72vIJg==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ethers": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz", + "integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/intersection-observer": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz", + "integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==", + "deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019." + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/matchmediaquery": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.3.1.tgz", + "integrity": "sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==", + "dependencies": { + "css-mediaquery": "^0.1.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nano-memoize": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/nano-memoize/-/nano-memoize-3.0.16.tgz", + "integrity": "sha512-JyK96AKVGAwVeMj3MoMhaSXaUNqgMbCRSQB3trUV8tYZfWEzqUBKdK1qJpfuNXgKeHOx1jv/IEYTM659ly7zUA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.0.tgz", + "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-responsive": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-9.0.2.tgz", + "integrity": "sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.2.1" + }, + "engines": { + "node": ">=0.10" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "dependencies": { + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/runes2": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", + "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/staged-components": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/staged-components/-/staged-components-1.1.3.tgz", + "integrity": "sha512-9EIswzDqjwlEu+ymkV09TTlJfzSbKgEnNteUnZSTxkpMgr5Wx2CzzA9WcMFWBNCldqVPsHVnRGGrApduq2Se5A==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3b078d3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "polymarket-bot-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 3000 --host 0.0.0.0", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.2", + "zustand": "^4.4.7", + "antd": "^5.12.0", + "antd-mobile": "^5.34.0", + "ethers": "^6.9.0", + "react-responsive": "^9.0.2" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1c7629d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,37 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import Layout from './components/Layout' +import AccountList from './pages/AccountList' +import AccountImport from './pages/AccountImport' +import AccountDetail from './pages/AccountDetail' +import LeaderList from './pages/LeaderList' +import LeaderAdd from './pages/LeaderAdd' +import ConfigPage from './pages/ConfigPage' +import OrderList from './pages/OrderList' +import Statistics from './pages/Statistics' + +function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} + +export default App + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..4e59696 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { Layout as AntLayout, Menu, Drawer, Button } from 'antd' +import { useMediaQuery } from 'react-responsive' +import { + WalletOutlined, + UserOutlined, + SettingOutlined, + UnorderedListOutlined, + BarChartOutlined, + MenuOutlined +} from '@ant-design/icons' +import type { ReactNode } from 'react' + +const { Header, Content, Sider } = AntLayout + +interface LayoutProps { + children: ReactNode +} + +const Layout: React.FC = ({ children }) => { + const navigate = useNavigate() + const location = useLocation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + + const menuItems = [ + { + key: '/accounts', + icon: , + label: '账户管理' + }, + { + key: '/leaders', + icon: , + label: 'Leader 管理' + }, + { + key: '/config', + icon: , + label: '跟单配置' + }, + { + key: '/orders', + icon: , + label: '订单管理' + }, + { + key: '/statistics', + icon: , + label: '统计信息' + } + ] + + const handleMenuClick = (key: string) => { + navigate(key) + if (isMobile) { + setMobileMenuOpen(false) + } + } + + if (isMobile) { + // 移动端布局 + return ( + +
+
+ Polymarket 跟单 +
+
+ + {children} + + setMobileMenuOpen(false)} + open={mobileMenuOpen} + bodyStyle={{ padding: 0 }} + > + handleMenuClick(key)} + style={{ border: 'none' }} + /> + + + ) + } + + // 桌面端布局 + return ( + + +
+ Polymarket 跟单 +
+ handleMenuClick(key)} + style={{ height: 'calc(100vh - 64px)', borderRight: 0 }} + /> + + + + {children} + + + + ) +} + +export default Layout + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1e72169 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles/index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) + diff --git a/frontend/src/pages/AccountDetail.tsx b/frontend/src/pages/AccountDetail.tsx new file mode 100644 index 0000000..e34c904 --- /dev/null +++ b/frontend/src/pages/AccountDetail.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { Card, Descriptions, Button, Space, Tag, Spin, message, Typography, Divider } from 'antd' +import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons' +import { useAccountStore } from '../store/accountStore' +import type { Account } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +const AccountDetail: React.FC = () => { + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const accountId = searchParams.get('id') + + const { fetchAccountDetail, fetchAccountBalance } = useAccountStore() + const [account, setAccount] = useState(null) + const [balance, setBalance] = useState(null) + const [loading, setLoading] = useState(true) + const [balanceLoading, setBalanceLoading] = useState(false) + + useEffect(() => { + if (accountId) { + loadAccountDetail() + loadBalance() + } else { + message.error('账户ID不能为空') + navigate('/accounts') + } + }, [accountId]) + + const loadAccountDetail = async () => { + if (!accountId) return + + setLoading(true) + try { + const accountData = await fetchAccountDetail(Number(accountId)) + setAccount(accountData) + } catch (error: any) { + message.error(error.message || '获取账户详情失败') + navigate('/accounts') + } finally { + setLoading(false) + } + } + + const loadBalance = async () => { + if (!accountId) return + + setBalanceLoading(true) + try { + const balanceData = await fetchAccountBalance(Number(accountId)) + setBalance(balanceData.balance || null) + } catch (error: any) { + console.error('获取余额失败:', error) + // 余额查询失败不显示错误,只显示 "-" + setBalance(null) + } finally { + setBalanceLoading(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!account) { + return null + } + + return ( +
+
+ + + + {account.accountName || `账户 ${account.id}`} + + + + + + +
+ + + + + {account.id} + + + {account.accountName || '-'} + + + + {account.walletAddress} + + + + + {account.isDefault ? '是' : '否'} + + + + {balanceLoading ? ( + + ) : balance ? ( + + {balance} USDC + + ) : ( + - + )} + + + + + + + + + + + {account.apiKeyConfigured ? '已配置' : '未配置'} + + + + + {account.apiSecretConfigured ? '已配置' : '未配置'} + + + + + {account.apiPassphraseConfigured ? '已配置' : '未配置'} + + + + {account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? ( + 完整配置 + ) : ( + 部分配置 + )} + + + + + {account.totalOrders !== undefined || account.totalPnl !== undefined ? ( + <> + + + + {account.totalOrders !== undefined && ( + + {account.totalOrders} + + )} + {account.totalPnl !== undefined && ( + + + {account.totalPnl} USDC + + + )} + + + + ) : null} +
+ ) +} + +export default AccountDetail + diff --git a/frontend/src/pages/AccountImport.tsx b/frontend/src/pages/AccountImport.tsx new file mode 100644 index 0000000..bdca0d3 --- /dev/null +++ b/frontend/src/pages/AccountImport.tsx @@ -0,0 +1,361 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert, Checkbox } from 'antd' +import { ArrowLeftOutlined } from '@ant-design/icons' +import { useAccountStore } from '../store/accountStore' +import { + getAddressFromPrivateKey, + getAddressFromMnemonic, + getPrivateKeyFromMnemonic, + isValidWalletAddress, + isValidPrivateKey, + isValidMnemonic +} from '../utils/ethers' +import { useMediaQuery } from 'react-responsive' + +const { Title, Text } = Typography + +type ImportType = 'privateKey' | 'mnemonic' + +const AccountImport: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { importAccount, loading } = useAccountStore() + const [form] = Form.useForm() + const [importType, setImportType] = useState('privateKey') + const [derivedAddress, setDerivedAddress] = useState('') + const [addressError, setAddressError] = useState('') + + // 当私钥输入时,自动推导地址 + const handlePrivateKeyChange = (e: React.ChangeEvent) => { + const privateKey = e.target.value.trim() + if (!privateKey) { + setDerivedAddress('') + setAddressError('') + return + } + + // 验证私钥格式 + if (!isValidPrivateKey(privateKey)) { + setAddressError('私钥格式不正确(应为64位十六进制字符串)') + setDerivedAddress('') + return + } + + try { + const address = getAddressFromPrivateKey(privateKey) + setDerivedAddress(address) + setAddressError('') + + // 自动填充钱包地址字段 + form.setFieldsValue({ walletAddress: address }) + } catch (error: any) { + setAddressError(error.message || '无法从私钥推导地址') + setDerivedAddress('') + } + } + + // 当助记词输入时,自动推导地址 + const handleMnemonicChange = (e: React.ChangeEvent) => { + const mnemonic = e.target.value.trim() + if (!mnemonic) { + setDerivedAddress('') + setAddressError('') + return + } + + // 验证助记词格式 + if (!isValidMnemonic(mnemonic)) { + setAddressError('助记词格式不正确(应为12或24个单词,用空格分隔)') + setDerivedAddress('') + return + } + + try { + const address = getAddressFromMnemonic(mnemonic, 0) + setDerivedAddress(address) + setAddressError('') + + // 自动填充钱包地址字段 + form.setFieldsValue({ walletAddress: address }) + } catch (error: any) { + setAddressError(error.message || '无法从助记词推导地址') + setDerivedAddress('') + } + } + + const handleSubmit = async (values: any) => { + try { + let privateKey: string + let walletAddress: string + + if (importType === 'privateKey') { + // 私钥模式 + privateKey = values.privateKey + walletAddress = values.walletAddress + + // 验证推导的地址和输入的地址是否一致 + if (derivedAddress && walletAddress !== derivedAddress) { + message.error('钱包地址与私钥不匹配') + return + } + } else { + // 助记词模式 + if (!values.mnemonic) { + message.error('请输入助记词') + return + } + + // 从助记词导出私钥和地址 + privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0) + const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0) + + // 如果用户手动输入了地址,验证是否与推导的地址一致 + if (values.walletAddress) { + if (values.walletAddress !== derivedAddressFromMnemonic) { + // 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址) + message.warning(`输入的地址与助记词推导的地址不一致。推导的地址: ${derivedAddressFromMnemonic},将使用推导的地址`) + walletAddress = derivedAddressFromMnemonic + } else { + // 地址匹配,使用用户输入的地址 + walletAddress = values.walletAddress + } + } else { + // 如果用户没有输入地址,使用推导的地址 + walletAddress = derivedAddressFromMnemonic + } + } + + // 验证钱包地址格式 + if (!isValidWalletAddress(walletAddress)) { + message.error('钱包地址格式不正确') + return + } + + await importAccount({ + privateKey: privateKey, + walletAddress: walletAddress, + accountName: values.accountName, + apiKey: values.apiKey, + apiSecret: values.apiSecret, + apiPassphrase: values.apiPassphrase, + isDefault: values.isDefault || false + }) + + message.success('导入账户成功') + navigate('/accounts') + } catch (error: any) { + message.error(error.message || '导入账户失败') + } + } + + return ( +
+
+ + 导入账户 +
+ + + + +
+ + { + setImportType(e.target.value) + setDerivedAddress('') + setAddressError('') + form.setFieldsValue({ walletAddress: '' }) + }} + > + 私钥 + 助记词 + + + + {importType === 'privateKey' ? ( + <> + { + if (!value) return Promise.resolve() + if (!isValidPrivateKey(value)) { + return Promise.reject(new Error('私钥格式不正确(应为64位十六进制字符串)')) + } + return Promise.resolve() + } + } + ]} + help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')} + validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} + > + + + + { + if (!value) return Promise.resolve() + if (!isValidWalletAddress(value)) { + return Promise.reject(new Error('钱包地址格式不正确')) + } + if (derivedAddress && value !== derivedAddress) { + return Promise.reject(new Error('钱包地址与私钥不匹配')) + } + return Promise.resolve() + } + } + ]} + > + + + + ) : ( + <> + { + if (!value) return Promise.resolve() + if (!isValidMnemonic(value)) { + return Promise.reject(new Error('助记词格式不正确(应为12或24个单词,用空格分隔)')) + } + return Promise.resolve() + } + } + ]} + help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')} + validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} + > + + + + { + if (!value) return Promise.resolve() + if (!isValidWalletAddress(value)) { + return Promise.reject(new Error('钱包地址格式不正确')) + } + if (derivedAddress && value !== derivedAddress) { + return Promise.reject(new Error('钱包地址与助记词不匹配')) + } + return Promise.resolve() + } + } + ]} + > + + + + )} + + + + + + + + + + + + + + + + + + + 设为默认账户 + + + + + + + + + +
+
+ ) +} + +export default AccountImport + diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx new file mode 100644 index 0000000..96168ca --- /dev/null +++ b/frontend/src/pages/AccountList.tsx @@ -0,0 +1,575 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider } from 'antd' +import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined } from '@ant-design/icons' +import { useAccountStore } from '../store/accountStore' +import type { Account } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +const AccountList: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail } = useAccountStore() + const [balanceMap, setBalanceMap] = useState>({}) + const [balanceLoading, setBalanceLoading] = useState>({}) + const [detailModalVisible, setDetailModalVisible] = useState(false) + const [detailAccount, setDetailAccount] = useState(null) + const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null) + const [detailBalanceLoading, setDetailBalanceLoading] = useState(false) + + useEffect(() => { + fetchAccounts() + }, [fetchAccounts]) + + // 加载所有账户的余额 + useEffect(() => { + const loadBalances = async () => { + for (const account of accounts) { + if (!balanceMap[account.id] && !balanceLoading[account.id]) { + setBalanceLoading(prev => ({ ...prev, [account.id]: true })) + try { + const balanceData = await fetchAccountBalance(account.id) + setBalanceMap(prev => ({ + ...prev, + [account.id]: { + total: balanceData.totalBalance || '0', + available: balanceData.availableBalance || '0', + position: balanceData.positionBalance || '0' + } + })) + } catch (error) { + console.error(`获取账户 ${account.id} 余额失败:`, error) + setBalanceMap(prev => ({ + ...prev, + [account.id]: { total: '-', available: '-', position: '-' } + })) + } finally { + setBalanceLoading(prev => ({ ...prev, [account.id]: false })) + } + } + } + } + + if (accounts.length > 0) { + loadBalances() + } + }, [accounts]) + + const handleDelete = async (account: Account) => { + try { + await deleteAccount(account.id) + message.success('删除账户成功') + } catch (error: any) { + message.error(error.message || '删除账户失败') + } + } + + const handleSetDefault = async (account: Account) => { + try { + await setDefaultAccount(account.id) + message.success('设置默认账户成功') + } catch (error: any) { + message.error(error.message || '设置默认账户失败') + } + } + + const handleShowDetail = async (account: Account) => { + try { + setDetailModalVisible(true) + setDetailAccount(account) + setDetailBalance(null) + setDetailBalanceLoading(false) + + // 加载详情和余额 + try { + const accountDetail = await fetchAccountDetail(account.id) + setDetailAccount(accountDetail) + + // 加载余额 + setDetailBalanceLoading(true) + try { + const balanceData = await fetchAccountBalance(account.id) + setDetailBalance({ + total: balanceData.totalBalance || '0', + available: balanceData.availableBalance || '0', + position: balanceData.positionBalance || '0', + positions: balanceData.positions || [] + }) + } catch (error) { + console.error('获取余额失败:', error) + setDetailBalance(null) + } finally { + setDetailBalanceLoading(false) + } + } catch (error: any) { + console.error('获取账户详情失败:', error) + message.error(error.message || '获取账户详情失败') + setDetailModalVisible(false) + setDetailAccount(null) + } + } catch (error: any) { + console.error('打开详情失败:', error) + message.error('打开详情失败') + setDetailModalVisible(false) + setDetailAccount(null) + } + } + + const handleRefreshDetailBalance = async () => { + if (!detailAccount) return + + setDetailBalanceLoading(true) + try { + const balanceData = await fetchAccountBalance(detailAccount.id) + setDetailBalance({ + total: balanceData.totalBalance || '0', + available: balanceData.availableBalance || '0', + position: balanceData.positionBalance || '0', + positions: balanceData.positions || [] + }) + message.success('余额刷新成功') + } catch (error: any) { + message.error(error.message || '刷新余额失败') + } finally { + setDetailBalanceLoading(false) + } + } + + const columns = [ + { + title: '账户名称', + dataIndex: 'accountName', + key: 'accountName', + render: (text: string, record: Account) => text || `账户 ${record.id}` + }, + { + title: '钱包地址', + dataIndex: 'walletAddress', + key: 'walletAddress', + render: (address: string) => ( + {address} + ) + }, + { + title: '默认账户', + dataIndex: 'isDefault', + key: 'isDefault', + render: (isDefault: boolean, record: Account) => ( + + handleDelete(record)} + okText="确定删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + + + ) + } + ] + + const mobileColumns = [ + { + title: '账户信息', + key: 'info', + render: (_: any, record: Account) => { + const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured + const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured + + return ( +
+
+ {record.accountName || `账户 ${record.id}`} +
+
+ {record.walletAddress} +
+
+ + {record.isDefault ? '默认' : '普通'} + + + {allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'} + +
+
+ 总余额: {balanceLoading[record.id] ? ( + + ) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? ( + `${balanceMap[record.id].total} USDC` + ) : ( + '-' + )} +
+ {balanceMap[record.id] && balanceMap[record.id].available !== '-' && ( +
+ 可用: {balanceMap[record.id].available} USDC | 仓位: {balanceMap[record.id].position} USDC +
+ )} +
+ ) + } + }, + { + title: '操作', + key: 'action', + width: 100, + render: (_: any, record: Account) => ( + + + {!record.isDefault && ( + + )} + handleDelete(record)} + okText="确定删除" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + + + ) + } + ] + + return ( +
+
+ + 账户管理 + + +
+ + + {isMobile ? ( +
+ ) : ( +
+ )} + + + {/* 账户详情 Modal */} + { + setDetailModalVisible(false) + setDetailAccount(null) + setDetailBalance(null) + }} + footer={[ + , + + ]} + width={isMobile ? '95%' : 800} + style={{ top: isMobile ? 20 : 50 }} + destroyOnClose + maskClosable + closable + > + {detailAccount ? ( +
+ + + {detailAccount.id} + + + {detailAccount.accountName || '-'} + + + + {detailAccount.walletAddress || '-'} + + + + + {detailAccount.isDefault ? '是' : '否'} + + + + {detailBalanceLoading ? ( + + ) : detailBalance ? ( + + {detailBalance.total} USDC + + ) : ( + - + )} + + + {detailBalanceLoading ? ( + + ) : detailBalance ? ( + + {detailBalance.available} USDC + + ) : ( + - + )} + + + {detailBalanceLoading ? ( + + ) : detailBalance ? ( + + {detailBalance.position} USDC + + ) : ( + - + )} + + + + + + + + + {detailAccount.apiKeyConfigured ? '已配置' : '未配置'} + + + + + {detailAccount.apiSecretConfigured ? '已配置' : '未配置'} + + + + + {detailAccount.apiPassphraseConfigured ? '已配置' : '未配置'} + + + + {detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? ( + 完整配置 + ) : ( + 部分配置 + )} + + + + {(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined) && ( + <> + + + {detailAccount.totalOrders !== undefined && ( + + {detailAccount.totalOrders} + + )} + {detailAccount.totalPnl !== undefined && ( + + + {detailAccount.totalPnl} USDC + + + )} + + + )} +
+ ) : ( +
+ +
加载中...
+
+ )} +
+ + ) +} + +export default AccountList + diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx new file mode 100644 index 0000000..6cb49e2 --- /dev/null +++ b/frontend/src/pages/ConfigPage.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from 'react' +import { Card, Form, Input, Button, Switch, Radio, InputNumber, message, Typography, Space } from 'antd' +import { SaveOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { CopyTradingConfig } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +const ConfigPage: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [config, setConfig] = useState(null) + + useEffect(() => { + fetchConfig() + }, []) + + const fetchConfig = async () => { + try { + const response = await apiService.config.get() + if (response.data.code === 0 && response.data.data) { + const data = response.data.data + setConfig(data) + form.setFieldsValue(data) + } else { + message.error(response.data.msg || '获取配置失败') + } + } catch (error: any) { + message.error(error.message || '获取配置失败') + } + } + + const handleSubmit = async (values: any) => { + setLoading(true) + try { + const response = await apiService.config.update(values) + if (response.data.code === 0) { + message.success('更新配置成功') + fetchConfig() + } else { + message.error(response.data.msg || '更新配置失败') + } + } catch (error: any) { + message.error(error.message || '更新配置失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+ 跟单配置 +
+ + +
+ + + 比例模式 + 固定金额模式 + + + + prevValues.copyMode !== currentValues.copyMode} + > + {({ getFieldValue }) => { + const copyMode = getFieldValue('copyMode') + return copyMode === 'RATIO' ? ( + + + + ) : ( + + + + ) + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} + +export default ConfigPage + diff --git a/frontend/src/pages/LeaderAdd.tsx b/frontend/src/pages/LeaderAdd.tsx new file mode 100644 index 0000000..7a0e44b --- /dev/null +++ b/frontend/src/pages/LeaderAdd.tsx @@ -0,0 +1,146 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Form, Input, Button, Select, Switch, message, Typography } from 'antd' +import { ArrowLeftOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import { useAccountStore } from '../store/accountStore' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography +const { Option } = Select + +const LeaderAdd: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { accounts, fetchAccounts } = useAccountStore() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetchAccounts() + }, [fetchAccounts]) + + const handleSubmit = async (values: any) => { + setLoading(true) + try { + const response = await apiService.leaders.add({ + leaderAddress: values.leaderAddress, + leaderName: values.leaderName, + accountId: values.accountId, + category: values.category, + enabled: values.enabled !== false + }) + + if (response.data.code === 0) { + message.success('添加 Leader 成功') + navigate('/leaders') + } else { + message.error(response.data.msg || '添加 Leader 失败') + } + } catch (error: any) { + message.error(error.message || '添加 Leader 失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+ + 添加 Leader +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} + +export default LeaderAdd + diff --git a/frontend/src/pages/LeaderList.tsx b/frontend/src/pages/LeaderList.tsx new file mode 100644 index 0000000..25f81c9 --- /dev/null +++ b/frontend/src/pages/LeaderList.tsx @@ -0,0 +1,155 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Table, Button, Space, Tag, Popconfirm, message } from 'antd' +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import { useState } from 'react' +import type { Leader } from '../types' +import { useMediaQuery } from 'react-responsive' + +const LeaderList: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [leaders, setLeaders] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetchLeaders() + }, []) + + const fetchLeaders = async () => { + setLoading(true) + try { + const response = await apiService.leaders.list() + if (response.data.code === 0 && response.data.data) { + setLeaders(response.data.data.list || []) + } else { + message.error(response.data.msg || '获取 Leader 列表失败') + } + } catch (error: any) { + message.error(error.message || '获取 Leader 列表失败') + } finally { + setLoading(false) + } + } + + const handleDelete = async (leaderId: number) => { + try { + const response = await apiService.leaders.delete({ leaderId }) + if (response.data.code === 0) { + message.success('删除 Leader 成功') + fetchLeaders() + } else { + message.error(response.data.msg || '删除 Leader 失败') + } + } catch (error: any) { + message.error(error.message || '删除 Leader 失败') + } + } + + const columns = [ + { + title: 'Leader 名称', + dataIndex: 'leaderName', + key: 'leaderName', + render: (text: string, record: Leader) => text || `Leader ${record.id}` + }, + { + title: '钱包地址', + dataIndex: 'leaderAddress', + key: 'leaderAddress', + render: (address: string) => ( + {address} + ) + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + render: (category: string | undefined) => category ? ( + {category} + ) : 全部 + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + render: (enabled: boolean) => ( + + {enabled ? '启用' : '禁用'} + + ) + }, + { + title: '跟单比例', + dataIndex: 'copyRatio', + key: 'copyRatio', + render: (ratio: string) => `${ratio}x` + }, + { + title: '操作', + key: 'action', + render: (_: any, record: Leader) => ( + + + handleDelete(record.id)} + okText="确定" + cancelText="取消" + > + + + + ) + } + ] + + return ( +
+
+

Leader 管理

+ +
+ + +
+ + + ) +} + +export default LeaderList + diff --git a/frontend/src/pages/OrderList.tsx b/frontend/src/pages/OrderList.tsx new file mode 100644 index 0000000..3e8dc4c --- /dev/null +++ b/frontend/src/pages/OrderList.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from 'react' +import { Card, Table, Tag, Space, message } from 'antd' +import { apiService } from '../services/api' +import type { CopyOrder } from '../types' +import { useMediaQuery } from 'react-responsive' + +const OrderList: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(false) + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 20, + total: 0 + }) + + useEffect(() => { + fetchOrders() + }, [pagination.current, pagination.pageSize]) + + const fetchOrders = async () => { + setLoading(true) + try { + const response = await apiService.orders.list({ + page: pagination.current, + limit: pagination.pageSize + }) + if (response.data.code === 0 && response.data.data) { + setOrders(response.data.data.list || []) + setPagination(prev => ({ + ...prev, + total: response.data.data?.total || 0 + })) + } else { + message.error(response.data.msg || '获取订单列表失败') + } + } catch (error: any) { + message.error(error.message || '获取订单列表失败') + } finally { + setLoading(false) + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'filled': + return 'success' + case 'cancelled': + return 'default' + case 'failed': + return 'error' + default: + return 'processing' + } + } + + const getSideColor = (side: string) => { + return side === 'BUY' ? 'green' : 'red' + } + + const columns = [ + { + title: 'Leader', + dataIndex: 'leaderName', + key: 'leaderName', + render: (text: string, record: CopyOrder) => text || record.leaderAddress.slice(0, 10) + '...' + }, + { + title: '市场', + dataIndex: 'marketId', + key: 'marketId', + render: (marketId: string) => ( + + {marketId.slice(0, 10)}... + + ) + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + render: (category: string) => ( + {category} + ) + }, + { + title: '方向', + dataIndex: 'side', + key: 'side', + render: (side: string) => ( + {side} + ) + }, + { + title: '价格', + dataIndex: 'price', + key: 'price' + }, + { + title: '数量', + dataIndex: 'size', + key: 'size' + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status: string) => ( + {status} + ) + }, + { + title: '盈亏', + dataIndex: 'pnl', + key: 'pnl', + render: (pnl: string | undefined) => pnl ? ( + + {pnl} USDC + + ) : '-' + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (timestamp: number) => new Date(timestamp).toLocaleString() + } + ] + + return ( +
+
+

订单管理

+
+ + +
{ + setPagination(prev => ({ ...prev, current: page, pageSize })) + } + }} + scroll={isMobile ? { x: 800 } : undefined} + /> + + + ) +} + +export default OrderList + diff --git a/frontend/src/pages/Statistics.tsx b/frontend/src/pages/Statistics.tsx new file mode 100644 index 0000000..1feca21 --- /dev/null +++ b/frontend/src/pages/Statistics.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import { Card, Row, Col, Statistic, message } from 'antd' +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { Statistics as StatisticsType } from '../types' +import { useMediaQuery } from 'react-responsive' + +const Statistics: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetchStatistics() + }, []) + + const fetchStatistics = async () => { + setLoading(true) + try { + const response = await apiService.statistics.global() + if (response.data.code === 0 && response.data.data) { + setStats(response.data.data) + } else { + message.error(response.data.msg || '获取统计信息失败') + } + } catch (error: any) { + message.error(error.message || '获取统计信息失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+

统计信息

+
+ + +
+ + + + + + + = 0 ? : } + valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }} + suffix="USDC" + loading={loading} + /> + + + + + + + + + + + + + + + } + valueStyle={{ color: '#3f8600' }} + suffix="USDC" + loading={loading} + /> + + + + + } + valueStyle={{ color: '#cf1322' }} + suffix="USDC" + loading={loading} + /> + + + + + ) +} + +export default Statistics + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..6896c9a --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,185 @@ +import axios, { AxiosInstance } from 'axios' +import type { ApiResponse } from '../types' + +/** + * API 基础配置 + */ +const apiClient: AxiosInstance = axios.create({ + baseURL: '/api', + timeout: 30000, + headers: { + 'Content-Type': 'application/json' + } +}) + +/** + * 请求拦截器 + */ +apiClient.interceptors.request.use( + (config) => { + return config + }, + (error) => { + return Promise.reject(error) + } +) + +/** + * 响应拦截器 + */ +apiClient.interceptors.response.use( + (response) => { + return response + }, + (error) => { + if (error.response) { + console.error('API 错误:', error.response.data) + } else if (error.request) { + console.error('网络错误:', error.request) + } else { + console.error('请求错误:', error.message) + } + return Promise.reject(error) + } +) + +/** + * API 服务 + */ +export const apiService = { + /** + * 账户管理 API + */ + accounts: { + /** + * 导入账户 + */ + import: (data: any) => + apiClient.post>('/copy-trading/accounts/import', data), + + /** + * 更新账户 + */ + update: (data: any) => + apiClient.post>('/copy-trading/accounts/update', data), + + /** + * 删除账户 + */ + delete: (data: { accountId: number }) => + apiClient.post>('/copy-trading/accounts/delete', data), + + /** + * 查询账户列表 + */ + list: () => + apiClient.post>('/copy-trading/accounts/list', {}), + + /** + * 查询账户详情 + */ + detail: (data: { accountId?: number }) => + apiClient.post>('/copy-trading/accounts/detail', data), + + /** + * 查询账户余额 + */ + balance: (data: { accountId?: number }) => + apiClient.post>('/copy-trading/accounts/balance', data), + + /** + * 设置默认账户 + */ + setDefault: (data: { accountId: number }) => + apiClient.post>('/copy-trading/accounts/set-default', data) + }, + + /** + * Leader 管理 API + */ + leaders: { + /** + * 添加 Leader + */ + add: (data: any) => + apiClient.post>('/copy-trading/leaders/add', data), + + /** + * 更新 Leader + */ + update: (data: any) => + apiClient.post>('/copy-trading/leaders/update', data), + + /** + * 删除 Leader + */ + delete: (data: { leaderId: number }) => + apiClient.post>('/copy-trading/leaders/delete', data), + + /** + * 查询 Leader 列表 + */ + list: (data: { enabled?: boolean; category?: string } = {}) => + apiClient.post>('/copy-trading/leaders/list', data) + }, + + /** + * 配置管理 API + */ + config: { + /** + * 获取全局配置 + */ + get: () => + apiClient.post>('/copy-trading/config/get', {}), + + /** + * 更新全局配置 + */ + update: (data: any) => + apiClient.post>('/copy-trading/config/update', data) + }, + + /** + * 订单管理 API + */ + orders: { + /** + * 查询跟单订单列表 + */ + list: (data: any) => + apiClient.post>('/copy-trading/orders/list', data), + + /** + * 取消跟单订单 + */ + cancel: (data: { copyOrderId: number }) => + apiClient.post>('/copy-trading/orders/cancel', data) + }, + + /** + * 统计 API + */ + statistics: { + /** + * 获取全局统计 + */ + global: (data: { startTime?: number; endTime?: number } = {}) => + apiClient.post>('/copy-trading/statistics/global', data), + + /** + * 获取 Leader 统计 + */ + leader: (data: { leaderId: number; startTime?: number; endTime?: number }) => + apiClient.post>('/copy-trading/statistics/leader', data), + + /** + * 获取分类统计 + */ + category: (data: { category: string; startTime?: number; endTime?: number }) => + apiClient.post>('/copy-trading/statistics/category', data) + } +} + +export default apiClient + diff --git a/frontend/src/store/accountStore.ts b/frontend/src/store/accountStore.ts new file mode 100644 index 0000000..2c3024b --- /dev/null +++ b/frontend/src/store/accountStore.ts @@ -0,0 +1,153 @@ +import { create } from 'zustand' +import type { Account } from '../types' +import { apiService } from '../services/api' + +interface AccountStore { + accounts: Account[] + currentAccount: Account | null + loading: boolean + error: string | null + + // Actions + fetchAccounts: () => Promise + setCurrentAccount: (account: Account | null) => void + importAccount: (data: any) => Promise + updateAccount: (data: any) => Promise + deleteAccount: (accountId: number) => Promise + setDefaultAccount: (accountId: number) => Promise + fetchAccountDetail: (accountId: number) => Promise + fetchAccountBalance: (accountId: number) => Promise<{ + availableBalance: string + positionBalance: string + totalBalance: string + positions: any[] + }> +} + +export const useAccountStore = create((set, get) => ({ + accounts: [], + currentAccount: null, + loading: false, + error: null, + + fetchAccounts: async () => { + set({ loading: true, error: null }) + try { + const response = await apiService.accounts.list() + if (response.data.code === 0 && response.data.data) { + const accounts = response.data.data.list || [] + set({ accounts, loading: false }) + + // 设置默认账户为当前账户 + const defaultAccount = accounts.find((a: Account) => a.isDefault) + if (defaultAccount) { + set({ currentAccount: defaultAccount }) + } + } else { + set({ error: response.data.msg || '获取账户列表失败', loading: false }) + } + } catch (error: any) { + set({ error: error.message || '获取账户列表失败', loading: false }) + } + }, + + setCurrentAccount: (account) => { + set({ currentAccount: account }) + }, + + importAccount: async (data) => { + set({ loading: true, error: null }) + try { + const response = await apiService.accounts.import(data) + if (response.data.code === 0) { + await get().fetchAccounts() + } else { + set({ error: response.data.msg || '导入账户失败', loading: false }) + throw new Error(response.data.msg || '导入账户失败') + } + } catch (error: any) { + set({ error: error.message || '导入账户失败', loading: false }) + throw error + } + }, + + updateAccount: async (data) => { + set({ loading: true, error: null }) + try { + const response = await apiService.accounts.update(data) + if (response.data.code === 0) { + await get().fetchAccounts() + } else { + set({ error: response.data.msg || '更新账户失败', loading: false }) + throw new Error(response.data.msg || '更新账户失败') + } + } catch (error: any) { + set({ error: error.message || '更新账户失败', loading: false }) + throw error + } + }, + + deleteAccount: async (accountId) => { + set({ loading: true, error: null }) + try { + const response = await apiService.accounts.delete({ accountId }) + if (response.data.code === 0) { + await get().fetchAccounts() + } else { + set({ error: response.data.msg || '删除账户失败', loading: false }) + throw new Error(response.data.msg || '删除账户失败') + } + } catch (error: any) { + set({ error: error.message || '删除账户失败', loading: false }) + throw error + } + }, + + setDefaultAccount: async (accountId) => { + set({ loading: true, error: null }) + try { + const response = await apiService.accounts.setDefault({ accountId }) + if (response.data.code === 0) { + await get().fetchAccounts() + } else { + set({ error: response.data.msg || '设置默认账户失败', loading: false }) + throw new Error(response.data.msg || '设置默认账户失败') + } + } catch (error: any) { + set({ error: error.message || '设置默认账户失败', loading: false }) + throw error + } + }, + + fetchAccountDetail: async (accountId) => { + set({ loading: true, error: null }) + try { + const response = await apiService.accounts.detail({ accountId }) + if (response.data.code === 0 && response.data.data) { + set({ loading: false }) + return response.data.data + } else { + const errorMsg = response.data.msg || '获取账户详情失败' + set({ error: errorMsg, loading: false }) + throw new Error(errorMsg) + } + } catch (error: any) { + set({ error: error.message || '获取账户详情失败', loading: false }) + throw error + } + }, + + fetchAccountBalance: async (accountId) => { + try { + const response = await apiService.accounts.balance({ accountId }) + if (response.data.code === 0 && response.data.data) { + return response.data.data + } else { + throw new Error(response.data.msg || '获取账户余额失败') + } + } catch (error: any) { + throw error + } + } +})) + diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..ede5a37 --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,32 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + min-height: 100vh; +} + +/* 移动端适配 */ +@media (max-width: 768px) { + body { + font-size: 14px; + } +} + +/* 桌面端适配 */ +@media (min-width: 769px) { + body { + font-size: 16px; + } +} + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..dabb9aa --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,156 @@ +/** + * API 统一响应格式 + */ +export interface ApiResponse { + code: number + data: T | null + msg: string +} + +/** + * 账户信息 + */ +export interface Account { + id: number + walletAddress: string + accountName?: string + isDefault: boolean + apiKeyConfigured: boolean + apiSecretConfigured: boolean + apiPassphraseConfigured: boolean + balance?: string + totalOrders?: number + totalPnl?: string +} + +/** + * 账户列表响应 + */ +export interface AccountListResponse { + list: Account[] + total: number +} + +/** + * 账户导入请求 + */ +export interface AccountImportRequest { + privateKey: string + walletAddress: string + accountName?: string + apiKey?: string + apiSecret?: string + apiPassphrase?: string + isDefault?: boolean +} + +/** + * 账户更新请求 + */ +export interface AccountUpdateRequest { + accountId: number + accountName?: string + apiKey?: string + apiSecret?: string + apiPassphrase?: string + isDefault?: boolean +} + +/** + * Leader 信息 + */ +export interface Leader { + id: number + leaderAddress: string + leaderName?: string + accountId?: number + category?: string + enabled: boolean + copyRatio: string + totalOrders?: number + totalPnl?: string +} + +/** + * Leader 列表响应 + */ +export interface LeaderListResponse { + list: Leader[] + total: number +} + +/** + * Leader 添加请求 + */ +export interface LeaderAddRequest { + leaderAddress: string + leaderName?: string + accountId?: number + category?: string + enabled?: boolean +} + +/** + * 跟单配置 + */ +export interface CopyTradingConfig { + copyMode: 'RATIO' | 'FIXED' + copyRatio: string + fixedAmount?: string + maxOrderSize: string + minOrderSize: string + maxDailyLoss: string + maxDailyOrders: number + priceTolerance: string + delaySeconds: number + pollIntervalSeconds: number + useWebSocket: boolean + websocketReconnectInterval: number + websocketMaxRetries: number + enabled: boolean +} + +/** + * 跟单订单 + */ +export interface CopyOrder { + id: number + accountId: number + leaderId: number + leaderAddress: string + leaderName?: string + marketId: string + category: string + side: 'BUY' | 'SELL' + price: string + size: string + copyRatio: string + orderId?: string + status: string + filledSize: string + pnl?: string + createdAt: number +} + +/** + * 订单列表响应 + */ +export interface OrderListResponse { + list: CopyOrder[] + total: number + page: number + limit: number +} + +/** + * 统计信息 + */ +export interface Statistics { + totalOrders: number + totalPnl: string + winRate: string + avgPnl: string + maxProfit: string + maxLoss: string +} + diff --git a/frontend/src/utils/ethers.ts b/frontend/src/utils/ethers.ts new file mode 100644 index 0000000..30a021f --- /dev/null +++ b/frontend/src/utils/ethers.ts @@ -0,0 +1,150 @@ +import { ethers } from 'ethers' + +/** + * 从私钥推导钱包地址 + */ +export function getAddressFromPrivateKey(privateKey: string): string { + try { + // 移除 0x 前缀(如果有) + const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey + + // 创建钱包 + const wallet = new ethers.Wallet(`0x${cleanKey}`) + return wallet.address + } catch (error) { + throw new Error(`无效的私钥: ${error}`) + } +} + +/** + * 从助记词推导钱包地址 + * @param mnemonic 助记词(12或24个单词,用空格分隔) + * @param index 派生路径索引(默认0,使用第一个地址) + */ +export function getAddressFromMnemonic(mnemonic: string, index: number = 0): string { + try { + // 验证助记词格式 + if (!isValidMnemonic(mnemonic)) { + throw new Error('助记词格式不正确') + } + + // ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase + // 它默认使用路径 m/44'/60'/0'/0/0 + if (index === 0) { + const wallet = ethers.Wallet.fromPhrase(mnemonic.trim()) + return wallet.address + } + + // 对于其他索引,使用 HDNodeWallet + // 从助记词创建 Mnemonic 对象 + const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim()) + + // 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index + // ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径 + const derivationPath = `m/44'/60'/0'/0/${index}` + const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath) + + return wallet.address + } catch (error: any) { + // 如果直接指定路径失败,尝试分步派生 + try { + const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim()) + // 先创建根节点(不指定路径) + const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj) + // 使用相对路径(不以 m/ 开头) + const relativePath = `44'/60'/0'/0/${index}` + const wallet = rootNode.derivePath(relativePath) + return wallet.address + } catch (fallbackError: any) { + throw new Error(`无效的助记词: ${error.message || error}`) + } + } +} + +/** + * 从助记词导出私钥 + * @param mnemonic 助记词(12或24个单词,用空格分隔) + * @param index 派生路径索引(默认0,使用第一个地址) + */ +export function getPrivateKeyFromMnemonic(mnemonic: string, index: number = 0): string { + try { + // 验证助记词格式 + if (!isValidMnemonic(mnemonic)) { + throw new Error('助记词格式不正确') + } + + // ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase + // 它默认使用路径 m/44'/60'/0'/0/0 + if (index === 0) { + const wallet = ethers.Wallet.fromPhrase(mnemonic.trim()) + return wallet.privateKey + } + + // 对于其他索引,使用 HDNodeWallet + // 从助记词创建 Mnemonic 对象 + const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim()) + + // 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index + // ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径 + const derivationPath = `m/44'/60'/0'/0/${index}` + const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath) + + return wallet.privateKey + } catch (error: any) { + // 如果直接指定路径失败,尝试分步派生 + try { + const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim()) + // 先创建根节点(不指定路径) + const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj) + // 使用相对路径(不以 m/ 开头) + const relativePath = `44'/60'/0'/0/${index}` + const wallet = rootNode.derivePath(relativePath) + return wallet.privateKey + } catch (fallbackError: any) { + throw new Error(`无法从助记词导出私钥: ${error.message || error}`) + } + } +} + +/** + * 验证助记词格式 + */ +export function isValidMnemonic(mnemonic: string): boolean { + try { + if (!mnemonic || !mnemonic.trim()) { + return false + } + + const words = mnemonic.trim().split(/\s+/) + // 助记词应该是 12 或 24 个单词 + if (words.length !== 12 && words.length !== 24) { + return false + } + + // 使用 ethers 验证助记词 + ethers.Mnemonic.fromPhrase(mnemonic.trim()) + return true + } catch { + return false + } +} + +/** + * 验证钱包地址格式 + */ +export function isValidWalletAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +/** + * 验证私钥格式 + */ +export function isValidPrivateKey(privateKey: string): boolean { + try { + const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey + return /^[a-fA-F0-9]{64}$/.test(cleanKey) + } catch { + return false + } +} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..256e8d8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..e428d50 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4618432 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + } +}) +