From 708d6ddb416a2eccd4877ce10239f689c9f6fa7b Mon Sep 17 00:00:00 2001 From: WrBug Date: Thu, 26 Feb 2026 10:28:28 +0800 Subject: [PATCH 01/26] =?UTF-8?q?fix(binance):=20=E4=BF=AE=E5=A4=8D=20K=20?= =?UTF-8?q?=E7=BA=BF=20WebSocket=20=E6=96=AD=E5=BC=80=E5=90=8E=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E9=87=8D=E8=BF=9E=E5=AF=BC=E8=87=B4=20openPrice/close?= =?UTF-8?q?Price=20=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 WebSocket 因网络问题断开时,updateSubscriptions() 只检查订阅集合是否相同, 未检查连接是否真的存在,导致断开后无法重连。 现在会额外检查需要的连接是否都存在,如果缺失则重新建立连接。 Made-with: Cursor --- .../polymarketbot/service/binance/BinanceKlineService.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt index c47fe53..67dbf83 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt @@ -91,7 +91,12 @@ class BinanceKlineService { } }.toSet() val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet() - if (normalized == requiredMarketPrefixes.get()) return + + // 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开) + val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys } + + // 只有当集合相同且所有需要的连接都存在时才跳过 + if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return requiredMarketPrefixes.set(normalized) synchronized(subscriptionLock) { connectedWebSockets.keys.toList().forEach { wsKey -> From a2be5b7f523f5851a09686e544f74f4af39136a0 Mon Sep 17 00:00:00 2001 From: WrBug Date: Thu, 26 Feb 2026 19:23:00 +0800 Subject: [PATCH 02/26] =?UTF-8?q?feat(crypto-tail):=20=E4=BB=B7=E5=B7=AE?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E7=9B=91=E6=8E=A7=E9=A1=B5=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E4=B8=8E=20UI=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:手动下单 API,trigger_type 区分 AUTO/MANUAL,价格 4 位小数向上取整 - 前端:监控页手动买入 Up/Down 按钮、二次确认弹窗、策略信息移动端适配 - 下单价格最多 4 位小数;确认页方向仅显示 Up/Down;按钮文案改为「买入 Up/Down」 Made-with: Cursor --- .../CryptoTailStrategyController.kt | 42 +++ .../dto/CryptoTailManualOrderRequest.kt | 21 ++ .../dto/CryptoTailManualOrderResponse.kt | 31 ++ .../entity/CryptoTailStrategyTrigger.kt | 3 + .../CryptoTailStrategyExecutionService.kt | 184 ++++++++- ...dd_trigger_type_to_crypto_tail_trigger.sql | 5 + deploy-interactive-README.md | 207 +++++++++- deploy-interactive.sh | 354 +++++++++--------- frontend/src/locales/en/common.json | 33 ++ frontend/src/locales/zh-CN/common.json | 33 ++ frontend/src/locales/zh-TW/common.json | 33 ++ frontend/src/pages/CryptoTailMonitor.tsx | 254 ++++++++++++- frontend/src/services/api.ts | 14 +- frontend/src/types/index.ts | 28 ++ 14 files changed, 1045 insertions(+), 197 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt create mode 100644 backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 40c30e9..4bc986f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -13,10 +13,13 @@ import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService +import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService import org.slf4j.LoggerFactory import org.springframework.context.MessageSource import org.springframework.http.ResponseEntity @@ -24,12 +27,14 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import kotlinx.coroutines.runBlocking @RestController @RequestMapping("/api/crypto-tail-strategy") class CryptoTailStrategyController( private val cryptoTailStrategyService: CryptoTailStrategyService, private val cryptoTailMonitorService: CryptoTailMonitorService, + private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService, private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, private val messageSource: MessageSource ) { @@ -216,4 +221,41 @@ class CryptoTailStrategyController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) } } + + /** + * 手动下单 + * 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单 + */ + @PostMapping("/manual-order") + fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity> { + return runBlocking { + try { + if (request.strategyId <= 0) { + return@runBlocking ResponseEntity.ok( + ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource) + ) + } + val result = cryptoTailStrategyExecutionService.manualOrder(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("手动下单失败: ${e.message}", e) + val code = when (e.message) { + "策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND + "当前周期已下单" -> ErrorCode.PARAM_ERROR + "价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR + "数量不能少于 1" -> ErrorCode.PARAM_ERROR + "总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR + "总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR + else -> ErrorCode.SERVER_ERROR + } + ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("手动下单异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt new file mode 100644 index 0000000..38074c7 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderRequest.kt @@ -0,0 +1,21 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略手动下单请求 + */ +data class CryptoTailManualOrderRequest( + /** 策略ID */ + val strategyId: Long = 0L, + /** 当前周期开始时间 (Unix 秒) */ + val periodStartUnix: Long = 0L, + /** 下单方向: UP or DOWN */ + val direction: String = "UP", + /** 下单价格 */ + val price: String = "0", + /** 下单数量 */ + val size: String = "1", + /** 市场标题(用于记录) */ + val marketTitle: String = "", + /** Token IDs */ + val tokenIds: List = emptyList() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt new file mode 100644 index 0000000..12000ef --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailManualOrderResponse.kt @@ -0,0 +1,31 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略手动下单响应 + */ +data class CryptoTailManualOrderResponse( + /** 是否成功 */ + val success: Boolean = false, + /** 订单ID */ + val orderId: String? = null, + /** 提示消息 */ + val message: String = "", + /** 下单详情 */ + val orderDetails: ManualOrderDetails? = null +) + +/** + * 手动下单详情 + */ +data class ManualOrderDetails( + /** 策略ID */ + val strategyId: Long = 0L, + /** 方向 */ + val direction: String = "", + /** 下单价格 */ + val price: String = "", + /** 下单数量 */ + val size: String = "", + /** 总金额 */ + val totalAmount: String = "" +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt index e8020df..472271e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt @@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger( @Column(name = "fail_reason", length = 500) val failReason: String? = null, + @Column(name = "trigger_type", nullable = false, length = 20) + val triggerType: String = "AUTO", + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index 9119b1a..97478af 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -3,6 +3,9 @@ package com.wrbug.polymarketbot.service.cryptotail import com.wrbug.polymarketbot.api.GammaEventBySlugResponse import com.wrbug.polymarketbot.api.NewOrderRequest import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest +import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse +import com.wrbug.polymarketbot.dto.ManualOrderDetails import com.wrbug.polymarketbot.entity.Account import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger @@ -411,7 +414,8 @@ class CryptoTailStrategyExecutionService( outcomeIndex, triggerPrice, amountUsdc, - orderRequest + orderRequest, + triggerType = "AUTO" ) return } @@ -427,7 +431,8 @@ class CryptoTailStrategyExecutionService( outcomeIndex: Int, triggerPrice: BigDecimal, amountUsdc: BigDecimal, - orderRequest: NewOrderRequest + orderRequest: NewOrderRequest, + triggerType: String = "AUTO" ) { var failReason: String? = null try { @@ -444,9 +449,10 @@ class CryptoTailStrategyExecutionService( amountUsdc, body.orderId, "success", - null + null, + triggerType = triggerType ) - logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType") return } failReason = body.errorMsg ?: "unknown" @@ -467,7 +473,8 @@ class CryptoTailStrategyExecutionService( amountUsdc, null, "fail", - failReason + failReason, + triggerType = triggerType ) logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason") } @@ -650,7 +657,8 @@ class CryptoTailStrategyExecutionService( amountUsdc: BigDecimal, orderId: String?, status: String, - failReason: String? + failReason: String?, + triggerType: String = "AUTO" ) { val record = CryptoTailStrategyTrigger( strategyId = strategy.id!!, @@ -661,11 +669,173 @@ class CryptoTailStrategyExecutionService( amountUsdc = amountUsdc, orderId = orderId, status = status, - failReason = failReason + failReason = failReason, + triggerType = triggerType ) triggerRepository.save(record) } + /** + * 手动下单:用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单 + */ + suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result { + return try { + val strategy = strategyRepository.findById(request.strategyId).orElse(null) + ?: return Result.failure(IllegalArgumentException("策略不存在")) + + val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1 + + if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) { + return Result.failure(IllegalArgumentException("outcomeIndex 越界")) + } + + val price = request.price.toSafeBigDecimal() + if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("价格必须在 0~1 之间")) + } + val priceRounded = price.setScale(4, RoundingMode.UP) + + val size = request.size.toSafeBigDecimal() + if (size < BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("数量不能少于 1")) + } + + val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP) + if (amountUsdc < BigDecimal.ONE) { + return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC")) + } + + val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix) + mutex.withLock { + if (triggerRepository.findByStrategyIdAndPeriodStartUnix( + strategy.id!!, + request.periodStartUnix + ) != null + ) { + return@withLock Result.failure(IllegalArgumentException("当前周期已下单")) + } + + var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix) + if (ctx == null) { + ctx = ensurePeriodContext( + strategy, + request.periodStartUnix, + request.tokenIds, + request.marketTitle.ifBlank { null } + ) + } + if (ctx != null) { + val tokenId = request.tokenIds.getOrNull(outcomeIndex) + ?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界")) + + val priceStr = priceRounded.toPlainString() + val sizeStr = size.toPlainString() + val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" + + val signedOrder = orderSigningService.createAndSignOrder( + privateKey = ctx.decryptedPrivateKey, + makerAddress = ctx.account.proxyAddress, + tokenId = tokenId, + side = "BUY", + price = priceStr, + size = sizeStr, + signatureType = ctx.signatureType, + nonce = "0", + feeRateBps = feeRateBps, + expiration = "0" + ) + + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = ctx.account.apiKey!!, + orderType = "FAK", + deferExec = false + ) + + val orderResult = submitOrderForManualOrder( + ctx.clobApi, + strategy, + request.periodStartUnix, + request.marketTitle, + outcomeIndex, + priceRounded, + amountUsdc, + orderRequest + ) + + orderResult.fold( + onSuccess = { orderId -> + Result.success( + CryptoTailManualOrderResponse( + success = true, + orderId = orderId, + message = "下单成功", + orderDetails = ManualOrderDetails( + strategyId = strategy.id!!, + direction = request.direction, + price = priceStr, + size = sizeStr, + totalAmount = amountUsdc.toPlainString() + ) + ) + ) + }, + onFailure = { e -> + Result.failure(e) + } + ) + } else { + Result.failure(IllegalArgumentException("账户未配置或凭证不足")) + } + } + } catch (e: Exception) { + logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun submitOrderForManualOrder( + clobApi: PolymarketClobApi, + strategy: CryptoTailStrategy, + periodStartUnix: Long, + marketTitle: String?, + outcomeIndex: Int, + price: BigDecimal, + amountUsdc: BigDecimal, + orderRequest: NewOrderRequest + ): Result { + return try { + val response = clobApi.createOrder(orderRequest) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + if (body.success && body.orderId != null) { + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + price, + amountUsdc, + body.orderId, + "success", + null, + triggerType = "MANUAL" + ) + logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + Result.success(body.orderId) + } else { + Result.failure(Exception(body.errorMsg ?: "unknown")) + } + } else { + val errorBody = response.errorBody()?.string().orEmpty() + Result.failure(Exception(errorBody.ifEmpty { "请求失败" })) + } + } catch (e: Exception) { + logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e) + Result.failure(e) + } + } + @PreDestroy fun destroy() { // 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留 diff --git a/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql b/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql new file mode 100644 index 0000000..60df806 --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__add_trigger_type_to_crypto_tail_trigger.sql @@ -0,0 +1,5 @@ +-- 添加触发类型字段到加密价差策略触发记录表 +-- AUTO: 自动下单触发 +-- MANUAL: 手动下单触发 +ALTER TABLE crypto_tail_strategy_trigger +ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)'; diff --git a/deploy-interactive-README.md b/deploy-interactive-README.md index de58e32..eb953d2 100644 --- a/deploy-interactive-README.md +++ b/deploy-interactive-README.md @@ -1,4 +1,209 @@ -# PolyHermes 一键部署脚本使用说明 +# PolyHermes One-Click Deployment Script / PolyHermes 一键部署脚本使用说明 + +[English](#english) | [中文](#中文) + +--- + + +## English + +## ✨ Core Features + +- **Run from any directory** - No need to download source code +- **Online images only** - Pull official images from Docker Hub +- **Auto-download config** - Download the latest `docker-compose.prod.yml` from GitHub +- **Interactive configuration** - User-friendly Q&A style configuration wizard +- **Auto-generate secrets** - All sensitive configurations will auto-generate secure random values on Enter + +## 🚀 Quick Start + +### One-Click Installation (Recommended) + +**Using curl (Recommended):** +```bash +mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh +``` + +**Using wget:** +```bash +mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh +``` + +This command will automatically: +- 📁 Create a dedicated working directory `~/polyhermes` +- 📥 Download the deployment script +- ✅ Check Docker environment +- ⚙️ Configure all parameters interactively (press Enter for defaults) +- 🔐 Auto-generate secure random secrets +- 🚀 Download latest images and deploy + +**Or run directly via pipe (without saving file):** +```bash +# curl method +mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash + +# wget method +mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash +``` + +### Method 1: Download and Run Script Directly + +```bash +# Download script +curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh + +# Add execute permission +chmod +x deploy-interactive.sh + +# Run +./deploy-interactive.sh +``` + +### Method 2: Run in Project Directory + +```bash +git clone https://github.com/WrBug/PolyHermes.git +cd PolyHermes +./deploy-interactive.sh +``` + +## 📝 Usage Flow + +After running the script, you will be guided through the following steps: + +``` +Step 1: Environment Check → Check Docker/Docker Compose +Step 2: Configuration → Interactive input (press Enter for defaults) +Step 3: Get Deploy Config → Download docker-compose.prod.yml from GitHub +Step 4: Generate Env File → Auto-generate .env +Step 5: Pull Docker Images → Pull latest images from Docker Hub +Step 6: Deploy Services → Start containers +Step 7: Health Check → Verify services are running properly +``` + +## ⚡ Simplest Usage + +**Press Enter for all configuration items to use default values**, the script will automatically: +- Use port 80 (application) and 3307 (MySQL) +- Generate 32-character database password +- Generate 128-character JWT secret +- Generate 64-character admin reset key +- Generate 64-character encryption key +- Configure reasonable log levels + +### Interactive Example + +The script will prompt you for configuration one by one, **press Enter to skip and use default values**: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Step 2: Configuration +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +💡 All configurations are optional, press Enter to use default or auto-generated values + +⚠ Secret config: Press Enter to auto-generate secure random secrets +⚠ Other config: Press Enter to use default values in parentheses + +【Basic Configuration】 +Will configure: Server port, MySQL port, Timezone +➤ Server port [Default: 80]: ⏎ +➤ MySQL port (external access) [Default: 3307]: ⏎ +➤ Timezone [Default: Asia/Shanghai]: ⏎ + +【Database Configuration】 +Will configure: Database username, Database password +➤ Database username [Default: root]: ⏎ +➤ Database password [Enter to auto-generate]: ⏎ +[✓] Database password auto-generated (32 characters) + +【Security Configuration】 +Will configure: JWT secret, Admin password reset key, Data encryption key +➤ JWT secret [Enter to auto-generate]: ⏎ +[✓] JWT secret auto-generated (128 characters) +➤ Admin password reset key [Enter to auto-generate]: ⏎ +[✓] Admin reset key auto-generated (64 characters) +➤ Encryption key (for API Key encryption) [Enter to auto-generate]: ⏎ +[✓] Encryption key auto-generated (64 characters) + +【Log Configuration】 +Will configure: Root log level, Application log level +Available levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF +➤ Root log level (third-party libs) [Default: WARN]: ⏎ +➤ Application log level [Default: INFO]: ⏎ + +【Other Configuration】 +Will configure: Runtime environment, Auto-update policy, GitHub repo +➤ Spring Profile [Default: prod]: ⏎ +➤ Allow prerelease updates (true/false) [Default: false]: ⏎ +➤ GitHub repository [Default: WrBug/PolyHermes]: ⏎ +``` + +## 🔧 Files Generated by Script + +After running, the script will generate in the current directory: + +1. **docker-compose.prod.yml** - Docker Compose config downloaded from GitHub (always latest) +2. **.env** - Environment variables file auto-generated based on your configuration + +These two files contain all the configuration needed to run PolyHermes. + +## 🌐 Post-Deployment Management + +### Quick Update (Recommended) + +If you already have configuration files, running the script again will detect and ask: + +```bash +./deploy-interactive.sh +``` + +``` +【Existing Configuration Detected】 +Found existing .env configuration file + +Use existing configuration to update images directly? [Y/n]: ⏎ +``` + +- **Press Enter or input Y**: Use existing config, pull latest images and update +- **Input N**: Reconfigure (existing config will be backed up) + +### Manual Management Commands + +```bash +# View service status +docker compose -f docker-compose.prod.yml ps + +# View logs +docker compose -f docker-compose.prod.yml logs -f + +# Restart services +docker compose -f docker-compose.prod.yml restart + +# Stop services +docker compose -f docker-compose.prod.yml down + +# Update to latest version +docker pull wrbug/polyhermes:latest +docker compose -f docker-compose.prod.yml up -d +``` + +## 🔐 Security Recommendations + +- **Protect .env file**: Contains sensitive information, never commit to version control +- **Backup database regularly**: Data is stored in Docker volume `mysql-data` +- **Configure HTTPS for production**: Recommend using Nginx or Caddy as reverse proxy + +## 📞 Support + +- [GitHub Repository](https://github.com/WrBug/PolyHermes) +- [Issue Feedback](https://github.com/WrBug/PolyHermes/issues) +- [Full Deployment Documentation](docs/zh/DEPLOYMENT_GUIDE.md) + +--- + + +## 中文 ## ✨ 核心特性 diff --git a/deploy-interactive.sh b/deploy-interactive.sh index 7ac6d19..ec0981e 100755 --- a/deploy-interactive.sh +++ b/deploy-interactive.sh @@ -1,18 +1,19 @@ #!/bin/bash # ======================================== +# PolyHermes Interactive Deploy Script # PolyHermes 交互式一键部署脚本 # ======================================== -# 功能: -# - 交互式配置环境变量 -# - 自动生成安全密钥 -# - 使用 Docker Hub 线上镜像部署 -# - 支持配置预检和回滚 +# Features / 功能: +# - Interactive env config / 交互式配置环境变量 +# - Auto-generate secrets / 自动生成安全密钥 +# - Deploy via Docker Hub images / 使用 Docker Hub 线上镜像部署 +# - Config check and rollback / 支持配置预检和回滚 # ======================================== set -e -# 颜色输出 +# Colors / 颜色输出 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -20,7 +21,14 @@ BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color -# 打印函数 +# Language: LANG=zh* → prompts in Chinese only; else show "中文 / English" +# 语言:LANG 为 zh* 时仅中文,否则显示「中文 / English」 +USE_ZH_ONLY=false +case "${LANG:-}" in + zh*) USE_ZH_ONLY=true ;; +esac + +# Print functions / 打印函数 info() { echo -e "${GREEN}[✓]${NC} $1" } @@ -37,6 +45,17 @@ title() { echo -e "${CYAN}${1}${NC}" } +# Bilingual: 中文 / English (or Chinese only when LANG=zh*) +bilingual() { + local zh="$1" + local en="$2" + if [ "$USE_ZH_ONLY" = true ]; then + echo "$zh" + else + echo "$zh / $en" + fi +} + # 生成随机密钥 generate_secret() { local length=${1:-32} @@ -47,54 +66,57 @@ generate_secret() { fi } -# 生成随机端口号(10000-60000之间) +# 生成随机端口号(10000-60000之间)/ Generate random port (10000-60000) generate_random_port() { echo $((10000 + RANDOM % 50001)) } -# 读取用户输入(支持默认值) +# 读取用户输入(支持默认值)/ Read user input (with default) read_input() { local prompt="$1" local default="$2" local is_secret="$3" local value="" - # 构建提示信息(不使用颜色,因为 read -p 可能不支持) local prompt_text="" if [ -n "$default" ]; then if [ "$is_secret" = "secret" ]; then - prompt_text="${prompt} [回车自动生成]: " + if [ "$USE_ZH_ONLY" = true ]; then + prompt_text="${prompt} [回车自动生成]: " + else + prompt_text="${prompt} [Enter to auto-generate]: " + fi else - prompt_text="${prompt} [默认: ${default}]: " + if [ "$USE_ZH_ONLY" = true ]; then + prompt_text="${prompt} [默认: ${default}]: " + else + prompt_text="${prompt} [Default: ${default}]: " + fi fi else prompt_text="${prompt}: " fi - # 使用 read -p 确保提示正确显示 read -r -p "$prompt_text" value - # 如果用户没有输入,使用默认值 if [ -z "$value" ]; then if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then - # 自动生成密钥 case "$prompt" in - *JWT*) + *JWT*|*jwt*) value=$(generate_secret 64) - # 输出到 stderr,避免被捕获到返回值中 - info "已自动生成 JWT 密钥(128字符)" >&2 + info "$(bilingual "已自动生成 JWT 密钥(128字符)" "JWT secret auto-generated (128 chars)")" >&2 ;; - *管理员*|*ADMIN*) + *管理员*|*ADMIN*|*admin*|*reset*|*Reset*) value=$(generate_secret 32) - info "已自动生成管理员重置密钥(64字符)" >&2 + info "$(bilingual "已自动生成管理员重置密钥(64字符)" "Admin reset key auto-generated (64 chars)")" >&2 ;; - *加密*|*CRYPTO*) + *加密*|*CRYPTO*|*crypto*|*Encryption*) value=$(generate_secret 32) - info "已自动生成加密密钥(64字符)" >&2 + info "$(bilingual "已自动生成加密密钥(64字符)" "Encryption key auto-generated (64 chars)")" >&2 ;; - *数据库密码*|*DB_PASSWORD*) + *数据库密码*|*DB_PASSWORD*|*database*|*Database*) value=$(generate_secret 16) - info "已自动生成数据库密码(32字符)" >&2 + info "$(bilingual "已自动生成数据库密码(32字符)" "Database password auto-generated (32 chars)")" >&2 ;; *) value="$default" @@ -108,126 +130,115 @@ read_input() { echo "$value" } -# 检查 Docker 环境 +# 检查 Docker 环境 / Check Docker environment check_docker() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 1: 环境检查" + title " $(bilingual "步骤 1: 环境检查" "Step 1: Environment Check")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 检查 Docker if ! command -v docker &> /dev/null; then - error "Docker 未安装" + error "$(bilingual "Docker 未安装" "Docker is not installed")" echo "" - info "请先安装 Docker:" + info "$(bilingual "请先安装 Docker:" "Please install Docker first:")" info " macOS: brew install docker" info " Ubuntu/Debian: apt-get install docker.io" info " CentOS/RHEL: yum install docker" exit 1 fi - info "Docker 已安装: $(docker --version | head -1)" + info "$(bilingual "Docker 已安装" "Docker installed"): $(docker --version | head -1)" - # 检查 Docker Compose if docker compose version &> /dev/null 2>&1; then - info "Docker Compose 已安装: $(docker compose version)" + info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker compose version)" elif command -v docker-compose &> /dev/null; then - info "Docker Compose 已安装: $(docker-compose --version)" + info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker-compose --version)" else - error "Docker Compose 未安装" + error "$(bilingual "Docker Compose 未安装" "Docker Compose is not installed")" echo "" - info "请先安装 Docker Compose:" + info "$(bilingual "请先安装 Docker Compose:" "Please install Docker Compose:")" info " https://docs.docker.com/compose/install/" exit 1 fi - # 检查 Docker 守护进程 if ! docker info &> /dev/null; then - error "Docker 守护进程未运行" - info "请启动 Docker 服务:" - info " macOS: 打开 Docker Desktop" + error "$(bilingual "Docker 守护进程未运行" "Docker daemon is not running")" + info "$(bilingual "请启动 Docker 服务:" "Please start Docker:")" + info " $(bilingual "macOS: 打开 Docker Desktop" "macOS: Open Docker Desktop")" info " Linux: systemctl start docker" exit 1 fi - info "Docker 守护进程运行正常" + info "$(bilingual "Docker 守护进程运行正常" "Docker daemon is running")" echo "" } -# 交互式配置收集 +# 交互式配置收集 / Interactive configuration collect_configuration() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 2: 配置收集" + title " $(bilingual "步骤 2: 配置收集" "Step 2: Configuration")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" + info "$(bilingual "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" "💡 All options are optional, press Enter for default or auto-generated values")" echo "" - warn "密钥配置:回车将自动生成安全的随机密钥" - warn "其他配置:回车将使用括号中的默认值" + warn "$(bilingual "密钥配置:回车将自动生成安全的随机密钥" "Secrets: Enter to auto-generate secure random keys")" + warn "$(bilingual "其他配置:回车将使用括号中的默认值" "Other: Enter to use default value in brackets")" echo "" - # 基础配置 - title "【基础配置】" - echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}" - # 生成随机端口作为默认值 + title "$(bilingual "【基础配置】" "【Basic】")" + echo -e "${CYAN}$(bilingual "将配置:服务器端口、MySQL端口、时区" "Server port, MySQL port, Timezone")${NC}" DEFAULT_PORT=$(generate_random_port) - SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT") - MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307") - TZ=$(read_input "➤ 时区" "Asia/Shanghai") + SERVER_PORT=$(read_input "$(bilingual "➤ 服务器端口" "➤ Server port")" "$DEFAULT_PORT") + MYSQL_PORT=$(read_input "$(bilingual "➤ MySQL 端口(外部访问)" "➤ MySQL port (external)")" "3307") + TZ=$(read_input "$(bilingual "➤ 时区" "➤ Timezone")" "Asia/Shanghai") echo "" - # 数据库配置 - title "【数据库配置】" - echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}" - echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}" - DB_USERNAME=$(read_input "➤ 数据库用户名" "root") - DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret") + title "$(bilingual "【数据库配置】" "【Database】")" + echo -e "${CYAN}$(bilingual "将配置:数据库用户名、数据库密码" "Database username, password")${NC}" + echo -e "${YELLOW}$(bilingual "💡 提示:密码留空将自动生成 32 字符的安全随机密码" "💡 Leave password empty to auto-generate 32-char password")${NC}" + DB_USERNAME=$(read_input "$(bilingual "➤ 数据库用户名" "➤ Database username")" "root") + DB_PASSWORD=$(read_input "$(bilingual "➤ 数据库密码" "➤ Database password")" "" "secret") echo "" - # 安全配置 - title "【安全配置】" - echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}" - echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}" - JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret") - ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret") - CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret") + title "$(bilingual "【安全配置】" "【Security】")" + echo -e "${CYAN}$(bilingual "将配置:JWT密钥、管理员密码重置密钥、数据加密密钥" "JWT secret, Admin reset key, Encryption key")${NC}" + echo -e "${YELLOW}$(bilingual "💡 提示:留空将自动生成高强度随机密钥(推荐)" "💡 Leave empty to auto-generate strong keys (recommended)")${NC}" + JWT_SECRET=$(read_input "$(bilingual "➤ JWT 密钥" "➤ JWT secret")" "" "secret") + ADMIN_RESET_PASSWORD_KEY=$(read_input "$(bilingual "➤ 管理员密码重置密钥" "➤ Admin password reset key")" "" "secret") + CRYPTO_SECRET_KEY=$(read_input "$(bilingual "➤ 加密密钥(用于加密 API Key)" "➤ Encryption key (for API Key)")" "" "secret") echo "" - # 日志配置 - title "【日志配置】" - echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}" - echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}" - LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN") - LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO") + title "$(bilingual "【日志配置】" "【Logging】")" + echo -e "${CYAN}$(bilingual "将配置:Root日志级别、应用日志级别" "Root log level, App log level")${NC}" + echo -e "${YELLOW}$(bilingual "可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF" "Levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF")${NC}" + LOG_LEVEL_ROOT=$(read_input "$(bilingual "➤ Root 日志级别(第三方库)" "➤ Root log level (3rd party)")" "WARN") + LOG_LEVEL_APP=$(read_input "$(bilingual "➤ 应用日志级别" "➤ App log level")" "INFO") echo "" - # 自动设置不需要用户输入的配置 SPRING_PROFILES_ACTIVE="prod" ALLOW_PRERELEASE="false" GITHUB_REPO="WrBug/PolyHermes" } -# 下载 docker-compose.prod.yml(如果不存在) +# 下载 docker-compose.prod.yml(如果不存在)/ Download docker-compose.prod.yml if missing download_docker_compose_file() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 3: 获取部署配置" + title " $(bilingual "步骤 3: 获取部署配置" "Step 3: Get Deploy Config")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [ -f "docker-compose.prod.yml" ]; then - info "检测到现有 docker-compose.prod.yml,跳过下载" + info "$(bilingual "检测到现有 docker-compose.prod.yml,跳过下载" "Existing docker-compose.prod.yml found, skip download")" echo "" return 0 fi - info "正在从 GitHub 下载 docker-compose.prod.yml..." + info "$(bilingual "正在从 GitHub 下载 docker-compose.prod.yml..." "Downloading docker-compose.prod.yml from GitHub...")" - # GitHub raw 文件链接 local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml" - # 尝试下载 if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then - info "docker-compose.prod.yml 下载成功" + info "$(bilingual "docker-compose.prod.yml 下载成功" "docker-compose.prod.yml downloaded")" else - error "docker-compose.prod.yml 下载失败" - warn "请检查网络连接或手动下载:" + error "$(bilingual "docker-compose.prod.yml 下载失败" "Failed to download docker-compose.prod.yml")" + warn "$(bilingual "请检查网络连接或手动下载:" "Check network or download manually:")" warn " $compose_url" exit 1 fi @@ -235,28 +246,26 @@ download_docker_compose_file() { echo "" } -# 生成 .env 文件 +# 生成 .env 文件 / Generate .env file generate_env_file() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 4: 生成环境变量文件" + title " $(bilingual "步骤 4: 生成环境变量文件" "Step 4: Generate .env")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - # 备份现有 .env 文件 if [ -f ".env" ]; then BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)" cp .env "$BACKUP_FILE" - warn "已备份现有配置文件到: $BACKUP_FILE" + warn "$(bilingual "已备份现有配置文件到" "Backed up existing config to"): $BACKUP_FILE" fi - # 生成新的 .env 文件 cat > .env </dev/null | grep -q .; then - warn "检测到正在运行的服务,正在停止..." + warn "$(bilingual "检测到正在运行的服务,正在停止..." "Stopping existing services...")" docker compose -f docker-compose.prod.yml down - info "已停止现有服务" + info "$(bilingual "已停止现有服务" "Stopped existing services")" fi - # 启动服务 - info "正在启动服务..." + info "$(bilingual "正在启动服务..." "Starting services...")" if docker compose -f docker-compose.prod.yml up -d; then - info "服务启动成功" + info "$(bilingual "服务启动成功" "Services started")" else - error "服务启动失败" - error "请检查日志: docker compose -f docker-compose.prod.yml logs" + error "$(bilingual "服务启动失败" "Failed to start services")" + error "$(bilingual "请检查日志" "Check logs"): docker compose -f docker-compose.prod.yml logs" exit 1 fi echo "" } -# 健康检查 +# 健康检查 / Health check health_check() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 步骤 7: 健康检查" + title " $(bilingual "步骤 7: 健康检查" "Step 7: Health Check")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - info "等待服务启动(最多等待 60 秒)..." + info "$(bilingual "等待服务启动(最多等待 60 秒)..." "Waiting for services (up to 60s)...")" local max_attempts=12 local attempt=0 @@ -377,13 +381,11 @@ health_check() { while [ $attempt -lt $max_attempts ]; do attempt=$((attempt + 1)) - # 检查容器状态 if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then - info "容器运行正常" + info "$(bilingual "容器运行正常" "Containers are up")" - # 检查应用是否响应 if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then - info "应用响应正常" + info "$(bilingual "应用响应正常" "App is responding")" echo "" return 0 fi @@ -394,79 +396,76 @@ health_check() { done echo "" - warn "健康检查超时,请手动检查服务状态" - warn "查看日志: docker compose -f docker-compose.prod.yml logs -f" + warn "$(bilingual "健康检查超时,请手动检查服务状态" "Health check timeout, please check services manually")" + warn "$(bilingual "查看日志" "View logs"): docker compose -f docker-compose.prod.yml logs -f" echo "" } -# 显示部署信息 +# 显示部署信息 / Show deployment info show_deployment_info() { title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - title " 部署完成!" + title " $(bilingual "部署完成!" "Deployment Complete!")" title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}" + info "$(bilingual "访问地址" "Access URL"): ${GREEN}http://localhost:${SERVER_PORT}${NC}" echo "" - title "【常用命令】" - echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}" - echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" - echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}" - echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}" - echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}" + title "$(bilingual "【常用命令】" "【Common Commands】")" + echo -e " $(bilingual "查看服务状态" "Status"): ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}" + echo -e " $(bilingual "查看日志" "Logs"): ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" + echo -e " $(bilingual "停止服务" "Stop"): ${CYAN}docker compose -f docker-compose.prod.yml down${NC}" + echo -e " $(bilingual "重启服务" "Restart"): ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}" + echo -e " $(bilingual "更新镜像" "Update"): ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}" echo "" - title "【数据库连接信息】" - echo -e " 主机: ${CYAN}localhost${NC}" - echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}" - echo -e " 数据库: ${CYAN}polyhermes${NC}" - echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}" - echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}" + title "$(bilingual "【数据库连接信息】" "【Database Connection】")" + echo -e " $(bilingual "主机" "Host"): ${CYAN}localhost${NC}" + echo -e " $(bilingual "端口" "Port"): ${CYAN}${MYSQL_PORT}${NC}" + echo -e " $(bilingual "数据库" "Database"): ${CYAN}polyhermes${NC}" + echo -e " $(bilingual "用户名" "Username"): ${CYAN}${DB_USERNAME}${NC}" + echo -e " $(bilingual "密码" "Password"): ${CYAN}${DB_PASSWORD}${NC}" echo "" - title "【管理员重置密钥】" - echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}" - echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}" + title "$(bilingual "【管理员重置密钥】" "【Admin Reset Key】")" + echo -e " $(bilingual "重置密钥" "Reset key"): ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}" + echo -e " ${YELLOW}$(bilingual "💡 此密钥用于重置管理员密码,请妥善保管" "💡 Keep this key safe; it is used to reset admin password")${NC}" echo "" - warn "重要提示:" - warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统" - warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" - warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" + warn "$(bilingual "重要提示:" "Important:")" + warn " 1. $(bilingual "请妥善保管 .env 文件,勿提交到版本控制系统" "Keep .env secure; do not commit to version control")" + warn " 2. $(bilingual "定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)" "Back up DB regularly (Docker volume: polyhermes_mysql-data)")" + warn " 3. $(bilingual "生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" "Use a reverse proxy (e.g. Nginx) and HTTPS in production")" echo "" } -# 主函数 +# 主函数 / Main main() { clear echo "" title "=========================================" - title " PolyHermes 交互式一键部署脚本 " + title " $(bilingual "PolyHermes 交互式一键部署脚本" "PolyHermes Interactive Deploy") " title "=========================================" echo "" - # 执行部署流程 check_docker - # 检查是否已存在 .env 文件 if [ -f ".env" ]; then echo "" - title "【检测到现有配置】" - info "发现已存在的 .env 配置文件" + title "$(bilingual "【检测到现有配置】" "【Existing Config Found】")" + info "$(bilingual "发现已存在的 .env 配置文件" "Found existing .env file")" echo "" - echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}" + echo -ne "${YELLOW}$(bilingual "是否使用现有配置直接更新镜像?[Y/n]" "Use existing config to update images? [Y/n]"): ${NC}" read -r use_existing use_existing=${use_existing:-Y} if [[ "$use_existing" =~ ^[Yy]$ ]]; then - info "将使用现有配置,跳过配置步骤" + info "$(bilingual "将使用现有配置,跳过配置步骤" "Using existing config, skipping configuration")" echo "" - # 从现有 .env 文件读取必要的变量 source .env 2>/dev/null || true else - warn "将重新配置,现有配置将被备份" + warn "$(bilingual "将重新配置,现有配置将被备份" "Will reconfigure; existing config will be backed up")" echo "" collect_configuration fi @@ -476,21 +475,18 @@ main() { download_docker_compose_file - # 只有在重新配置时才生成新的 .env 文件 if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then generate_env_file fi - # 确认部署 echo "" - title "【确认部署】" - echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}" + title "$(bilingual "【确认部署】" "【Confirm Deploy】")" + echo -ne "${YELLOW}$(bilingual "是否开始部署?[Y/n](回车默认为是)" "Start deployment? [Y/n] (Enter = Yes)"): ${NC}" read -r confirm - # 默认为 Y,只有明确输入 n/N 才取消 confirm=${confirm:-Y} if [[ "$confirm" =~ ^[Nn]$ ]]; then - warn "部署已取消" + warn "$(bilingual "部署已取消" "Deployment cancelled")" exit 0 fi @@ -500,11 +496,11 @@ main() { health_check show_deployment_info - info "部署流程已完成!" + info "$(bilingual "部署流程已完成!" "Deployment finished!")" } -# 捕获 Ctrl+C -trap 'echo ""; warn "部署已中断"; exit 1' INT +# 捕获 Ctrl+C / Handle Ctrl+C +trap 'echo ""; warn "$(bilingual "部署已中断" "Deployment interrupted")"; exit 1' INT -# 运行主函数 +# 运行主函数 / Run main main "$@" diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index bd68b3c..0d007d6 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1644,6 +1644,39 @@ "switchToLatest": "Switch to Latest Period", "periodEnded": "Current period has ended", "newPeriodAvailable": "New period has started" + }, + "manualOrder": { + "title": "Manual Order", + "buttonUp": "Buy Up", + "buttonDown": "Buy Down", + "confirmTitle": "Manual Order Confirmation", + "marketTitle": "Market Title", + "direction": "Direction", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "Order Price", + "orderSize": "Order Size", + "totalAmount": "Total Amount", + "account": "Account", + "cancel": "Cancel", + "confirm": "Confirm Order", + "orderUnit": "USDC", + "sizeUnit": "shares", + "statusNotOrdered": "Not Ordered", + "statusOrdered": "This period has been ordered", + "statusOrderedAuto": "This period has been auto-ordered", + "statusOrderedManual": "This period has been manually ordered", + "errorInsufficientBalance": "Insufficient account balance, available: {balance} USDC", + "errorPriceOutOfRange": "Price must be between 0 and 1", + "errorMinSize": "Size cannot be less than 1 share", + "errorExceedsBalance": "Total amount exceeds available balance", + "errorPriceRequired": "Please enter order price", + "errorSizeRequired": "Please enter order size", + "success": "Manual order successful", + "failed": "Manual order failed: {{reason}}", + "errorSigning": "Signing failed: {reason}", + "errorNetwork": "Network error, please try again later", + "priceNotLoaded": "Price data not loaded" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 8f442c8..fd764cf 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1644,6 +1644,39 @@ "switchToLatest": "切换到最新周期", "periodEnded": "当前周期已结束", "newPeriodAvailable": "新周期已开始" + }, + "manualOrder": { + "title": "手动下单", + "buttonUp": "买入 Up", + "buttonDown": "买入 Down", + "confirmTitle": "手动下单确认", + "marketTitle": "市场标题", + "direction": "方向", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "下单价格", + "orderSize": "下单数量", + "totalAmount": "总金额", + "account": "账户", + "cancel": "取消", + "confirm": "确认下单", + "orderUnit": "USDC", + "sizeUnit": "张", + "statusNotOrdered": "未下单", + "statusOrdered": "本周期已下单", + "statusOrderedAuto": "本周期已自动下单", + "statusOrderedManual": "本周期已手动下单", + "errorInsufficientBalance": "账户余额不足,可用余额:{{balance}} USDC", + "errorPriceOutOfRange": "价格必须在 0~1 之间", + "errorMinSize": "数量不能少于 1 张", + "errorExceedsBalance": "总金额超过可用余额", + "errorPriceRequired": "请输入下单价格", + "errorSizeRequired": "请输入下单数量", + "success": "手动下单成功", + "failed": "手动下单失败:{{reason}}", + "errorSigning": "签名失败:{{reason}}", + "errorNetwork": "网络错误,请稍后重试", + "priceNotLoaded": "价格数据未加载" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index a0d8fd2..1d3650b 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1644,6 +1644,39 @@ "switchToLatest": "切換到最新週期", "periodEnded": "當前週期已結束", "newPeriodAvailable": "新週期已開始" + }, + "manualOrder": { + "title": "手動下單", + "buttonUp": "買入 Up", + "buttonDown": "買入 Down", + "confirmTitle": "手動下單確認", + "marketTitle": "市場標題", + "direction": "方向", + "directionUp": "Up", + "directionDown": "Down", + "orderPrice": "下單價格", + "orderSize": "下單數量", + "totalAmount": "總金額", + "account": "賬戶", + "cancel": "取消", + "confirm": "確認下單", + "orderUnit": "USDC", + "sizeUnit": "張", + "statusNotOrdered": "未下單", + "statusOrdered": "本週期已下單", + "statusOrderedAuto": "本週期已自動下單", + "statusOrderedManual": "本週期已手動下單", + "errorInsufficientBalance": "賬戶餘額不足,可用餘額:{balance} USDC", + "errorPriceOutOfRange": "價格必須在 0~1 之間", + "errorMinSize": "數量不能少於 1 張", + "errorExceedsBalance": "總金額超過可用餘額", + "errorPriceRequired": "請輸入下單價格", + "errorSizeRequired": "請輸入下單數量", + "success": "手動下單成功", + "failed": "手動下單失敗:{{reason}}", + "errorSigning": "簽名失敗:{reason}", + "errorNetwork": "網絡錯誤,請稍後重試", + "priceNotLoaded": "價格數據未加載" } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index ee47bda..c703fc1 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -12,9 +12,12 @@ import { Alert, Radio, Button, - Tooltip + Tooltip, + Modal, + InputNumber, + message } from 'antd' -import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined } from '@ant-design/icons' +import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined, ShoppingCartOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import * as echarts from 'echarts' @@ -84,6 +87,24 @@ const CryptoTailMonitor: React.FC = () => { // 标记当前是否在查看旧周期(手动模式下) const [isViewingOldPeriod, setIsViewingOldPeriod] = useState(false) + // 手动下单状态 + const [manualOrderModal, setManualOrderModal] = useState<{ + visible: boolean + direction: 'UP' | 'DOWN' + price: string + size: string + totalAmount: string + bestBid: string + }>({ + visible: false, + direction: 'UP', + price: '', + size: '', + totalAmount: '', + bestBid: '' + }) + const [ordering, setOrdering] = useState(false) + // 获取策略列表 useEffect(() => { const fetchStrategies = async () => { @@ -709,6 +730,94 @@ const CryptoTailMonitor: React.FC = () => { const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 && parseFloat(currentSpread) < Math.min(...minSpreadLineNum) + // 手动下单:打开弹窗 + const handleOpenManualOrderModal = (direction: 'UP' | 'DOWN') => { + if (!pushData) { + message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) + return + } + const bestBid = direction === 'UP' ? pushData.currentPriceUp : pushData.currentPriceDown + if (!bestBid) { + message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) + return + } + // 计算默认价格:最优 bid × 1.1,限制在 0~1 之间 + const rawPrice = parseFloat(bestBid) * 1.1 + const defaultPrice = Math.min(1, Math.max(0, rawPrice)) + const defaultAmountUsdc = 10 + const defaultSize = Math.ceil(defaultAmountUsdc / defaultPrice) + setManualOrderModal({ + visible: true, + direction, + price: defaultPrice.toFixed(4), + size: defaultSize.toFixed(2), + totalAmount: (defaultPrice * defaultSize).toFixed(2), + bestBid + }) + } + + const handleCloseManualOrderModal = () => { + setManualOrderModal({ + visible: false, + direction: 'UP', + price: '', + size: '', + totalAmount: '', + bestBid: '' + }) + } + + const handlePriceChange = (value: number | null) => { + if (value === null) return + const clamped = Math.min(1, Math.max(0, value)) + const price = clamped.toFixed(4) + const size = parseFloat(manualOrderModal.size) + const totalAmount = (clamped * size).toFixed(2) + setManualOrderModal({ ...manualOrderModal, price, totalAmount }) + } + + const handleSizeChange = (value: number | null) => { + if (value === null) return + const size = value.toFixed(2) + const priceRaw = parseFloat(manualOrderModal.price) + const price = Math.min(1, Math.max(0, priceRaw)) + const totalAmount = (price * value).toFixed(2) + setManualOrderModal({ ...manualOrderModal, size, totalAmount, price: price.toFixed(4) }) + } + + const handleManualOrder = async () => { + if (!initData || !pushData) return + try { + setOrdering(true) + const tokenIds: string[] = [] + if (initData.tokenIdUp) tokenIds.push(initData.tokenIdUp) + if (initData.tokenIdDown) tokenIds.push(initData.tokenIdDown) + const request = { + strategyId: initData.strategyId, + periodStartUnix: pushData.periodStartUnix, + direction: manualOrderModal.direction, + price: Math.min(1, Math.max(0, parseFloat(manualOrderModal.price) || 0)).toFixed(4), + size: manualOrderModal.size, + marketTitle: pushData.marketTitle || initData.marketTitle, + tokenIds + } + const res = await apiService.cryptoTailStrategy.manualOrder(request) + if (res.data.code === 0 && res.data.data?.success) { + message.success(t('cryptoTailMonitor.manualOrder.success')) + handleCloseManualOrderModal() + } else { + const reason = res.data.msg?.trim() || 'unknown' + message.error(t('cryptoTailMonitor.manualOrder.failed', { reason })) + } + } catch (error: unknown) { + const err = error as { response?: { data?: { msg?: string } }; message?: string } + const reason = err?.response?.data?.msg?.trim() ?? err?.message?.trim() ?? 'unknown' + message.error(t('cryptoTailMonitor.manualOrder.failed', { reason })) + } finally { + setOrdering(false) + } + } + return (
@@ -914,29 +1023,61 @@ const CryptoTailMonitor: React.FC = () => { /> </Card> + {/* 手动下单 */} + <Card title={t('cryptoTailMonitor.manualOrder.title')} style={{ marginTop: 16 }}> + <Row gutter={16}> + <Col span={12}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('UP')} + loading={ordering} + block + style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }} + > + {t('cryptoTailMonitor.manualOrder.buttonUp')} + </Button> + </Col> + <Col span={12}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('DOWN')} + loading={ordering} + block + style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }} + > + {t('cryptoTailMonitor.manualOrder.buttonDown')} + </Button> + </Col> + </Row> + </Card> + {/* 策略信息 */} <Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}> - <Row gutter={[16, 8]}> - <Col span={12}> + <Row gutter={[16, isMobile ? 12 : 8]}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.market')}: </Text> <Text>{pushData?.marketTitle ?? initData.marketTitle}</Text> </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.interval')}: </Text> <Text>{initData.intervalSeconds === 300 ? '5m' : '15m'}</Text> </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.account')}: </Text> <Text>{initData.accountName || `#${initData.accountId}`}</Text> </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadMode')}: </Text> <Text>{initData.minSpreadMode}</Text> {initData.minSpreadMode === 'FIXED' && initData.minSpreadValue && ( <Text> ({formatNumber(initData.minSpreadValue, 4)})</Text> )} </Col> - <Col span={12}> + <Col xs={24} sm={24} md={12}> <Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadDirection')}: </Text> <Text>{(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}</Text> </Col> @@ -944,6 +1085,103 @@ const CryptoTailMonitor: React.FC = () => { </Card> </> )} + + {/* 手动下单确认弹窗 */} + <Modal + title={t('cryptoTailMonitor.manualOrder.confirmTitle')} + open={manualOrderModal.visible} + onCancel={handleCloseManualOrderModal} + footer={[ + <Button key="cancel" onClick={handleCloseManualOrderModal}> + {t('cryptoTailMonitor.manualOrder.cancel')} + </Button>, + <Button + key="confirm" + type="primary" + onClick={handleManualOrder} + loading={ordering} + style={ + manualOrderModal.direction === 'UP' + ? { backgroundColor: '#1890ff', borderColor: '#1890ff' } + : { backgroundColor: '#fa8c16', borderColor: '#fa8c16' } + } + > + {t('cryptoTailMonitor.manualOrder.confirm')} + </Button> + ]} + width={isMobile ? '100%' : 480} + style={isMobile ? { maxWidth: '100%', top: 0, paddingBottom: 0 } : undefined} + > + {initData && ( + <Space direction="vertical" style={{ width: '100%' }} size={16}> + <Row gutter={[12, 8]}> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.marketTitle')} + </Text> + <Text>{pushData?.marketTitle ?? initData.marketTitle}</Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.direction')} + </Text> + <Text + strong + style={{ + color: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16' + }} + > + {manualOrderModal.direction === 'UP' + ? t('cryptoTailMonitor.manualOrder.directionUp') + : t('cryptoTailMonitor.manualOrder.directionDown')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.orderPrice')} ({t('cryptoTailMonitor.manualOrder.orderUnit')}) + </Text> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined} + onChange={handlePriceChange} + min={0} + max={1} + step={0.0001} + precision={4} + placeholder="0.0000" + /> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.orderSize')} ({t('cryptoTailMonitor.manualOrder.sizeUnit')}) + </Text> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined} + onChange={handleSizeChange} + min={1} + precision={2} + placeholder="1" + /> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.totalAmount')} + </Text> + <Text strong style={{ fontSize: 16 }}> + {manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.account')} + </Text> + <Text>{initData.accountName || `#${initData.accountId}`}</Text> + </Col> + </Row> + </Space> + )} + </Modal> </div> ) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4c8c71a..ac67fa0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -499,9 +499,19 @@ export const apiService = { autoMinSpread: (data: { intervalSeconds: number }) => apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data), monitorInit: (strategyId: number) => - apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId }) + apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId }), + manualOrder: (data: { + strategyId: number + periodStartUnix: number + direction: 'UP' | 'DOWN' + price: string + size: string + marketTitle: string + tokenIds: string[] + }) => + apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data) }, - + /** * 订单管理 API */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7e1189b..edbd531 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1156,6 +1156,10 @@ export interface CryptoTailMonitorInitResponse { currentTimestamp: number /** 是否启用 */ enabled: boolean + /** 投入金额模式: FIXED or RATIO */ + amountMode?: string + /** 投入金额数值 */ + amountValue?: string } /** @@ -1203,3 +1207,27 @@ export interface CryptoTailMonitorPushData { /** 周期是否已结束 */ periodEnded: boolean } + +export interface CryptoTailManualOrderResponse { + /** 是否成功 */ + success: boolean + /** 订单ID */ + orderId?: string + /** 提示消息 */ + message: string + /** 下单详情 */ + orderDetails?: ManualOrderDetails +} + +export interface ManualOrderDetails { + /** 策略ID */ + strategyId: number + /** 方向 */ + direction: string + /** 下单价格 */ + price: string + /** 下单数量 */ + size: string + /** 总金额 */ + totalAmount: string +} From cb1f43871a7ebd455f4e3c11b2c4c5ae536f10a8 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Thu, 26 Feb 2026 20:43:27 +0800 Subject: [PATCH 03/26] =?UTF-8?q?fix(crypto-tail):=20=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=97=B6=E6=8C=89=E5=91=A8=E6=9C=9F=E6=8B=89?= =?UTF-8?q?=E5=8F=96=20tokenIds=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E4=B8=8B=E5=8D=95=20orderbook=20=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 initMonitor 支持可选 periodStartUnix,按指定周期取市场与 tokenIds - 前端周期切换时调用 monitorInit 传入 pushPeriod,确保 tokenIds 与当前周期一致 - 修复切换周期无变化及手动下单报 orderbook does not exist Made-with: Cursor --- .../polymarketbot/dto/CryptoTailMonitorDto.kt | 4 +- .../cryptotail/CryptoTailMonitorService.kt | 3 +- frontend/src/pages/CryptoTailMonitor.tsx | 55 ++++++++++++++----- frontend/src/services/api.ts | 4 +- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt index 3761a2e..b279d1d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt @@ -5,7 +5,9 @@ package com.wrbug.polymarketbot.dto */ data class CryptoTailMonitorInitRequest( /** 策略ID */ - val strategyId: Long = 0L + val strategyId: Long = 0L, + /** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */ + val periodStartUnix: Long? = null ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt index d713be1..e534f76 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt @@ -147,7 +147,8 @@ class CryptoTailMonitorService( val account = accountRepository.findById(strategy.accountId).orElse(null) val nowSeconds = System.currentTimeMillis() / 1000 - val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds + val periodStartUnix = request.periodStartUnix + ?: ((nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds) // 获取市场信息 val slug = "${strategy.marketSlugPrefix}-$periodStartUnix" diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index c703fc1..57ce647 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -64,6 +64,10 @@ const CryptoTailMonitor: React.FC = () => { const marketChartRef = useRef<HTMLDivElement>(null) const marketChartInstance = useRef<echarts.ECharts | null>(null) const lastPeriodStartRef = useRef<number | null>(null) + const selectedStrategyIdRef = useRef<number | null>(null) + useEffect(() => { + selectedStrategyIdRef.current = selectedStrategyId + }, [selectedStrategyId]) // localStorage key for period switch mode const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode' @@ -148,7 +152,7 @@ const CryptoTailMonitor: React.FC = () => { setPendingPeriodData(null) setIsViewingOldPeriod(false) try { - const res = await apiService.cryptoTailStrategy.monitorInit(selectedStrategyId) + const res = await apiService.cryptoTailStrategy.monitorInit({ strategyId: selectedStrategyId }) if (res.data.code === 0 && res.data.data) { setInitData(res.data.data) } else { @@ -193,11 +197,23 @@ const CryptoTailMonitor: React.FC = () => { const lastPeriod = lastPeriodStartRef.current if (pushPeriod != null && pushPeriod !== lastPeriod) { - // 新周期到来 + // 新周期到来:重新拉取 init(含 tokenIds),再更新状态 lastPeriodStartRef.current = pushPeriod - + const marketTitle = (data as { marketTitle?: string }).marketTitle + const applyFreshInit = (fresh: CryptoTailMonitorInitResponse) => { + if (selectedStrategyIdRef.current !== fresh.strategyId) return + const merged: CryptoTailMonitorInitResponse = { + ...fresh, + periodStartUnix: pushPeriod, + marketTitle: marketTitle ?? fresh.marketTitle ?? '' + } + if (periodSwitchMode === 'manual' && lastPeriod != null) { + setPendingPeriodData(p => p ? { ...p, initData: merged } : null) + } else { + setInitData(merged) + } + } if (periodSwitchMode === 'manual' && lastPeriod != null) { - // 手动模式:保存新周期数据到 pending,保留当前显示 setPendingPeriodData({ periodStartUnix: pushPeriod, priceHistory: [newPoint], @@ -205,26 +221,35 @@ const CryptoTailMonitor: React.FC = () => { pushData: data }) setIsViewingOldPeriod(true) - // 更新 pending 数据的 initData - setInitData(prev => { - if (prev) { - setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } } : null) + apiService.cryptoTailStrategy.monitorInit({ strategyId: selectedStrategyId!, periodStartUnix: pushPeriod }).then(res => { + if (res.data?.code === 0 && res.data?.data) applyFreshInit(res.data.data) + else { + setInitData(prev => { + if (prev) setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } } : null) + return prev ?? null + }) } - return prev + }).catch(() => { + setInitData(prev => { + if (prev) setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } } : null) + return prev ?? null + }) }) } else { - // 自动模式或首次推送:直接切换 - if (lastPeriod != null) { - setHasSwitchedPeriod(true) - } + if (lastPeriod != null) setHasSwitchedPeriod(true) setFirstDataTime(newPoint.time) - setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } : null) setPriceHistory([newPoint]) setPushData(data) setIsViewingOldPeriod(false) setPendingPeriodData(null) - return + apiService.cryptoTailStrategy.monitorInit({ strategyId: selectedStrategyId!, periodStartUnix: pushPeriod }).then(res => { + if (res.data?.code === 0 && res.data?.data) applyFreshInit(res.data.data) + else setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } : null) + }).catch(() => { + setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: marketTitle ?? prev.marketTitle ?? '' } : null) + }) } + return } else { // 同周期:追加数据 if (periodSwitchMode === 'manual' && isViewingOldPeriod && pendingPeriodData) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ac67fa0..3087445 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -498,8 +498,8 @@ export const apiService = { apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}), autoMinSpread: (data: { intervalSeconds: number }) => apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data), - monitorInit: (strategyId: number) => - apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId }), + monitorInit: (data: { strategyId: number; periodStartUnix?: number }) => + apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', data), manualOrder: (data: { strategyId: number periodStartUnix: number From 9b4d8fc0012303440a02397fa34360b7f057c9a4 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Thu, 26 Feb 2026 20:50:46 +0800 Subject: [PATCH 04/26] =?UTF-8?q?feat(frontend):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E4=BB=B7=E5=B7=AE=E7=AD=96=E7=95=A5=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E9=A1=B5=E9=9D=A2=E7=A7=BB=E5=8A=A8=E7=AB=AF=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移动端策略选择器:标题与下拉框垂直布局,支持换行显示 - 下拉框选项支持文本换行,避免长策略名称被截断 - 移动端手动下单按钮改为底部悬浮固定显示 - 两个按钮均分屏幕宽度,符合移动端触摸操作规范 Made-with: Cursor --- frontend/src/pages/CryptoTailMonitor.tsx | 115 ++++++++++++++++------- 1 file changed, 81 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index 57ce647..797fc18 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -851,18 +851,22 @@ const CryptoTailMonitor: React.FC = () => { {/* 顶部控制区 */} <Card style={{ marginBottom: 16 }}> - <Space wrap size="middle"> - <Space> + <Space direction={isMobile ? 'vertical' : 'horizontal'} size="middle" style={{ width: '100%' }}> + <Space direction={isMobile ? 'vertical' : 'horizontal'} size="small" style={{ width: isMobile ? '100%' : 'auto' }}> <Text strong>{t('cryptoTailMonitor.selectStrategy')}</Text> <Select - style={{ minWidth: isMobile ? 200 : 300 }} + style={{ minWidth: isMobile ? '100%' : 300, width: isMobile ? '100%' : 'auto' }} loading={strategiesLoading} value={selectedStrategyId} onChange={(id) => setSelectedStrategyId(id)} placeholder={t('cryptoTailMonitor.selectStrategyPlaceholder')} + popupMatchSelectWidth={false} + dropdownStyle={{ minWidth: isMobile ? 280 : 'auto', wordWrap: 'break-word', whiteSpace: 'normal' }} + optionLabelProp="label" options={strategies.map(s => ({ label: `${s.name || s.marketSlugPrefix} (${s.intervalSeconds === 300 ? '5m' : '15m'})`, - value: s.id + value: s.id, + style: { whiteSpace: 'normal', wordWrap: 'break-word' } }))} /> </Space> @@ -1049,36 +1053,38 @@ const CryptoTailMonitor: React.FC = () => { </Card> {/* 手动下单 */} - <Card title={t('cryptoTailMonitor.manualOrder.title')} style={{ marginTop: 16 }}> - <Row gutter={16}> - <Col span={12}> - <Button - type="primary" - icon={<ShoppingCartOutlined />} - disabled={!pushData || pushData.triggered || pushData.periodEnded} - onClick={() => handleOpenManualOrderModal('UP')} - loading={ordering} - block - style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }} - > - {t('cryptoTailMonitor.manualOrder.buttonUp')} - </Button> - </Col> - <Col span={12}> - <Button - type="primary" - icon={<ShoppingCartOutlined />} - disabled={!pushData || pushData.triggered || pushData.periodEnded} - onClick={() => handleOpenManualOrderModal('DOWN')} - loading={ordering} - block - style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }} - > - {t('cryptoTailMonitor.manualOrder.buttonDown')} - </Button> - </Col> - </Row> - </Card> + {!isMobile ? ( + <Card title={t('cryptoTailMonitor.manualOrder.title')} style={{ marginTop: 16 }}> + <Row gutter={16}> + <Col span={12}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('UP')} + loading={ordering} + block + style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }} + > + {t('cryptoTailMonitor.manualOrder.buttonUp')} + </Button> + </Col> + <Col span={12}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('DOWN')} + loading={ordering} + block + style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }} + > + {t('cryptoTailMonitor.manualOrder.buttonDown')} + </Button> + </Col> + </Row> + </Card> + ) : null} {/* 策略信息 */} <Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}> @@ -1207,6 +1213,47 @@ const CryptoTailMonitor: React.FC = () => { </Space> )} </Modal> + + {/* 移动端底部悬浮按钮 */} + {isMobile && ( + <div + style={{ + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: 1000, + padding: '12px 16px', + background: 'rgba(255, 255, 255, 0.95)', + backdropFilter: 'blur(10px)', + borderTop: '1px solid #f0f0f0', + boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.06)' + }} + > + <div style={{ display: 'flex', width: '100%', gap: 0 }}> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('UP')} + loading={ordering} + style={{ flex: 1, backgroundColor: '#1890ff', borderColor: '#1890ff', height: 44, borderRadius: '6px 0 0 6px' }} + > + {t('cryptoTailMonitor.manualOrder.buttonUp')} + </Button> + <Button + type="primary" + icon={<ShoppingCartOutlined />} + disabled={!pushData || pushData.triggered || pushData.periodEnded} + onClick={() => handleOpenManualOrderModal('DOWN')} + loading={ordering} + style={{ flex: 1, backgroundColor: '#fa8c16', borderColor: '#fa8c16', height: 44, borderRadius: '0 6px 6px 0' }} + > + {t('cryptoTailMonitor.manualOrder.buttonDown')} + </Button> + </div> + </div> + )} </div> ) } From 79b154515d7f117c6f35690482e9472bc9228bb1 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Thu, 26 Feb 2026 21:19:06 +0800 Subject: [PATCH 05/26] =?UTF-8?q?feat(frontend):=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA=E4=B8=8E?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端返回 amountMode 和 amountValue 字段,支持前端获取策略配置 - 手动下单确认弹窗显示用户可用余额(绿色高亮) - 价格输入框右侧添加"获取最新价"按钮,支持实时更新价格 - 数量输入框右侧添加"最大"按钮,根据余额自动计算最大购买数量 - 修正数量计算逻辑:FIXED 和 RATIO 模式均按实际金额计算数量,保留2位小数 - FIXED 模式:使用配置的固定金额 - RATIO 模式:按比例计算可用余额 - 数量计算使用保留小数方式(而非向上取整),确保精确匹配投入金额 - 示例:固定1U,价格0.4,自动填充2.5张 - 示例:比例30%,余额20U,价格0.4,自动填充15张 Made-with: Cursor --- .../polymarketbot/dto/CryptoTailMonitorDto.kt | 6 +- .../cryptotail/CryptoTailMonitorService.kt | 4 +- frontend/src/locales/en/common.json | 11 +- frontend/src/locales/zh-CN/common.json | 11 +- frontend/src/locales/zh-TW/common.json | 11 +- frontend/src/pages/CryptoTailMonitor.tsx | 163 +++++++++++++++--- 6 files changed, 175 insertions(+), 31 deletions(-) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt index b279d1d..56ec5c5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt @@ -57,7 +57,11 @@ data class CryptoTailMonitorInitResponse( /** 当前时间 (毫秒时间戳) */ val currentTimestamp: Long = System.currentTimeMillis(), /** 是否启用 */ - val enabled: Boolean = true + val enabled: Boolean = true, + /** 投入金额模式: FIXED or RATIO */ + val amountMode: String? = null, + /** 投入金额数值 */ + val amountValue: String? = null ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt index e534f76..5efa49b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt @@ -208,7 +208,9 @@ class CryptoTailMonitorService( tokenIdUp = tokenIds.getOrNull(0), tokenIdDown = tokenIds.getOrNull(1), currentTimestamp = System.currentTimeMillis(), - enabled = strategy.enabled + enabled = strategy.enabled, + amountMode = strategy.amountMode, + amountValue = strategy.amountValue.toPlainString() ) Result.success(response) diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 0d007d6..bddd445 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1676,7 +1676,16 @@ "failed": "Manual order failed: {{reason}}", "errorSigning": "Signing failed: {reason}", "errorNetwork": "Network error, please try again later", - "priceNotLoaded": "Price data not loaded" + "priceNotLoaded": "Price data not loaded", + "insufficientBalance": "Insufficient balance, available amount less than 1 USDC", + "fetchBalanceFailed": "Failed to fetch account balance", + "availableBalance": "Available Balance", + "fetchLatestPrice": "Latest Price", + "priceUpdated": "Price updated", + "maxSize": "Max", + "invalidPriceOrBalance": "Invalid price or balance", + "insufficientBalanceForMax": "Insufficient balance for max size", + "maxSizeUpdated": "Updated to max size" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index fd764cf..ca63a4e 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1676,7 +1676,16 @@ "failed": "手动下单失败:{{reason}}", "errorSigning": "签名失败:{{reason}}", "errorNetwork": "网络错误,请稍后重试", - "priceNotLoaded": "价格数据未加载" + "priceNotLoaded": "价格数据未加载", + "insufficientBalance": "余额不足,可用金额少于 1 USDC", + "fetchBalanceFailed": "获取账户余额失败", + "availableBalance": "可用余额", + "fetchLatestPrice": "获取最新价", + "priceUpdated": "价格已更新", + "maxSize": "最大", + "invalidPriceOrBalance": "价格或余额无效", + "insufficientBalanceForMax": "余额不足以购买最大数量", + "maxSizeUpdated": "已更新为最大数量" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 1d3650b..aa5020a 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1676,7 +1676,16 @@ "failed": "手動下單失敗:{{reason}}", "errorSigning": "簽名失敗:{reason}", "errorNetwork": "網絡錯誤,請稍後重試", - "priceNotLoaded": "價格數據未加載" + "priceNotLoaded": "價格數據未加載", + "insufficientBalance": "餘額不足,可用金額少於 1 USDC", + "fetchBalanceFailed": "獲取賬戶餘額失敗", + "availableBalance": "可用餘額", + "fetchLatestPrice": "獲取最新價", + "priceUpdated": "價格已更新", + "maxSize": "最大", + "invalidPriceOrBalance": "價格或餘額無效", + "insufficientBalanceForMax": "餘額不足以購買最大數量", + "maxSizeUpdated": "已更新為最大數量" } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index 797fc18..9dd031f 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -99,13 +99,15 @@ const CryptoTailMonitor: React.FC = () => { size: string totalAmount: string bestBid: string + availableBalance: string }>({ visible: false, direction: 'UP', price: '', size: '', totalAmount: '', - bestBid: '' + bestBid: '', + availableBalance: '' }) const [ordering, setOrdering] = useState(false) @@ -756,7 +758,7 @@ const CryptoTailMonitor: React.FC = () => { parseFloat(currentSpread) < Math.min(...minSpreadLineNum) // 手动下单:打开弹窗 - const handleOpenManualOrderModal = (direction: 'UP' | 'DOWN') => { + const handleOpenManualOrderModal = async (direction: 'UP' | 'DOWN') => { if (!pushData) { message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) return @@ -769,18 +771,81 @@ const CryptoTailMonitor: React.FC = () => { // 计算默认价格:最优 bid × 1.1,限制在 0~1 之间 const rawPrice = parseFloat(bestBid) * 1.1 const defaultPrice = Math.min(1, Math.max(0, rawPrice)) - const defaultAmountUsdc = 10 - const defaultSize = Math.ceil(defaultAmountUsdc / defaultPrice) + + // 获取账户余额 + let availableBalance = '0' + if (initData?.accountId) { + try { + const balanceRes = await apiService.accounts.balance({ accountId: initData.accountId }) + if (balanceRes.data.code === 0 && balanceRes.data.data?.availableBalance) { + availableBalance = balanceRes.data.data.availableBalance + } + } catch (e) { + console.error('获取账户余额失败:', e) + } + } + + // 使用策略配置的金额 + let defaultAmountUsdc = 10 + if (initData?.amountMode === 'FIXED' && initData?.amountValue) { + defaultAmountUsdc = parseFloat(initData.amountValue) + } else if (initData?.amountMode === 'RATIO' && initData?.amountValue) { + // RATIO 模式:按比例计算 + const balanceNum = parseFloat(availableBalance) + const ratio = parseFloat(initData.amountValue || '10') + defaultAmountUsdc = Math.floor(balanceNum * ratio / 100) + // 至少保留 1 USDC + if (defaultAmountUsdc < 1) { + message.warning(t('cryptoTailMonitor.manualOrder.insufficientBalance')) + return + } + } + + // 计算默认数量(保留2位小数,用于手动下单) + let defaultSize = (defaultAmountUsdc / defaultPrice).toFixed(2) + // 确保至少 1 张 + if (parseFloat(defaultSize) < 1) { + defaultSize = '1.00' + } + + // 重新计算总金额(基于实际数量) + const defaultTotalAmount = (defaultPrice * parseFloat(defaultSize)).toFixed(2) + setManualOrderModal({ visible: true, direction, price: defaultPrice.toFixed(4), - size: defaultSize.toFixed(2), - totalAmount: (defaultPrice * defaultSize).toFixed(2), - bestBid + size: defaultSize, + totalAmount: defaultTotalAmount, + bestBid, + availableBalance }) } + // 获取最新价 + const handleFetchLatestPrice = async () => { + if (!pushData) { + message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) + return + } + const latestPrice = manualOrderModal.direction === 'UP' + ? pushData.currentPriceUp + : pushData.currentPriceDown + if (!latestPrice) { + message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) + return + } + const price = parseFloat(latestPrice) + const size = parseFloat(manualOrderModal.size) + const totalAmount = (price * size).toFixed(2) + setManualOrderModal({ + ...manualOrderModal, + price: price.toFixed(4), + totalAmount + }) + message.success(t('cryptoTailMonitor.manualOrder.priceUpdated')) + } + const handleCloseManualOrderModal = () => { setManualOrderModal({ visible: false, @@ -788,7 +853,8 @@ const CryptoTailMonitor: React.FC = () => { price: '', size: '', totalAmount: '', - bestBid: '' + bestBid: '', + availableBalance: '' }) } @@ -810,6 +876,33 @@ const CryptoTailMonitor: React.FC = () => { setManualOrderModal({ ...manualOrderModal, size, totalAmount, price: price.toFixed(4) }) } + // 计算最大数量(截位处理) + const handleMaxSize = () => { + const price = parseFloat(manualOrderModal.price) + const balance = parseFloat(manualOrderModal.availableBalance) + + if (price <= 0 || balance <= 0) { + message.warning(t('cryptoTailMonitor.manualOrder.invalidPriceOrBalance')) + return + } + + // 最大数量 = Math.floor(可用余额 / 价格) + const maxSize = Math.floor(balance / price) + + if (maxSize < 1) { + message.warning(t('cryptoTailMonitor.manualOrder.insufficientBalanceForMax')) + return + } + + const totalAmount = (price * maxSize).toFixed(2) + setManualOrderModal({ + ...manualOrderModal, + size: maxSize.toFixed(2), + totalAmount + }) + message.success(t('cryptoTailMonitor.manualOrder.maxSizeUpdated')) + } + const handleManualOrder = async () => { if (!initData || !pushData) return try { @@ -1167,33 +1260,51 @@ const CryptoTailMonitor: React.FC = () => { : t('cryptoTailMonitor.manualOrder.directionDown')} </Text> </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.availableBalance')} + </Text> + <Text strong style={{ fontSize: 16, color: '#52c41a' }}> + {manualOrderModal.availableBalance ? formatNumber(manualOrderModal.availableBalance, 2) : '-'} {t('cryptoTailMonitor.manualOrder.orderUnit')} + </Text> + </Col> <Col span={24}> <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> {t('cryptoTailMonitor.manualOrder.orderPrice')} ({t('cryptoTailMonitor.manualOrder.orderUnit')}) </Text> - <InputNumber - style={{ width: '100%' }} - value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined} - onChange={handlePriceChange} - min={0} - max={1} - step={0.0001} - precision={4} - placeholder="0.0000" - /> + <Space.Compact style={{ width: '100%' }}> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined} + onChange={handlePriceChange} + min={0} + max={1} + step={0.0001} + precision={4} + placeholder="0.0000" + /> + <Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />}> + {t('cryptoTailMonitor.manualOrder.fetchLatestPrice')} + </Button> + </Space.Compact> </Col> <Col span={24}> <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> {t('cryptoTailMonitor.manualOrder.orderSize')} ({t('cryptoTailMonitor.manualOrder.sizeUnit')}) </Text> - <InputNumber - style={{ width: '100%' }} - value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined} - onChange={handleSizeChange} - min={1} - precision={2} - placeholder="1" - /> + <Space.Compact style={{ width: '100%' }}> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined} + onChange={handleSizeChange} + min={1} + precision={2} + placeholder="1" + /> + <Button onClick={handleMaxSize} type="primary" ghost> + {t('cryptoTailMonitor.manualOrder.maxSize')} + </Button> + </Space.Compact> </Col> <Col span={24}> <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> From 1967d97c31c56b54db1d346dbfca035440eabfc6 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Thu, 26 Feb 2026 21:26:12 +0800 Subject: [PATCH 06/26] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=20RAT?= =?UTF-8?q?IO=20=E6=A8=A1=E5=BC=8F=E4=B8=8B=E9=87=91=E9=A2=9D=E8=A2=AB?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=88=AA=E6=96=AD=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 RATIO 模式下对 defaultAmountUsdc 的 Math.floor 取整 - 保持精确的金额用于计算数量,确保符合用户预期 - 修复示例:60%比例,余额2.95U,价格0.682 - 修复前:数量 = 1.47(金额被截断为1U) - 修复后:数量 = 2.60(金额正确为1.77U) Made-with: Cursor --- frontend/src/pages/CryptoTailMonitor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index 9dd031f..bd9014f 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -793,7 +793,7 @@ const CryptoTailMonitor: React.FC = () => { // RATIO 模式:按比例计算 const balanceNum = parseFloat(availableBalance) const ratio = parseFloat(initData.amountValue || '10') - defaultAmountUsdc = Math.floor(balanceNum * ratio / 100) + defaultAmountUsdc = balanceNum * ratio / 100 // 至少保留 1 USDC if (defaultAmountUsdc < 1) { message.warning(t('cryptoTailMonitor.manualOrder.insufficientBalance')) From 1688fa96337cc5289d4315d316e13d9531263611 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Thu, 26 Feb 2026 21:30:18 +0800 Subject: [PATCH 07/26] =?UTF-8?q?feat(frontend):=20=E4=BC=98=E5=8C=96"?= =?UTF-8?q?=E6=9C=80=E5=A4=A7"=E6=8C=89=E9=92=AE=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BF=9D=E7=95=992=E4=BD=8D?= =?UTF-8?q?=E5=B0=8F=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改前:只能整数数量,导致余额浪费 - 示例:余额2.95U,价格0.86 → 数量3张(浪费0.37U) - 修改后:保留2位小数,充分利用余额 - 示例:余额2.95U,价格0.86 → 数量3.43张(利用全部余额) Made-with: Cursor --- frontend/src/pages/CryptoTailMonitor.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index bd9014f..091310f 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -886,9 +886,10 @@ const CryptoTailMonitor: React.FC = () => { return } - // 最大数量 = Math.floor(可用余额 / 价格) - const maxSize = Math.floor(balance / price) + // 最大数量 = 余额 / 价格,保留2位小数 + let maxSize = Math.floor((balance / price) * 100) / 100 + // 确保至少 1 张 if (maxSize < 1) { message.warning(t('cryptoTailMonitor.manualOrder.insufficientBalanceForMax')) return From e5acad50913f3827bd95ab321227136027956b75 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Thu, 26 Feb 2026 21:36:59 +0800 Subject: [PATCH 08/26] =?UTF-8?q?fix(frontend):=20=E9=99=90=E5=88=B6?= =?UTF-8?q?=E4=B8=8B=E5=8D=95=E4=BB=B7=E6=A0=BC=E6=9C=80=E9=AB=98=E4=B8=BA?= =?UTF-8?q?=200.99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改所有价格限制从 1 改为 0.99 - 影响位置: - handleOpenManualOrderModal: 默认价格计算 - handlePriceChange: 手动修改价格 - handleFetchLatestPrice: 获取最新价 - handleSizeChange: 修改数量时重新计算价格 - handleManualOrder: 提交订单时 - 确保价格不会超过 Polymarket 的最高限制 0.99 Made-with: Cursor --- frontend/src/pages/CryptoTailMonitor.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index 091310f..80db6d2 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -768,9 +768,9 @@ const CryptoTailMonitor: React.FC = () => { message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) return } - // 计算默认价格:最优 bid × 1.1,限制在 0~1 之间 + // 计算默认价格:最优 bid × 1.1,限制在 0~0.99 之间 const rawPrice = parseFloat(bestBid) * 1.1 - const defaultPrice = Math.min(1, Math.max(0, rawPrice)) + const defaultPrice = Math.min(0.99, Math.max(0, rawPrice)) // 获取账户余额 let availableBalance = '0' @@ -835,7 +835,7 @@ const CryptoTailMonitor: React.FC = () => { message.warning(t('cryptoTailMonitor.manualOrder.priceNotLoaded')) return } - const price = parseFloat(latestPrice) + const price = Math.min(0.99, parseFloat(latestPrice)) const size = parseFloat(manualOrderModal.size) const totalAmount = (price * size).toFixed(2) setManualOrderModal({ @@ -860,7 +860,7 @@ const CryptoTailMonitor: React.FC = () => { const handlePriceChange = (value: number | null) => { if (value === null) return - const clamped = Math.min(1, Math.max(0, value)) + const clamped = Math.min(0.99, Math.max(0, value)) const price = clamped.toFixed(4) const size = parseFloat(manualOrderModal.size) const totalAmount = (clamped * size).toFixed(2) @@ -871,7 +871,7 @@ const CryptoTailMonitor: React.FC = () => { if (value === null) return const size = value.toFixed(2) const priceRaw = parseFloat(manualOrderModal.price) - const price = Math.min(1, Math.max(0, priceRaw)) + const price = Math.min(0.99, Math.max(0, priceRaw)) const totalAmount = (price * value).toFixed(2) setManualOrderModal({ ...manualOrderModal, size, totalAmount, price: price.toFixed(4) }) } @@ -915,7 +915,7 @@ const CryptoTailMonitor: React.FC = () => { strategyId: initData.strategyId, periodStartUnix: pushData.periodStartUnix, direction: manualOrderModal.direction, - price: Math.min(1, Math.max(0, parseFloat(manualOrderModal.price) || 0)).toFixed(4), + price: Math.min(0.99, Math.max(0, parseFloat(manualOrderModal.price) || 0)).toFixed(4), size: manualOrderModal.size, marketTitle: pushData.marketTitle || initData.marketTitle, tokenIds From 5483a1aa778d54d249599fdc7d8975d40f82e18a Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Fri, 27 Feb 2026 04:11:55 +0800 Subject: [PATCH 09/26] =?UTF-8?q?feat(frontend):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E4=BB=B7=E5=B7=AE=E7=9B=91=E6=8E=A7=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 刷新页面时保留当前选择的策略(localStorage 缓存) - 移动端下单弹窗改为 BottomSheet 样式,更紧凑 - 买入按钮实时显示当前价格,如 "买入 Up (0.50)" - 周期切换时自动关闭弹窗并提示用户 Made-with: Cursor --- frontend/src/locales/en/common.json | 3 +- frontend/src/locales/zh-CN/common.json | 3 +- frontend/src/locales/zh-TW/common.json | 3 +- frontend/src/pages/CryptoTailMonitor.tsx | 350 +++++++++++++++++------ 4 files changed, 266 insertions(+), 93 deletions(-) diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index bddd445..2ecb7c7 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1685,7 +1685,8 @@ "maxSize": "Max", "invalidPriceOrBalance": "Invalid price or balance", "insufficientBalanceForMax": "Insufficient balance for max size", - "maxSizeUpdated": "Updated to max size" + "maxSizeUpdated": "Updated to max size", + "periodChanged": "Period has changed, popup closed" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index ca63a4e..853c74c 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1685,7 +1685,8 @@ "maxSize": "最大", "invalidPriceOrBalance": "价格或余额无效", "insufficientBalanceForMax": "余额不足以购买最大数量", - "maxSizeUpdated": "已更新为最大数量" + "maxSizeUpdated": "已更新为最大数量", + "periodChanged": "周期已切换,弹窗已关闭" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index aa5020a..e4ea674 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1685,7 +1685,8 @@ "maxSize": "最大", "invalidPriceOrBalance": "價格或餘額無效", "insufficientBalanceForMax": "餘額不足以購買最大數量", - "maxSizeUpdated": "已更新為最大數量" + "maxSizeUpdated": "已更新為最大數量", + "periodChanged": "週期已切換,彈窗已關閉" } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index 80db6d2..4ddacfd 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -17,6 +17,7 @@ import { InputNumber, message } from 'antd' +import { Popup as AntdMobilePopup } from 'antd-mobile' import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined, ShoppingCartOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' @@ -33,6 +34,10 @@ import type { const { Title, Text } = Typography +// localStorage keys +const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode' +const SELECTED_STRATEGY_ID_KEY = 'cryptoTailMonitor_selectedStrategyId' + /** 分时图数据点:时间戳、BTC 价格 USDC、市场 Up/Down 价格 0-1 */ interface PriceDataPoint { time: number @@ -49,8 +54,11 @@ const CryptoTailMonitor: React.FC = () => { const [strategies, setStrategies] = useState<CryptoTailStrategyDto[]>([]) const [strategiesLoading, setStrategiesLoading] = useState(false) - // 选中的策略 - const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null) + // 选中的策略(从 localStorage 恢复) + const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(() => { + const cached = localStorage.getItem(SELECTED_STRATEGY_ID_KEY) + return cached != null ? parseInt(cached, 10) : null + }) // 监控数据 const [initData, setInitData] = useState<CryptoTailMonitorInitResponse | null>(null) @@ -69,9 +77,6 @@ const CryptoTailMonitor: React.FC = () => { selectedStrategyIdRef.current = selectedStrategyId }, [selectedStrategyId]) - // localStorage key for period switch mode - const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode' - // 记录首次数据进入时间(用于中途进入时的横轴起点) const [firstDataTime, setFirstDataTime] = useState<number | null>(null) // 标记是否已切换过周期(切换后使用完整周期) @@ -100,6 +105,7 @@ const CryptoTailMonitor: React.FC = () => { totalAmount: string bestBid: string availableBalance: string + periodStartUnix: number | null }>({ visible: false, direction: 'UP', @@ -107,10 +113,21 @@ const CryptoTailMonitor: React.FC = () => { size: '', totalAmount: '', bestBid: '', - availableBalance: '' + availableBalance: '', + periodStartUnix: null }) const [ordering, setOrdering] = useState(false) + // 检测周期切换,关闭弹窗并提示用户 + useEffect(() => { + if (manualOrderModal.visible && manualOrderModal.periodStartUnix != null && pushData) { + if (pushData.periodStartUnix !== manualOrderModal.periodStartUnix) { + message.warning(t('cryptoTailMonitor.manualOrder.periodChanged')) + handleCloseManualOrderModal() + } + } + }, [pushData?.periodStartUnix, manualOrderModal.visible, manualOrderModal.periodStartUnix]) + // 获取策略列表 useEffect(() => { const fetchStrategies = async () => { @@ -118,10 +135,18 @@ const CryptoTailMonitor: React.FC = () => { try { const res = await apiService.cryptoTailStrategy.list({ enabled: true }) if (res.data.code === 0 && res.data.data) { - setStrategies(res.data.data.list ?? []) - // 自动选择第一个策略 - if (res.data.data.list?.length > 0 && !selectedStrategyId) { - setSelectedStrategyId(res.data.data.list[0].id) + const strategyList = res.data.data.list ?? [] + setStrategies(strategyList) + // 从 localStorage 恢复选中的策略 + const cachedId = localStorage.getItem(SELECTED_STRATEGY_ID_KEY) + const cachedStrategyId = cachedId != null ? parseInt(cachedId, 10) : null + // 检查缓存的策略是否在列表中 + const isValidCached = cachedStrategyId != null && strategyList.some(s => s.id === cachedStrategyId) + if (isValidCached) { + setSelectedStrategyId(cachedStrategyId) + } else if (strategyList.length > 0) { + // 自动选择第一个策略 + setSelectedStrategyId(strategyList[0].id) } } } catch (e) { @@ -133,6 +158,13 @@ const CryptoTailMonitor: React.FC = () => { fetchStrategies() }, []) + // 保存选中的策略到 localStorage + useEffect(() => { + if (selectedStrategyId != null) { + localStorage.setItem(SELECTED_STRATEGY_ID_KEY, String(selectedStrategyId)) + } + }, [selectedStrategyId]) + // 初始化监控数据 useEffect(() => { if (!selectedStrategyId) { @@ -818,7 +850,8 @@ const CryptoTailMonitor: React.FC = () => { size: defaultSize, totalAmount: defaultTotalAmount, bestBid, - availableBalance + availableBalance, + periodStartUnix: pushData.periodStartUnix }) } @@ -854,7 +887,8 @@ const CryptoTailMonitor: React.FC = () => { size: '', totalAmount: '', bestBid: '', - availableBalance: '' + availableBalance: '', + periodStartUnix: null }) } @@ -1160,7 +1194,7 @@ const CryptoTailMonitor: React.FC = () => { block style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }} > - {t('cryptoTailMonitor.manualOrder.buttonUp')} + {t('cryptoTailMonitor.manualOrder.buttonUp')} {pushData?.currentPriceUp ? `(${formatNumber(pushData.currentPriceUp, 2)})` : ''} </Button> </Col> <Col span={12}> @@ -1173,7 +1207,7 @@ const CryptoTailMonitor: React.FC = () => { block style={{ backgroundColor: '#fa8c16', borderColor: '#fa8c16' }} > - {t('cryptoTailMonitor.manualOrder.buttonDown')} + {t('cryptoTailMonitor.manualOrder.buttonDown')} {pushData?.currentPriceDown ? `(${formatNumber(pushData.currentPriceDown, 2)})` : ''} </Button> </Col> </Row> @@ -1211,48 +1245,162 @@ const CryptoTailMonitor: React.FC = () => { </> )} - {/* 手动下单确认弹窗 */} - <Modal - title={t('cryptoTailMonitor.manualOrder.confirmTitle')} - open={manualOrderModal.visible} - onCancel={handleCloseManualOrderModal} - footer={[ - <Button key="cancel" onClick={handleCloseManualOrderModal}> - {t('cryptoTailMonitor.manualOrder.cancel')} - </Button>, - <Button - key="confirm" - type="primary" - onClick={handleManualOrder} - loading={ordering} - style={ - manualOrderModal.direction === 'UP' - ? { backgroundColor: '#1890ff', borderColor: '#1890ff' } - : { backgroundColor: '#fa8c16', borderColor: '#fa8c16' } - } - > - {t('cryptoTailMonitor.manualOrder.confirm')} - </Button> - ]} - width={isMobile ? '100%' : 480} - style={isMobile ? { maxWidth: '100%', top: 0, paddingBottom: 0 } : undefined} - > - {initData && ( - <Space direction="vertical" style={{ width: '100%' }} size={16}> - <Row gutter={[12, 8]}> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.marketTitle')} - </Text> - <Text>{pushData?.marketTitle ?? initData.marketTitle}</Text> - </Col> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.direction')} + {/* 手动下单确认弹窗 - 桌面端使用 Modal */} + {!isMobile && ( + <Modal + title={t('cryptoTailMonitor.manualOrder.confirmTitle')} + open={manualOrderModal.visible} + onCancel={handleCloseManualOrderModal} + footer={[ + <Button key="cancel" onClick={handleCloseManualOrderModal}> + {t('cryptoTailMonitor.manualOrder.cancel')} + </Button>, + <Button + key="confirm" + type="primary" + onClick={handleManualOrder} + loading={ordering} + style={ + manualOrderModal.direction === 'UP' + ? { backgroundColor: '#1890ff', borderColor: '#1890ff' } + : { backgroundColor: '#fa8c16', borderColor: '#fa8c16' } + } + > + {t('cryptoTailMonitor.manualOrder.confirm')} + </Button> + ]} + width={480} + > + {initData && ( + <Space direction="vertical" style={{ width: '100%' }} size={16}> + <Row gutter={[12, 8]}> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.marketTitle')} + </Text> + <Text>{pushData?.marketTitle ?? initData.marketTitle}</Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.direction')} + </Text> + <Text + strong + style={{ + color: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16' + }} + > + {manualOrderModal.direction === 'UP' + ? t('cryptoTailMonitor.manualOrder.directionUp') + : t('cryptoTailMonitor.manualOrder.directionDown')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.availableBalance')} + </Text> + <Text strong style={{ fontSize: 16, color: '#52c41a' }}> + {manualOrderModal.availableBalance ? formatNumber(manualOrderModal.availableBalance, 2) : '-'} {t('cryptoTailMonitor.manualOrder.orderUnit')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.orderPrice')} ({t('cryptoTailMonitor.manualOrder.orderUnit')}) + </Text> + <Space.Compact style={{ width: '100%' }}> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined} + onChange={handlePriceChange} + min={0} + max={1} + step={0.0001} + precision={4} + placeholder="0.0000" + /> + <Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />}> + {t('cryptoTailMonitor.manualOrder.fetchLatestPrice')} + </Button> + </Space.Compact> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.orderSize')} ({t('cryptoTailMonitor.manualOrder.sizeUnit')}) + </Text> + <Space.Compact style={{ width: '100%' }}> + <InputNumber + style={{ width: '100%' }} + value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined} + onChange={handleSizeChange} + min={1} + precision={2} + placeholder="1" + /> + <Button onClick={handleMaxSize} type="primary" ghost> + {t('cryptoTailMonitor.manualOrder.maxSize')} + </Button> + </Space.Compact> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.totalAmount')} + </Text> + <Text strong style={{ fontSize: 16 }}> + {manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')} + </Text> + </Col> + <Col span={24}> + <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> + {t('cryptoTailMonitor.manualOrder.account')} + </Text> + <Text>{initData.accountName || `#${initData.accountId}`}</Text> + </Col> + </Row> + </Space> + )} + </Modal> + )} + + {/* 移动端 BottomSheet 弹窗 */} + {isMobile && ( + <AntdMobilePopup + visible={manualOrderModal.visible} + onMaskClick={handleCloseManualOrderModal} + onClose={handleCloseManualOrderModal} + bodyStyle={{ + borderRadius: '16px 16px 0 0', + padding: '12px 16px', + maxHeight: '70vh', + overflow: 'auto' + }} + > + {initData && ( + <div> + {/* 标题栏 */} + <div style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12 + }}> + <Text strong style={{ fontSize: 16 }}> + {t('cryptoTailMonitor.manualOrder.confirmTitle')} </Text> + <Button type="text" onClick={handleCloseManualOrderModal} style={{ padding: 0, fontSize: 18 }}> + ✕ + </Button> + </div> + + {/* 市场信息 + 方向 + 余额(一行) */} + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, flexWrap: 'wrap', gap: 8 }}> + <div style={{ flex: '1 1 auto', minWidth: 0 }}> + <Text type="secondary" style={{ fontSize: 12 }}>{t('cryptoTailMonitor.manualOrder.marketTitle')}: </Text> + <Text style={{ fontSize: 13 }} ellipsis>{pushData?.marketTitle ?? initData.marketTitle}</Text> + </div> <Text strong style={{ + fontSize: 14, color: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16' }} > @@ -1260,22 +1408,26 @@ const CryptoTailMonitor: React.FC = () => { ? t('cryptoTailMonitor.manualOrder.directionUp') : t('cryptoTailMonitor.manualOrder.directionDown')} </Text> - </Col> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.availableBalance')} + </div> + + {/* 可用余额 */} + <div style={{ marginBottom: 10 }}> + <Text type="secondary" style={{ fontSize: 12 }}> + {t('cryptoTailMonitor.manualOrder.availableBalance')}: </Text> - <Text strong style={{ fontSize: 16, color: '#52c41a' }}> + <Text strong style={{ fontSize: 14, color: '#52c41a', marginLeft: 4 }}> {manualOrderModal.availableBalance ? formatNumber(manualOrderModal.availableBalance, 2) : '-'} {t('cryptoTailMonitor.manualOrder.orderUnit')} </Text> - </Col> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.orderPrice')} ({t('cryptoTailMonitor.manualOrder.orderUnit')}) + </div> + + {/* 价格输入 */} + <div style={{ marginBottom: 10 }}> + <Text type="secondary" style={{ display: 'block', marginBottom: 2, fontSize: 12 }}> + {t('cryptoTailMonitor.manualOrder.orderPrice')} </Text> <Space.Compact style={{ width: '100%' }}> <InputNumber - style={{ width: '100%' }} + style={{ width: '100%', height: 36 }} value={manualOrderModal.price ? parseFloat(manualOrderModal.price) : undefined} onChange={handlePriceChange} min={0} @@ -1284,47 +1436,65 @@ const CryptoTailMonitor: React.FC = () => { precision={4} placeholder="0.0000" /> - <Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />}> + <Button onClick={handleFetchLatestPrice} icon={<SyncOutlined />} style={{ height: 36, fontSize: 12 }}> {t('cryptoTailMonitor.manualOrder.fetchLatestPrice')} </Button> </Space.Compact> - </Col> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.orderSize')} ({t('cryptoTailMonitor.manualOrder.sizeUnit')}) + </div> + + {/* 数量输入 */} + <div style={{ marginBottom: 10 }}> + <Text type="secondary" style={{ display: 'block', marginBottom: 2, fontSize: 12 }}> + {t('cryptoTailMonitor.manualOrder.orderSize')} </Text> <Space.Compact style={{ width: '100%' }}> <InputNumber - style={{ width: '100%' }} + style={{ width: '100%', height: 36 }} value={manualOrderModal.size ? parseFloat(manualOrderModal.size) : undefined} onChange={handleSizeChange} min={1} precision={2} placeholder="1" /> - <Button onClick={handleMaxSize} type="primary" ghost> + <Button onClick={handleMaxSize} type="primary" ghost style={{ height: 36, fontSize: 12 }}> {t('cryptoTailMonitor.manualOrder.maxSize')} </Button> </Space.Compact> - </Col> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.totalAmount')} - </Text> - <Text strong style={{ fontSize: 16 }}> - {manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')} - </Text> - </Col> - <Col span={24}> - <Text type="secondary" style={{ display: 'block', marginBottom: 4 }}> - {t('cryptoTailMonitor.manualOrder.account')} - </Text> - <Text>{initData.accountName || `#${initData.accountId}`}</Text> - </Col> - </Row> - </Space> - )} - </Modal> + </div> + + {/* 总金额 + 账户(一行) */} + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> + <div> + <Text type="secondary" style={{ fontSize: 12 }}>{t('cryptoTailMonitor.manualOrder.totalAmount')}: </Text> + <Text strong style={{ fontSize: 16, marginLeft: 4 }}> + {manualOrderModal.totalAmount} {t('cryptoTailMonitor.manualOrder.orderUnit')} + </Text> + </div> + <div> + <Text type="secondary" style={{ fontSize: 12 }}>{t('cryptoTailMonitor.manualOrder.account')}: </Text> + <Text style={{ fontSize: 12 }}>{initData.accountName || `#${initData.accountId}`}</Text> + </div> + </div> + + {/* 确认按钮 */} + <Button + type="primary" + block + onClick={handleManualOrder} + loading={ordering} + style={{ + height: 44, + borderRadius: 8, + backgroundColor: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16', + borderColor: manualOrderModal.direction === 'UP' ? '#1890ff' : '#fa8c16' + }} + > + {t('cryptoTailMonitor.manualOrder.confirm')} + </Button> + </div> + )} + </AntdMobilePopup> + )} {/* 移动端底部悬浮按钮 */} {isMobile && ( @@ -1351,7 +1521,7 @@ const CryptoTailMonitor: React.FC = () => { loading={ordering} style={{ flex: 1, backgroundColor: '#1890ff', borderColor: '#1890ff', height: 44, borderRadius: '6px 0 0 6px' }} > - {t('cryptoTailMonitor.manualOrder.buttonUp')} + {t('cryptoTailMonitor.manualOrder.buttonUp')} {pushData?.currentPriceUp ? `(${formatNumber(pushData.currentPriceUp, 2)})` : ''} </Button> <Button type="primary" @@ -1361,7 +1531,7 @@ const CryptoTailMonitor: React.FC = () => { loading={ordering} style={{ flex: 1, backgroundColor: '#fa8c16', borderColor: '#fa8c16', height: 44, borderRadius: '0 6px 6px 0' }} > - {t('cryptoTailMonitor.manualOrder.buttonDown')} + {t('cryptoTailMonitor.manualOrder.buttonDown')} {pushData?.currentPriceDown ? `(${formatNumber(pushData.currentPriceDown, 2)})` : ''} </Button> </div> </div> From 3cc37dac55c23f315f26a6b4b49b84cf54c7e535 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Sun, 1 Mar 2026 05:41:04 +0800 Subject: [PATCH 10/26] =?UTF-8?q?feat(backend):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=8E=A8=E9=80=81=EF=BC=8C=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E8=BE=93=E8=B5=A2=E5=B9=B6=E6=98=BE=E7=A4=BA=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 赎回通知区分输赢:赢的仓位显示"赎回成功",输的仓位显示"已结算(无收益)" - 买入/卖出/赎回通知消息中添加钱包可用余额显示 - 新增多语言文案支持 Made-with: Cursor --- .../service/accounts/AccountService.kt | 57 +++++-- .../statistics/OrderStatusUpdateService.kt | 25 ++- .../system/TelegramNotificationService.kt | 154 +++++++++++++++++- .../resources/i18n/messages_en.properties | 8 + .../resources/i18n/messages_zh_CN.properties | 8 + .../resources/i18n/messages_zh_TW.properties | 8 + 6 files changed, 237 insertions(+), 23 deletions(-) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index d0c8cb5..49c1baf 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -1331,6 +1331,14 @@ class AccountService( // 使用当前时间作为订单创建时间 val orderTime = System.currentTimeMillis() + + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}") + null + } telegramNotificationService?.sendOrderSuccessNotification( orderId = orderId, @@ -1348,7 +1356,8 @@ class AccountService( apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null }, walletAddressForApi = account.walletAddress, locale = locale, - orderTime = orderTime // 使用订单创建时间 + orderTime = orderTime, // 使用订单创建时间 + availableBalance = availableBalance ) } catch (e: Exception) { logger.warn("发送订单成功通知失败: ${e.message}", e) @@ -1800,16 +1809,42 @@ class AccountService( for (transaction in accountTransactions) { val account = accounts[transaction.accountId] if (account != null) { - telegramNotificationService?.sendRedeemNotification( - accountName = account.accountName, - walletAddress = account.walletAddress, - transactionHash = transaction.transactionHash, - totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info -> - sum.add(info.value.toSafeBigDecimal()) - }.toPlainString(), - positions = transaction.positions, - locale = locale - ) + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}") + null + } + + // 计算该账户的赎回总价值 + val accountTotalValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info -> + sum.add(info.value.toSafeBigDecimal()) + } + + // 根据赎回价值选择不同的通知类型 + if (accountTotalValue.gt(BigDecimal.ZERO)) { + // 有收益:发送赎回成功通知 + telegramNotificationService?.sendRedeemNotification( + accountName = account.accountName, + walletAddress = account.walletAddress, + transactionHash = transaction.transactionHash, + totalRedeemedValue = accountTotalValue.toPlainString(), + positions = transaction.positions, + locale = locale, + availableBalance = availableBalance + ) + } else { + // 无收益(输的仓位):发送已结算无收益通知 + telegramNotificationService?.sendRedeemNoReturnNotification( + accountName = account.accountName, + walletAddress = account.walletAddress, + transactionHash = transaction.transactionHash, + positions = transaction.positions, + locale = locale, + availableBalance = availableBalance + ) + } } } } catch (e: Exception) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt index 2f31d63..97f58db 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt @@ -38,7 +38,8 @@ class OrderStatusUpdateService( private val cryptoUtils: CryptoUtils, private val trackingService: CopyOrderTrackingService, private val marketService: MarketService, // 市场信息服务 - private val telegramNotificationService: TelegramNotificationService? + private val telegramNotificationService: TelegramNotificationService?, + private val blockchainService: com.wrbug.polymarketbot.service.common.BlockchainService ) : ApplicationContextAware { private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java) @@ -930,6 +931,14 @@ class OrderStatusUpdateService( null } + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}") + null + } + // 发送通知 telegramNotificationService.sendOrderSuccessNotification( orderId = order.buyOrderId, @@ -950,7 +959,8 @@ class OrderStatusUpdateService( locale = locale, leaderName = leaderName, configName = configName, - orderTime = orderCreatedAt // 使用订单创建时间 + orderTime = orderCreatedAt, // 使用订单创建时间 + availableBalance = availableBalance ) logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}") @@ -1023,6 +1033,14 @@ class OrderStatusUpdateService( null } + // 查询可用余额 + val availableBalance = try { + blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull() + } catch (e: Exception) { + logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}") + null + } + // 发送通知 telegramNotificationService.sendOrderSuccessNotification( orderId = record.sellOrderId, @@ -1043,7 +1061,8 @@ class OrderStatusUpdateService( locale = locale, leaderName = leaderName, configName = configName, - orderTime = orderCreatedAt // 使用订单创建时间 + orderTime = orderCreatedAt, // 使用订单创建时间 + availableBalance = availableBalance ) logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index 49dba41..c53256d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -98,7 +98,8 @@ class TelegramNotificationService( locale: java.util.Locale? = null, leaderName: String? = null, // Leader 名称(备注) configName: String? = null, // 跟单配置名 - orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示 + orderTime: Long? = null, // 订单创建时间(毫秒时间戳),用于通知中的时间显示 + availableBalance: String? = null // 可用余额(可选) ) { // 1. 如果提供了 orderId,检查是否已发送过通知(去重) if (orderId != null) { @@ -192,7 +193,8 @@ class TelegramNotificationService( locale = currentLocale, leaderName = leaderName, configName = configName, - orderTime = orderTime + orderTime = orderTime, + availableBalance = availableBalance ) sendMessage(message) } @@ -766,7 +768,8 @@ class TelegramNotificationService( locale: java.util.Locale, leaderName: String? = null, // Leader 名称(备注) configName: String? = null, // 跟单配置名 - orderTime: Long? = null // 订单创建时间(毫秒时间戳) + orderTime: Long? = null, // 订单创建时间(毫秒时间戳) + availableBalance: String? = null // 可用余额 ): String { // 获取多语言文本 @@ -781,6 +784,7 @@ class TelegramNotificationService( val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale) val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale) val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val availableBalanceLabel = messageSource.getMessage("notification.order.available_balance", null, "可用余额", locale) val unknown = messageSource.getMessage("common.unknown", null, "未知", locale) val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale) @@ -879,6 +883,23 @@ class TelegramNotificationService( val priceDisplay = formatPrice(price) val sizeDisplay = formatQuantity(size) + // 格式化可用余额 + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) { + balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + balanceDecimal.stripTrailingZeros() + } + "\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" + } catch (e: Exception) { + "\n• $availableBalanceLabel: <code>$availableBalance</code> USDC" + } + } else { + "" + } + return """$icon <b>$orderCreatedSuccess</b> 📊 <b>$orderInfo:</b> @@ -888,7 +909,7 @@ class TelegramNotificationService( • $priceLabel: <code>$priceDisplay</code> • $quantityLabel: <code>$sizeDisplay</code> shares • $amountLabel: <code>$amountDisplay</code> USDC -• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo +• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay ⏰ $timeLabel: <code>$time</code>""" } @@ -1095,6 +1116,7 @@ class TelegramNotificationService( /** * 发送仓位赎回通知 * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) + * @param availableBalance 可用余额(可选) */ suspend fun sendRedeemNotification( accountName: String?, @@ -1102,7 +1124,8 @@ class TelegramNotificationService( transactionHash: String, totalRedeemedValue: String, positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, - locale: java.util.Locale? = null + locale: java.util.Locale? = null, + availableBalance: String? = null ) { // 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取) val currentLocale = locale ?: try { @@ -1118,7 +1141,8 @@ class TelegramNotificationService( transactionHash = transactionHash, totalRedeemedValue = totalRedeemedValue, positions = positions, - locale = currentLocale + locale = currentLocale, + availableBalance = availableBalance ) sendMessage(message) } @@ -1132,7 +1156,8 @@ class TelegramNotificationService( transactionHash: String, totalRedeemedValue: String, positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, - locale: java.util.Locale + locale: java.util.Locale, + availableBalance: String? = null ): String { // 获取多语言文本 val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale) @@ -1145,6 +1170,7 @@ class TelegramNotificationService( val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale) val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale) val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale) val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" // 构建账户信息(格式:账户名(钱包地址)) @@ -1186,19 +1212,129 @@ class TelegramNotificationService( " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC" } + // 格式化可用余额 + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) { + balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + balanceDecimal.stripTrailingZeros() + } + "\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" + } catch (e: Exception) { + "\n• $availableBalanceLabel: <code>$availableBalance</code> USDC" + } + } else { + "" + } + return """💸 <b>$redeemSuccess</b> 📊 <b>$redeemInfo:</b> • $accountLabel: $escapedAccountInfo • $transactionHashLabel: <code>$escapedTxHash</code> -• $totalValueLabel: <code>$totalValueDisplay</code> USDC +• $totalValueLabel: <code>$totalValueDisplay</code> USDC$availableBalanceDisplay 📦 <b>$positionsLabel:</b> $positionsText ⏰ $timeLabel: <code>$time</code>""" } - + + /** + * 发送仓位已结算(无收益)通知 + * 用于输的仓位,赎回价值为 0 的情况 + */ + suspend fun sendRedeemNoReturnNotification( + accountName: String?, + walletAddress: String?, + transactionHash: String, + positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, + locale: java.util.Locale? = null, + availableBalance: String? = null + ) { + val currentLocale = locale ?: try { + LocaleContextHolder.getLocale() + } catch (e: Exception) { + logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e) + java.util.Locale("zh", "CN") + } + + val message = buildRedeemNoReturnMessage( + accountName = accountName, + walletAddress = walletAddress, + transactionHash = transactionHash, + positions = positions, + locale = currentLocale, + availableBalance = availableBalance + ) + sendMessage(message) + } + + /** + * 构建仓位已结算(无收益)消息 + */ + private fun buildRedeemNoReturnMessage( + accountName: String?, + walletAddress: String?, + transactionHash: String, + positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>, + locale: java.util.Locale, + availableBalance: String? = null + ): String { + val noReturnTitle = messageSource.getMessage("notification.redeem.no_return.title", null, "仓位已结算(无收益)", locale) + val noReturnInfo = messageSource.getMessage("notification.redeem.no_return.info", null, "结算信息", locale) + val noReturnMessage = messageSource.getMessage("notification.redeem.no_return.message", null, "市场已结算,您的预测未命中,赎回价值为 0。", locale) + val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale) + val transactionHashLabel = messageSource.getMessage("notification.redeem.transaction_hash", null, "交易哈希", locale) + val positionsLabel = messageSource.getMessage("notification.redeem.no_return.positions", null, "结算仓位", locale) + val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale) + val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale) + val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户" + + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = DateUtils.formatDateTime() + + val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">") + val escapedTxHash = transactionHash.replace("<", "<").replace(">", ">") + + val positionsText = positions.joinToString("\n") { position -> + val quantityDisplay = formatQuantity(position.quantity) + " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares" + } + + // 格式化可用余额 + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) { + balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() + } else { + balanceDecimal.stripTrailingZeros() + } + "\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" + } catch (e: Exception) { + "\n• $availableBalanceLabel: <code>$availableBalance</code> USDC" + } + } else { + "" + } + + return """📋 <b>$noReturnTitle</b> + +📊 <b>$noReturnInfo:</b> +<i>$noReturnMessage</i> + +• $accountLabel: $escapedAccountInfo +• $transactionHashLabel: <code>$escapedTxHash</code>$availableBalanceDisplay + +📦 <b>$positionsLabel:</b> +$positionsText + +⏰ $timeLabel: <code>$time</code>""" + } + /** * 脱敏显示地址(只显示前6位和后4位) */ diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 3cebc68..960115e 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -13,6 +13,7 @@ notification.order.quantity=Quantity notification.order.amount=Amount notification.order.account=Account notification.order.time=Time +notification.order.available_balance=Available Balance notification.order.error_info=Error Information notification.order.unknown_account=Unknown Account notification.order.calculate_failed=Calculation Failed @@ -26,6 +27,13 @@ notification.redeem.position_count=Position Count notification.redeem.positions=Redeemed Positions notification.redeem.account=Account notification.redeem.time=Time +notification.redeem.available_balance=Available Balance + +# Position Settled (No Return) +notification.redeem.no_return.title=Position Settled (No Return) +notification.redeem.no_return.info=Settlement Information +notification.redeem.no_return.message=Market settled. Your prediction was incorrect. Redemption value is 0. +notification.redeem.no_return.positions=Settled Positions # Auto Redeem related notifications notification.auto_redeem.disabled.title=Auto Redeem Disabled diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 8d9d961..7fc04f6 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -13,6 +13,7 @@ notification.order.quantity=数量 notification.order.amount=金额 notification.order.account=账户 notification.order.time=时间 +notification.order.available_balance=可用余额 notification.order.error_info=错误信息 notification.order.unknown_account=未知账户 notification.order.calculate_failed=计算失败 @@ -26,6 +27,13 @@ notification.redeem.position_count=仓位数量 notification.redeem.positions=赎回仓位 notification.redeem.account=账户 notification.redeem.time=时间 +notification.redeem.available_balance=可用余额 + +# 仓位已结算(无收益) +notification.redeem.no_return.title=仓位已结算(无收益) +notification.redeem.no_return.info=结算信息 +notification.redeem.no_return.message=市场已结算,您的预测未命中,赎回价值为 0。 +notification.redeem.no_return.positions=结算仓位 # 自动赎回相关通知 notification.auto_redeem.disabled.title=自动赎回未开启 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 5d06e6c..a71d80c 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -13,6 +13,7 @@ notification.order.quantity=數量 notification.order.amount=金額 notification.order.account=賬戶 notification.order.time=時間 +notification.order.available_balance=可用餘額 notification.order.error_info=錯誤信息 notification.order.unknown_account=未知賬戶 notification.order.calculate_failed=計算失敗 @@ -26,6 +27,13 @@ notification.redeem.position_count=倉位數量 notification.redeem.positions=贖回倉位 notification.redeem.account=賬戶 notification.redeem.time=時間 +notification.redeem.available_balance=可用餘額 + +# 倉位已結算(無收益) +notification.redeem.no_return.title=倉位已結算(無收益) +notification.redeem.no_return.info=結算信息 +notification.redeem.no_return.message=市場已結算,您的預測未命中,贖回價值為 0。 +notification.redeem.no_return.positions=結算倉位 # 自動贖回相關通知 notification.auto_redeem.disabled.title=自動贖回未開啟 From f86749e85df81aad827bbddd3fadb5df1e9e5137 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Sun, 1 Mar 2026 05:57:34 +0800 Subject: [PATCH 11/26] =?UTF-8?q?feat(frontend):=20=E4=BC=98=E5=8C=96=20Le?= =?UTF-8?q?ader=20=E5=88=97=E8=A1=A8=20UI=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=93=8D=E4=BD=9C=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移动端:Cell 样式美化,渐变背景头部,资产常驻显示 - PC 端:操作列改用图标,合并跟单/回测数到图标 Badge - 跟单/回测数量用 Badge 显示,数量为 0 时半透明禁用 Made-with: Cursor --- frontend/src/pages/LeaderList.tsx | 392 +++++++++++++++++++++--------- 1 file changed, 283 insertions(+), 109 deletions(-) diff --git a/frontend/src/pages/LeaderList.tsx b/frontend/src/pages/LeaderList.tsx index 8099e78..9151081 100644 --- a/frontend/src/pages/LeaderList.tsx +++ b/frontend/src/pages/LeaderList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd' -import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons' +import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col, Tooltip, Badge } from 'antd' +import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { apiService } from '../services/api' import type { Leader, LeaderBalanceResponse } from '../types' @@ -225,11 +225,11 @@ const LeaderList: React.FC = () => { title: t('leaderList.leaderName'), dataIndex: 'leaderName', key: 'leaderName', - width: 150, + width: 200, render: (text: string, record: Leader) => ( <Space direction="vertical" size={0}> <Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text> - <Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text> + <Text type="secondary" style={{ fontSize: '12px', fontFamily: 'monospace' }}>{record.leaderAddress}</Text> </Space> ) }, @@ -237,76 +237,155 @@ const LeaderList: React.FC = () => { title: t('leaderList.remark'), dataIndex: 'remark', key: 'remark', - width: 200, + width: 180, ellipsis: true, render: (remark: string | undefined) => { if (!remark) return <Text type="secondary">-</Text> - return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text> + return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 160 }}>{remark}</Text> } }, { title: t('leaderDetail.availableBalance'), key: 'balance', - width: 150, + width: 180, render: (_: any, record: Leader) => { const balance = balanceMap[record.id] if (!balance) return <Spin size="small" /> - const displayText = balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC` - return <Text style={{ color: '#1890ff', fontSize: '14px' }}>{displayText}</Text> + return ( + <Space direction="vertical" size={0}> + <Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}> + {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`} + </Text> + <Text type="secondary" style={{ fontSize: '12px' }}> + {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)} + </Text> + </Space> + ) } }, - { - title: t('leaderList.copyTradingCount'), - dataIndex: 'copyTradingCount', - key: 'copyTradingCount', - width: 100, - render: (count: number, record: Leader) => ( - <Button - type="link" - size="small" - onClick={() => navigate(`/copy-trading?leaderId=${record.id}`)} - disabled={count === 0} - style={{ padding: 0 }} - > - <Tag color="cyan">{count}</Tag> - </Button> - ) - }, - { - title: t('leaderList.backtestCount'), - dataIndex: 'backtestCount', - key: 'backtestCount', - width: 100, - render: (count: number, record: Leader) => ( - <Button - type="link" - size="small" - onClick={() => navigate(`/backtest?leaderId=${record.id}`)} - disabled={count === 0} - style={{ padding: 0 }} - > - <Tag color="purple">{count}</Tag> - </Button> - ) - }, { title: t('common.actions'), key: 'action', - width: isMobile ? 180 : 250, + width: 200, fixed: 'right' as const, render: (_: any, record: Leader) => ( - <Space size="small" wrap> - <Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}> - {t('common.viewDetail')} - </Button> + <Space size={4}> + <Tooltip title={t('common.viewDetail')}> + <div + onClick={() => handleShowDetail(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} /> + </div> + </Tooltip> + {record.website && ( - <Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}> - {t('leaderList.openWebsite')} - </Button> + <Tooltip title={t('leaderList.openWebsite')}> + <div + onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <GlobalOutlined style={{ fontSize: '16px', color: '#fa8c16' }} /> + </div> + </Tooltip> )} - <Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}> - {t('common.edit')} - </Button> + + <Tooltip title={t('common.edit')}> + <div + onClick={() => navigate(`/leaders/edit?id=${record.id}`)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <EditOutlined style={{ fontSize: '16px', color: '#52c41a' }} /> + </div> + </Tooltip> + + <Tooltip title={`${t('leaderList.viewCopyTradings')} (${record.copyTradingCount})`}> + <div + onClick={() => { + if (record.copyTradingCount > 0) { + navigate(`/copy-trading?leaderId=${record.id}`) + } + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: record.copyTradingCount === 0 ? 'not-allowed' : 'pointer', + borderRadius: '6px', + opacity: record.copyTradingCount === 0 ? 0.4 : 1, + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => record.copyTradingCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <Badge count={record.copyTradingCount} size="small" offset={[-4, -4]}> + <CopyOutlined style={{ fontSize: '16px', color: '#13c2c2' }} /> + </Badge> + </div> + </Tooltip> + + <Tooltip title={`${t('leaderList.viewBacktests')} (${record.backtestCount})`}> + <div + onClick={() => { + if (record.backtestCount > 0) { + navigate(`/backtest?leaderId=${record.id}`) + } + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: record.backtestCount === 0 ? 'not-allowed' : 'pointer', + borderRadius: '6px', + opacity: record.backtestCount === 0 ? 0.4 : 1, + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => record.backtestCount > 0 && (e.currentTarget.style.backgroundColor = '#f0f0f0')} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <Badge count={record.backtestCount} size="small" offset={[-4, -4]}> + <LineChartOutlined style={{ fontSize: '16px', color: '#722ed1' }} /> + </Badge> + </div> + </Tooltip> + <Popconfirm title={t('leaderList.deleteConfirm')} description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined} @@ -314,9 +393,24 @@ const LeaderList: React.FC = () => { okText={t('common.confirm')} cancelText={t('common.cancel')} > - <Button type="link" size="small" danger icon={<DeleteOutlined />}> - {t('common.delete')} - </Button> + <Tooltip title={t('common.delete')}> + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} /> + </div> + </Tooltip> </Popconfirm> </Space> ) @@ -346,71 +440,148 @@ const LeaderList: React.FC = () => { dataSource={leaders} renderItem={(leader) => { const balance = balanceMap[leader.id] + const isLoading = balanceLoading[leader.id] return ( - <Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}> - <div style={{ marginBottom: '12px' }}> - <div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}> - {leader.leaderName || `Leader ${leader.id}`} + <Card + key={leader.id} + style={{ + marginBottom: '10px', + borderRadius: '10px', + boxShadow: '0 1px 3px rgba(0,0,0,0.08)', + border: '1px solid #e8e8e8', + overflow: 'hidden' + }} + bodyStyle={{ padding: '0' }} + > + {/* 头部区域 - 名称和地址 */} + <div style={{ + padding: '10px 12px', + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + color: '#fff' + }}> + <div style={{ fontSize: '15px', fontWeight: '600', marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> + <span>{leader.leaderName || `Leader ${leader.id}`}</span> + {leader.website && ( + <GlobalOutlined + style={{ fontSize: '13px', cursor: 'pointer', opacity: 0.8 }} + onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} + /> + )} </div> - <div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}> + <div style={{ fontSize: '10px', opacity: '0.85', fontFamily: 'monospace', wordBreak: 'break-all' }}> {leader.leaderAddress} </div> </div> - {balance && ( - <div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}> - <div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}> - {t('leaderDetail.availableBalance')}: {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`} + {/* 资产区域 - 常驻显示 */} + <div style={{ + padding: '8px 12px', + backgroundColor: '#fafafa', + borderBottom: '1px solid #f0f0f0', + minHeight: '42px', + display: 'flex', + alignItems: 'center' + }}> + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}> + <div> + <div style={{ fontSize: '10px', color: '#8c8c8c' }}> + {t('leaderDetail.availableBalance')} + </div> + <div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}> + {balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'} + </div> </div> - <div style={{ fontSize: '11px', color: '#666' }}> - {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)} + <div style={{ textAlign: 'right' }}> + <div style={{ fontSize: '10px', color: '#8c8c8c' }}> + {t('leaderDetail.positionBalance')} + </div> + <div style={{ fontSize: '14px', fontWeight: '500', color: '#722ed1' }}> + {balance?.position && balance.position !== '-' ? formatUSDC(balance.position) : '-'} + </div> </div> </div> - )} - - <Divider style={{ margin: '12px 0' }} /> - - <div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}> - <Button - type="default" - size="small" - onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)} - disabled={leader.copyTradingCount === 0} - style={{ borderRadius: '6px', padding: '8px 16px' }} - > - {t('leaderList.viewCopyTradings')} ({leader.copyTradingCount}) - </Button> - <Button - type="default" - size="small" - onClick={() => navigate(`/backtest?leaderId=${leader.id}`)} - disabled={leader.backtestCount === 0} - style={{ borderRadius: '6px', padding: '8px 16px' }} - > - {t('leaderList.viewBacktests')} ({leader.backtestCount}) - </Button> </div> + {/* 备注区域 */} {leader.remark && ( - <div style={{ marginBottom: '12px' }}> - <Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}:</Text> - <Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text> + <div style={{ + padding: '6px 12px', + backgroundColor: '#fffbe6', + borderBottom: '1px solid #ffe58f', + fontSize: '11px', + color: '#8c8c8c' + }}> + <span style={{ color: '#d48806' }}>{t('leaderList.remark')}:</span> + <span>{leader.remark}</span> </div> )} - <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> - <Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}> - {t('common.viewDetail')} - </Button> - {leader.website && ( - <Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}> - {t('leaderList.openWebsite')} - </Button> - )} - <Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}> - {t('common.edit')} - </Button> + {/* 图标操作栏 */} + <div style={{ + padding: '8px 12px', + display: 'flex', + justifyContent: 'space-around', + alignItems: 'center' + }}> + <Tooltip title={t('common.viewDetail')}> + <div + onClick={() => handleShowDetail(leader)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + <EyeOutlined style={{ fontSize: '18px', color: '#1890ff' }} /> + <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.viewDetail')}</span> + </div> + </Tooltip> + + <Tooltip title={t('common.edit')}> + <div + onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + <EditOutlined style={{ fontSize: '18px', color: '#52c41a' }} /> + <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.edit')}</span> + </div> + </Tooltip> + + <Tooltip title={t('leaderList.viewCopyTradings')}> + <div + onClick={() => navigate(`/copy-trading?leaderId=${leader.id}`)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + cursor: leader.copyTradingCount === 0 ? 'not-allowed' : 'pointer', + padding: '4px 8px', + opacity: leader.copyTradingCount === 0 ? 0.4 : 1 + }} + > + <Badge count={leader.copyTradingCount} size="small" offset={[-2, -2]}> + <CopyOutlined style={{ fontSize: '18px', color: '#13c2c2' }} /> + </Badge> + <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewCopyTradings')}</span> + </div> + </Tooltip> + + <Tooltip title={t('leaderList.viewBacktests')}> + <div + onClick={() => navigate(`/backtest?leaderId=${leader.id}`)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + cursor: leader.backtestCount === 0 ? 'not-allowed' : 'pointer', + padding: '4px 8px', + opacity: leader.backtestCount === 0 ? 0.4 : 1 + }} + > + <Badge count={leader.backtestCount} size="small" offset={[-2, -2]}> + <LineChartOutlined style={{ fontSize: '18px', color: '#722ed1' }} /> + </Badge> + <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('leaderList.viewBacktests')}</span> + </div> + </Tooltip> + <Popconfirm title={t('leaderList.deleteConfirm')} description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined} @@ -418,9 +589,12 @@ const LeaderList: React.FC = () => { okText={t('common.confirm')} cancelText={t('common.cancel')} > - <Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px', padding: '8px 16px' }}> - {t('common.delete')} - </Button> + <Tooltip title={t('common.delete')}> + <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + <DeleteOutlined style={{ fontSize: '18px', color: '#ff4d4f' }} /> + <span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('common.delete')}</span> + </div> + </Tooltip> </Popconfirm> </div> </Card> From 4ebfacfc219f1f63c7c21fec2f741168c8f95e45 Mon Sep 17 00:00:00 2001 From: WrBug <iwrbug@gmail.com> Date: Sun, 1 Mar 2026 06:48:51 +0800 Subject: [PATCH 12/26] =?UTF-8?q?feat(frontend):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=E5=88=97=E8=A1=A8=E9=A1=B5=E9=9D=A2=20UI?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=A9=BA=E7=8A=B6=E6=80=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 AccountList、BacktestList、CopyTradingList、CryptoTailStrategyList、LeaderList、TemplateList、UserList 页面操作栏 - 添加空数据状态提示 - 添加相关多语言翻译 key Made-with: Cursor --- frontend/src/locales/en/common.json | 16 +- frontend/src/locales/zh-CN/common.json | 16 +- frontend/src/locales/zh-TW/common.json | 16 +- frontend/src/pages/AccountList.tsx | 396 ++++++----- frontend/src/pages/BacktestList.tsx | 636 +++++++++++------- frontend/src/pages/CopyTradingList.tsx | 337 +++++----- frontend/src/pages/CryptoTailStrategyList.tsx | 201 +++--- frontend/src/pages/LeaderList.tsx | 23 +- frontend/src/pages/TemplateList.tsx | 343 ++++++---- frontend/src/pages/UserList.tsx | 192 +++++- 10 files changed, 1272 insertions(+), 904 deletions(-) diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 2ecb7c7..fbe3f3a 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -42,7 +42,8 @@ "pageOf": "Page", "ascending": "Ascending", "descending": "Descending", - "day": "day" + "day": "day", + "orders": "orders" }, "account": { "title": "Account Management", @@ -176,7 +177,8 @@ "updateFailed": "Failed to update account", "getDetailFailedForEdit": "Failed to get account detail", "loading": "Loading...", - "fetchFailed": "Failed to get account list" + "fetchFailed": "Failed to get account list", + "noData": "No account data" }, "accountImport": { "title": "Import Account", @@ -660,7 +662,8 @@ "deleteSuccess": "User deleted successfully", "deleteFailed": "Failed to delete user", "deleteConfirm": "Are you sure you want to delete this user?", - "total": "Total {{total}} items" + "total": "Total {{total}} items", + "noData": "No user data" }, "statistics": { "title": "Statistics", @@ -1121,7 +1124,9 @@ "updateStatusFailed": "Failed to update copy trading status", "deleteSuccess": "Copy trading deleted successfully", "deleteFailed": "Failed to delete copy trading", - "deleteConfirm": "Are you sure you want to delete this copy trading relationship?" + "deleteConfirm": "Are you sure you want to delete this copy trading relationship?", + "profitRate": "Profit Rate", + "noData": "No copy trading configuration" }, "notificationSettings": { "title": "Notification Settings", @@ -1351,6 +1356,8 @@ "title": "Backtest", "taskName": "Task Name", "leader": "Leader", + "balance": "Balance (Init→Final)", + "profit": "Profit (Amount/Rate)", "initialBalance": "Initial Balance", "backtestDays": "Backtest Days", "status": "Status", @@ -1399,6 +1406,7 @@ "createCopyTradingSuccess": "Copy trading config created successfully", "noTasks": "No backtest tasks", "noTrades": "No trade records", + "noData": "No backtest data", "fetchTasksFailed": "Failed to fetch task list", "fetchTaskDetailFailed": "Failed to fetch task detail", "fetchTradesFailed": "Failed to fetch trade records", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 853c74c..c7f34bd 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -42,7 +42,8 @@ "copyFailed": "复制失败", "ascending": "升序", "descending": "降序", - "day": "天" + "day": "天", + "orders": "单" }, "login": { "title": "登录", @@ -176,7 +177,8 @@ "updateFailed": "更新账户失败", "getDetailFailedForEdit": "获取账户详情失败", "loading": "加载中...", - "fetchFailed": "获取账户列表失败" + "fetchFailed": "获取账户列表失败", + "noData": "暂无账户数据" }, "accountImport": { "title": "导入账户", @@ -660,7 +662,8 @@ "deleteSuccess": "删除用户成功", "deleteFailed": "删除用户失败", "deleteConfirm": "确定要删除这个用户吗?", - "total": "共 {{total}} 条" + "total": "共 {{total}} 条", + "noData": "暂无用户数据" }, "statistics": { "title": "统计信息", @@ -1121,7 +1124,9 @@ "updateStatusFailed": "更新跟单状态失败", "deleteSuccess": "删除跟单成功", "deleteFailed": "删除跟单失败", - "deleteConfirm": "确定要删除这个跟单关系吗?" + "deleteConfirm": "确定要删除这个跟单关系吗?", + "profitRate": "收益率", + "noData": "暂无跟单配置" }, "notificationSettings": { "title": "消息推送设置", @@ -1351,6 +1356,8 @@ "title": "回测", "taskName": "任务名称", "leader": "Leader", + "balance": "资金 (初始→最终)", + "profit": "收益 (金额/比例)", "initialBalance": "初始资金", "backtestDays": "回测天数", "status": "状态", @@ -1399,6 +1406,7 @@ "createCopyTradingSuccess": "跟单配置创建成功", "noTasks": "暂无回测任务", "noTrades": "暂无交易记录", + "noData": "暂无回测数据", "fetchTasksFailed": "获取任务列表失败", "fetchTaskDetailFailed": "获取任务详情失败", "fetchTradesFailed": "获取交易记录失败", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index e4ea674..7b149d0 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -42,7 +42,8 @@ "pageOf": "第", "ascending": "升序", "descending": "降序", - "day": "天" + "day": "天", + "orders": "單" }, "account": { "title": "賬戶管理", @@ -176,7 +177,8 @@ "updateFailed": "更新賬戶失敗", "getDetailFailedForEdit": "獲取賬戶詳情失敗", "loading": "加載中...", - "fetchFailed": "獲取賬戶列表失敗" + "fetchFailed": "獲取賬戶列表失敗", + "noData": "暫無賬戶數據" }, "accountImport": { "title": "導入賬戶", @@ -660,7 +662,8 @@ "deleteSuccess": "刪除用戶成功", "deleteFailed": "刪除用戶失敗", "deleteConfirm": "確定要刪除這個用戶嗎?", - "total": "共 {{total}} 條" + "total": "共 {{total}} 條", + "noData": "暫無用戶數據" }, "statistics": { "title": "統計信息", @@ -1121,7 +1124,9 @@ "updateStatusFailed": "更新跟單狀態失敗", "deleteSuccess": "刪除跟單成功", "deleteFailed": "刪除跟單失敗", - "deleteConfirm": "確定要刪除這個跟單關係嗎?" + "deleteConfirm": "確定要刪除這個跟單關係嗎?", + "profitRate": "收益率", + "noData": "暫無跟單配置" }, "notificationSettings": { "title": "消息推送設置", @@ -1351,6 +1356,8 @@ "title": "回測", "taskName": "任務名稱", "leader": "Leader", + "balance": "資金 (初始→最終)", + "profit": "收益 (金額/比例)", "initialBalance": "初始資金", "backtestDays": "回測天數", "status": "狀態", @@ -1399,6 +1406,7 @@ "createCopyTradingSuccess": "跟單配置創建成功", "noTasks": "暫無回測任務", "noTrades": "暫無交易記錄", + "noData": "暫無回測數據", "fetchTasksFailed": "獲取任務列表失敗", "fetchTaskDetailFailed": "獲取任務詳情失敗", "fetchTradesFailed": "獲取交易記錄失敗", diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index 0ed3693..e9b5f7f 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd' -import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd' +import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useAccountStore } from '../store/accountStore' import type { Account } from '../types' @@ -331,151 +331,49 @@ const AccountList: React.FC = () => { { title: t('accountList.action'), key: 'action', + width: 140, render: (_: any, record: Account) => ( - <Space size="small"> - <Button - type="link" - size="small" - onClick={() => handleShowDetail(record)} - > - {t('accountList.detail')} - </Button> - <Button - type="link" - size="small" - icon={<EditOutlined />} - onClick={() => handleShowEdit(record)} - > - {t('accountList.edit')} - </Button> - <Popconfirm - title={t('accountList.deleteConfirm')} - description={ - record.apiKeyConfigured - ? t('accountList.deleteConfirmDesc') - : t('accountList.deleteConfirmDescSimple') - } - onConfirm={() => handleDelete(record)} - okText={t('accountList.deleteConfirmOk')} - cancelText={t('common.cancel')} - okButtonProps={{ danger: true }} - > - <Button type="link" size="small" danger> - {t('accountList.delete')} - </Button> - </Popconfirm> - </Space> - ) - } - ] - - const mobileColumns = [ - { - title: t('accountList.accountName'), - key: 'info', - render: (_: any, record: Account) => { - return ( - <div style={{ padding: '8px 0' }}> - <div style={{ - fontWeight: 'bold', - marginBottom: '8px', - fontSize: '16px' - }}> - {record.accountName || `${t('accountList.accountName')} ${record.id}`} - </div> - <div style={{ - fontSize: '11px', - color: '#666', - marginBottom: '8px', - wordBreak: 'break-all', - fontFamily: 'monospace', - lineHeight: '1.4' - }}> - <div style={{ marginBottom: '4px' }}> - <strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'} - <Button - type="text" - size="small" - icon={<CopyOutlined />} - onClick={(e) => { - e.stopPropagation() - handleCopy(record.walletAddress) - }} - style={{ marginLeft: '4px', padding: '0 4px' }} - /> - </div> - <div style={{ marginBottom: '4px' }}> - <strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'} - <Button - type="text" - size="small" - icon={<CopyOutlined />} - onClick={(e) => { - e.stopPropagation() - handleCopy(record.proxyAddress) - }} - style={{ marginLeft: '4px', padding: '0 4px' }} - /> - </div> - {record.walletType && ( - <div style={{ marginBottom: '4px' }}> - <strong>{t('accountList.walletType')}:</strong>{' '} - <Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}> - {record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'} - </Tag> - </div> - )} + <Space size={4}> + <Tooltip title={t('accountList.detail')}> + <div + onClick={() => handleShowDetail(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <EyeOutlined style={{ fontSize: '16px', color: '#1890ff' }} /> </div> - <div style={{ - fontSize: '14px', - fontWeight: '500', - color: '#1890ff' - }}> - {t('accountList.totalBalance')}: {balanceLoading[record.id] ? ( - <Spin size="small" style={{ marginLeft: '4px' }} /> - ) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? ( - `${formatUSDC(balanceMap[record.id].total)} USDC` - ) : ( - '-' - )} + </Tooltip> + + <Tooltip title={t('accountList.edit')}> + <div + onClick={() => handleShowEdit(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <EditOutlined style={{ fontSize: '16px', color: '#1890ff' }} /> </div> - {balanceMap[record.id] && balanceMap[record.id].available !== '-' && ( - <div style={{ - fontSize: '12px', - color: '#666', - marginTop: '4px' - }}> - {t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC - </div> - )} - </div> - ) - } - }, - { - title: t('accountList.action'), - key: 'action', - width: 100, - render: (_: any, record: Account) => ( - <Space direction="vertical" size="small" style={{ width: '100%' }}> - <Button - type="primary" - size="small" - block - onClick={() => handleShowDetail(record)} - style={{ minHeight: '32px' }} - > - {t('accountList.viewDetail')} - </Button> - <Button - size="small" - block - icon={<EditOutlined />} - onClick={() => handleShowEdit(record)} - style={{ minHeight: '32px' }} - > - {t('accountList.edit')} - </Button> + </Tooltip> + <Popconfirm title={t('accountList.deleteConfirm')} description={ @@ -488,14 +386,24 @@ const AccountList: React.FC = () => { cancelText={t('common.cancel')} okButtonProps={{ danger: true }} > - <Button - size="small" - block - danger - style={{ minHeight: '32px' }} - > - {t('accountList.delete')} - </Button> + <Tooltip title={t('accountList.delete')}> + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff1f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + <DeleteOutlined style={{ fontSize: '16px', color: '#ff4d4f' }} /> + </div> + </Tooltip> </Popconfirm> </Space> ) @@ -519,16 +427,15 @@ const AccountList: React.FC = () => { <Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}> {t('accountList.title')} - + +
{ borderRadius: isMobile ? '0' : undefined }}> {isMobile ? ( - + loading ? ( +
+ +
+ ) : accounts.length === 0 ? ( + + ) : ( + { + const balance = balanceMap[account.id] + + return ( + + {/* 头部区域 */} +
+
+ + {account.accountName || `${t('accountList.accountName')} ${account.id}`} +
+
+ {account.walletAddress ? `${account.walletAddress.slice(0, 6)}...${account.walletAddress.slice(-4)}` : '-'} +
+
+ + {/* 资产区域 */} +
+
+
+
+ {t('accountList.totalBalance')} +
+
+ {balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'} +
+
+
+
+ {t('accountList.walletType')} +
+
+ {account.walletType ? ( + + {account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'} + + ) : '-'} +
+
+
+
+ + {/* 地址信息区域 */} +
+
+ {t('accountList.proxyAddress')}: {account.proxyAddress ? `${account.proxyAddress.slice(0, 6)}...${account.proxyAddress.slice(-4)}` : '-'} +
+
+ + {/* 图标操作栏 */} +
+ +
handleShowDetail(account)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('accountList.detail')} +
+
+ + +
handleShowEdit(account)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('accountList.edit')} +
+
+ + handleDelete(account)} + okText={t('accountList.deleteConfirmOk')} + cancelText={t('common.cancel')} + okButtonProps={{ danger: true }} + > + +
+ + {t('accountList.delete')} +
+
+
+
+
+ ) + }} + /> + ) ) : (
{ } // 删除任务 - const handleDelete = (id: number) => { - Modal.confirm({ - title: t('backtest.deleteConfirm'), - okText: t('common.confirm'), - cancelText: t('common.cancel'), - onOk: async () => { - try { - const response = await backtestService.delete({ id }) - if (response.data.code === 0) { - message.success(t('backtest.deleteSuccess')) - fetchTasks() - } else { - message.error(response.data.msg || t('backtest.deleteFailed')) - } - } catch (error) { - console.error('Failed to delete backtest task:', error) - message.error(t('backtest.deleteFailed')) - } + const handleDelete = async (id: number) => { + try { + const response = await backtestService.delete({ id }) + if (response.data.code === 0) { + message.success(t('backtest.deleteSuccess')) + fetchTasks() + } else { + message.error(response.data.msg || t('backtest.deleteFailed')) } - }) + } catch (error) { + console.error('Failed to delete backtest task:', error) + message.error(t('backtest.deleteFailed')) + } } // 停止任务 - const handleStop = (id: number) => { - Modal.confirm({ - title: t('backtest.stopConfirm'), - okText: t('common.confirm'), - cancelText: t('common.cancel'), - onOk: async () => { - try { - const response = await backtestService.stop({ id }) - if (response.data.code === 0) { - message.success(t('backtest.stopSuccess')) - fetchTasks() - } else { - message.error(response.data.msg || t('backtest.stopFailed')) - } - } catch (error) { - console.error('Failed to stop backtest task:', error) - message.error(t('backtest.stopFailed')) - } + const handleStop = async (id: number) => { + try { + const response = await backtestService.stop({ id }) + if (response.data.code === 0) { + message.success(t('backtest.stopSuccess')) + fetchTasks() + } else { + message.error(response.data.msg || t('backtest.stopFailed')) } - }) + } catch (error) { + console.error('Failed to stop backtest task:', error) + message.error(t('backtest.stopFailed')) + } } // 按配置重新测试(仅已完成任务) @@ -502,168 +488,227 @@ const BacktestList: React.FC = () => { title: t('backtest.taskName'), dataIndex: 'taskName', key: 'taskName', - width: isMobile ? 120 : 150 + width: isMobile ? 120 : 140, + ellipsis: true }, { title: t('backtest.leader'), dataIndex: 'leaderName', key: 'leaderName', - width: isMobile ? 100 : 150, + width: isMobile ? 100 : 120, + ellipsis: true, render: (_: any, record: BacktestTaskDto) => record.leaderName || `Leader ${record.leaderId}` }, { - title: t('backtest.initialBalance'), - dataIndex: 'initialBalance', - key: 'initialBalance', - width: 120, - render: (value: string) => formatUSDC(value) - }, - { - title: t('backtest.finalBalance'), - dataIndex: 'finalBalance', - key: 'finalBalance', - width: 120, - render: (value: string | null) => value ? formatUSDC(value) : '-' - }, - { - title: t('backtest.profitAmount'), - dataIndex: 'profitAmount', - key: 'profitAmount', - width: 120, - render: (value: string | null) => value ? ( - = 0 ? '#52c41a' : '#ff4d4f' }}> - {formatUSDC(value)} - - ) : '-' - }, - { - title: t('backtest.profitRate'), - dataIndex: 'profitRate', - key: 'profitRate', - width: 100, - render: (value: string | null) => value ? ( - = 0 ? '#52c41a' : '#ff4d4f' }}> - {value}% - - ) : '-' + title: t('backtest.balance'), + key: 'balance', + width: 160, + render: (_: any, record: BacktestTaskDto) => ( +
+
{formatUSDC(record.initialBalance)}
+
{record.finalBalance ? formatUSDC(record.finalBalance) : '-'}
+
+ ) }, { - title: t('backtest.backtestDays'), - dataIndex: 'backtestDays', - key: 'backtestDays', - width: 100, - render: (value: number) => `${value} ${t('common.day')}` + title: t('backtest.profit'), + key: 'profit', + width: 140, + render: (_: any, record: BacktestTaskDto) => { + const profitAmount = record.profitAmount ? parseFloat(record.profitAmount) : null + const profitRate = record.profitRate ? parseFloat(record.profitRate) : null + const color = profitAmount !== null ? (profitAmount >= 0 ? '#52c41a' : '#ff4d4f') : undefined + return ( +
+
+ {profitAmount !== null ? formatUSDC(record.profitAmount) : '-'} +
+
+ {profitRate !== null ? `${record.profitRate}%` : '-'} +
+
+ ) + } }, { title: t('backtest.status'), dataIndex: 'status', key: 'status', - width: 100, - render: (status: string) => ( - {getStatusText(status)} - ) - }, - { - title: t('backtest.progress'), - dataIndex: 'progress', - key: 'progress', - width: 120, - render: (progress: number) => ( -
-
{progress}%
-
-
+ width: 130, + render: (status: string, record: BacktestTaskDto) => { + const isRunning = status === 'RUNNING' || status === 'PENDING' + return ( +
+ {getStatusText(status)} + {isRunning && record.progress !== undefined && ( +
+ {record.progress}% +
+ )}
-
- ) + ) + } }, { title: t('backtest.totalTrades'), dataIndex: 'totalTrades', key: 'totalTrades', - width: 100 + width: 80, + align: 'center' as const }, { title: t('backtest.createdAt'), dataIndex: 'createdAt', key: 'createdAt', - width: isMobile ? 150 : 180, + width: isMobile ? 150 : 150, render: (timestamp: number) => new Date(timestamp).toLocaleString() }, { title: t('common.actions'), key: 'actions', fixed: isMobile ? false : ('right' as const), - width: isMobile ? 100 : 150, + width: isMobile ? 100 : 180, render: (_: any, record: BacktestTaskDto) => ( - - + + +
handleViewDetail(record.id)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ {record.status === 'COMPLETED' && ( <> - - + +
handleRerun(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ +
handleCreateCopyTrading(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
)} + {record.status === 'RUNNING' && ( - + +
e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ )} + {(record.status === 'STOPPED' || record.status === 'FAILED') && ( - + +
handleRetry(record.id)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
)} + {(record.status === 'PENDING' || record.status === 'COMPLETED' || record.status === 'STOPPED' || record.status === 'FAILED') && ( - + +
e.currentTarget.style.backgroundColor = '#fff1f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ )}
) @@ -671,94 +716,205 @@ const BacktestList: React.FC = () => { ] return ( -
- +
+
+

{t('backtest.title')}

+ + +
+ + - {/* 头部操作栏 */} - -
- - setLeaderIdFilter(value)} - leaders={leaders} - /> - - - - + {/* 筛选栏 */} + + + setLeaderIdFilter(value)} + leaders={leaders} + /> - - - - - + + + + + + + + - {/* 数据表格 */} -
`${t('common.total')} ${total} ${t('common.items')}`, - onChange: (newPage) => setPage(newPage), - simple: isMobile - }} - scroll={isMobile ? { x: 1200 } : { x: 1400 }} - /> + {/* 数据:移动端卡片 / 桌面端表格 */} + {isMobile ? ( + <> + {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( + + ) : ( + ( + +
+
{task.taskName}
+
{task.leaderName || `Leader ${task.leaderId}`}
+
+
+
+
{t('backtest.profitAmount')}
+ {task.profitAmount != null ? ( +
= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC
+ ) : ( +
-
+ )} +
+
+
{t('backtest.profitRate')}
+ {task.profitRate != null ? ( +
= 0 ? '#52c41a' : '#ff4d4f' }}>{task.profitRate}%
+ ) : ( +
-
+ )} +
+
+
{t('backtest.status')}
+ {getStatusText(task.status)} +
+
+
+ {t('backtest.initialBalance')}: {formatUSDC(task.initialBalance)} · {t('backtest.backtestDays')}: {task.backtestDays} {t('common.day')} · {t('backtest.progress')}: {task.progress}% +
+
+ +
handleViewDetail(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('common.viewDetail')} +
+
+ {task.status === 'COMPLETED' && ( + <> + +
handleRerun(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('backtest.rerun')} +
+
+ +
handleCreateCopyTrading(task)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('backtest.createCopyTrading')} +
+
+ + )} + {task.status === 'RUNNING' && ( + +
handleStop(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('backtest.stop')} +
+
+ )} + {(task.status === 'STOPPED' || task.status === 'FAILED') && ( + +
handleRetry(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('backtest.retry')} +
+
+ )} + {(task.status === 'PENDING' || task.status === 'COMPLETED' || task.status === 'STOPPED' || task.status === 'FAILED') && ( + +
handleDelete(task.id)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('common.delete')} +
+
+ )} +
+
+ )} + /> + )} + {/* 移动端分页 */} + {!loading && tasks.length > 0 && total > size && ( +
+ + {page} / {Math.ceil(total / size)} + +
+ )} + + ) : ( +
`${t('common.total')} ${totalCount} ${t('common.items')}`, + onChange: (newPage) => setPage(newPage), + simple: isMobile + }} + scroll={{ x: 1000 }} + /> + )} diff --git a/frontend/src/pages/CopyTradingList.tsx b/frontend/src/pages/CopyTradingList.tsx index 540ce5e..58c8c4d 100644 --- a/frontend/src/pages/CopyTradingList.tsx +++ b/frontend/src/pages/CopyTradingList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd' -import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons' +import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Spin, List, Empty, Tooltip } from 'antd' +import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined, WalletOutlined, UserOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import type { MenuProps } from 'antd' import { apiService } from '../services/api' @@ -384,19 +384,21 @@ const CopyTradingList: React.FC = () => { return (
- -
-

{t('copyTradingList.title') || '跟单配置管理'}

+
+

{t('copyTradingList.title') || '跟单配置管理'}

+ -
- -
+ size={isMobile ? 'middle' : 'large'} + style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }} + /> + +
+ + +
setFilters({ ...filters, enabled: value !== undefined ? value : undefined })} > - - + +
@@ -440,207 +442,180 @@ const CopyTradingList: React.FC = () => {
) : copyTradings.length === 0 ? ( -
- 暂无跟单配置 -
+ ) : ( -
- {copyTradings.map((record) => { + { const stats = statisticsMap[record.id] - const date = new Date(record.createdAt) - const formattedDate = date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) return ( - {/* 基本信息 */} -
-
- {record.configName || t('copyTradingList.configNameNotProvided') || '未提供'} -
-
- {record.copyMode === 'RATIO' - ? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%` - : `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}` - } -
-
- - {record.enabled ? '启用' : '禁用'} - + {/* 头部区域 - 配置名称 */} +
+
+ {record.configName || t('copyTradingList.configNameNotProvided') || '未提供'} handleToggleStatus(record)} - checkedChildren="开启" - unCheckedChildren="停止" + checkedChildren={t('copyTradingList.enabled') || '开启'} + unCheckedChildren={t('copyTradingList.disabled') || '停止'} size="small" />
-
- - - - {/* 账户信息 */} -
-
账户
-
- {record.accountName || `账户 ${record.accountId}`} +
+ {record.copyMode === 'RATIO' + ? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%` + : `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC` + }
-
- {record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)} +
+ + {/* 盈亏区域 - 常驻显示 */} +
+
+
+
+ {t('copyTradingList.totalPnl') || '总盈亏'} +
+ {stats ? ( +
+ {getPnlIcon(stats.totalPnl)} + {formatUSDC(stats.totalPnl)} USDC +
+ ) : loadingStatistics.has(record.id) ? ( + + ) : ( +
-
+ )} +
+ {stats && ( +
+
+ {t('copyTradingList.profitRate') || '收益率'} +
+
+ {formatPercent(stats.totalPnlPercent)} +
+
+ )}
- - {/* Leader 信息 */} -
-
Leader
-
- {record.leaderName || `Leader ${record.leaderId}`} + + {/* 账户和Leader信息区域 */} +
+
+ + {t('copyTradingList.wallet') || '账户'}: {record.accountName || `#${record.accountId}`}
-
- {record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)} +
+ + Leader: {record.leaderName || `#${record.leaderId}`}
- - {/* 总盈亏 */} - {stats && ( -
-
总盈亏
-
- {getPnlIcon(stats.totalPnl)} - {formatUSDC(stats.totalPnl)} USDC + + {/* 图标操作栏 */} +
+ +
{ + setEditModalCopyTradingId(record.id.toString()) + setEditModalOpen(true) + }} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('common.edit') || '编辑'}
-
- {formatPercent(stats.totalPnlPercent)} + + + +
{ + setStatisticsModalCopyTradingId(record.id.toString()) + setStatisticsModalOpen(true) + }} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('copyTradingList.statistics') || '统计'}
-
- )} - - {loadingStatistics.has(record.id) && ( -
- 加载统计中... -
- )} - - {/* 创建时间 */} -
-
- 创建时间: {formattedDate} -
-
- - {/* 操作按钮 */} -
- - - , - onClick: () => { - setOrdersModalCopyTradingId(record.id.toString()) - setOrdersModalTab('buy') - setOrdersModalOpen(true) - } - }, - { - key: 'filteredOrders', - label: t('copyTradingList.filteredOrders') || '已过滤订单', - icon: , - onClick: () => { - setFilteredOrdersModalCopyTradingId(record.id.toString()) - setFilteredOrdersModalOpen(true) - } - } - ] - }} - trigger={['click']} - > - - + + {t('copyTradingList.orders') || '订单'} +
+
+ handleDelete(record.id)} okText={t('common.confirm') || '确定'} cancelText={t('common.cancel') || '取消'} > - + +
+ + {t('common.delete') || '删除'} +
+
) - })} -
+ }} + /> )}
) : ( diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index bbd8f90..f12f5e0 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -21,12 +21,11 @@ import { Tabs, DatePicker, Empty, - Typography, - Divider + Typography } from 'antd' import type { Dayjs } from 'dayjs' import dayjs from 'dayjs' -import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons' +import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import { apiService } from '../services/api' @@ -543,17 +542,28 @@ const CryptoTailStrategyList: React.FC = () => { } return ( -
-
-

{t('cryptoTailStrategy.list.title')}

- +
+
+
+

{t('cryptoTailStrategy.list.title')}

+ +
+ +
{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && ( {
} - style={{ marginBottom: 16 }} + style={{ marginBottom: 16, margin: isMobile ? '0 8px 16px' : undefined }} /> )} - +
-
{ onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} > - + )} @@ -328,7 +328,7 @@ const LeaderList: React.FC = () => { onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} > - + @@ -354,7 +354,7 @@ const LeaderList: React.FC = () => { onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} > - + @@ -381,7 +381,7 @@ const LeaderList: React.FC = () => { onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} > - + @@ -421,9 +421,9 @@ const LeaderList: React.FC = () => {

{t('leaderList.title')}

- + +
@@ -440,7 +440,6 @@ const LeaderList: React.FC = () => { dataSource={leaders} renderItem={(leader) => { const balance = balanceMap[leader.id] - const isLoading = balanceLoading[leader.id] return ( { {/* 头部区域 - 名称和地址 */}
@@ -539,7 +538,7 @@ const LeaderList: React.FC = () => { onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} > - + {t('common.edit')}
@@ -557,7 +556,7 @@ const LeaderList: React.FC = () => { }} > - + {t('leaderList.viewCopyTradings')}
@@ -576,7 +575,7 @@ const LeaderList: React.FC = () => { }} > - + {t('leaderList.viewBacktests')}
diff --git a/frontend/src/pages/TemplateList.tsx b/frontend/src/pages/TemplateList.tsx index ead9a4a..6a3dfce 100644 --- a/frontend/src/pages/TemplateList.tsx +++ b/frontend/src/pages/TemplateList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin } from 'antd' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Divider, Spin, Empty, List, Tooltip } from 'antd' import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { apiService } from '../services/api' @@ -212,25 +212,50 @@ const TemplateList: React.FC = () => { { title: t('common.actions') || '操作', key: 'action', - width: isMobile ? 120 : 200, + width: isMobile ? 120 : 120, + fixed: 'right' as const, render: (_: any, record: CopyTradingTemplate) => ( - - - + + +
navigate(`/templates/edit/${record.id}`)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ + +
handleCopy(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ { okText={t('common.confirm') || '确定'} cancelText={t('common.cancel') || '取消'} > - + +
e.currentTarget.style.backgroundColor = '#fff1f0'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
) @@ -254,26 +289,29 @@ const TemplateList: React.FC = () => { return (
- -
-

{t('templateList.title') || '跟单模板管理'}

- - setSearchText(e.target.value)} - /> +
+

{t('templateList.title') || '跟单模板管理'}

+ + setSearchText(e.target.value)} + /> + - -
+ size={isMobile ? 'middle' : 'large'} + style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }} + /> + +
+
+ + {isMobile ? ( // 移动端卡片布局 @@ -283,116 +321,133 @@ const TemplateList: React.FC = () => {
) : filteredTemplates.length === 0 ? ( -
- {t('templateList.noData') || '暂无模板数据'} -
+ ) : ( -
- {filteredTemplates.map((template) => { - const date = new Date(template.createdAt) - const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) - + { return ( - {/* 模板名称和模式 */} -
-
+ {/* 头部区域 - 模板名称 */} +
+
{template.templateName}
-
- - {template.copyMode === 'RATIO' ? (t('templateList.ratioMode') || '比例模式') : (t('templateList.fixedAmountMode') || '固定金额模式')} - - - {template.supportSell ? (t('templateList.supportSell') || '跟单卖出') : (t('templateList.notSupportSell') || '不跟单卖出')} - -
-
- - - - {/* 跟单配置 */} -
-
{t('templateList.copyConfig') || '跟单配置'}
-
+
{template.copyMode === 'RATIO' - ? `${t('templateList.ratio') || '比例'} ${template.copyRatio}x` - : template.fixedAmount - ? `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(template.fixedAmount)} USDC` - : '-' + ? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%` + : `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC` }
- - {/* 其他配置信息 */} - {template.copyMode === 'RATIO' && ( -
-
{t('templateList.amountLimit') || '金额限制'}
-
- {template.maxOrderSize && ( - {t('templateList.max') || '最大'}: {formatUSDC(template.maxOrderSize)} USDC - )} - {template.maxOrderSize && template.minOrderSize && | } - {template.minOrderSize && ( - {t('templateList.min') || '最小'}: {formatUSDC(template.minOrderSize)} USDC - )} - {!template.maxOrderSize && !template.minOrderSize && {t('templateList.notSet') || '未设置'}} + + {/* 配置信息区域 */} +
+
+
+
+ {t('templateList.supportSell') || '跟单卖出'} +
+
+ + {template.supportSell ? (t('common.yes') || '是') : (t('common.no') || '否')} + +
+
+
+
+ {t('templateList.maxDailyOrders') || '每日最大'} +
+
+ {template.maxDailyOrders} {t('common.orders') || '单'} +
-
- )} - -
-
{t('templateList.otherConfig') || '其他配置'}
-
- {t('templateList.maxDailyOrders') || '每日最大订单'}: {template.maxDailyOrders} | {t('templateList.priceTolerance') || '价格容忍度'}: {template.priceTolerance}%
- - {/* 创建时间 */} -
-
- {t('common.createdAt') || '创建时间'}: {formattedDate} + + {/* 金额限制区域(仅比例模式显示) */} + {template.copyMode === 'RATIO' && ( +
+ {t('templateList.amountLimit') || '金额限制'}: + {template.maxOrderSize && ( + {t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC + )} + {template.maxOrderSize && template.minOrderSize && | } + {template.minOrderSize && ( + {t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC + )} + {!template.maxOrderSize && !template.minOrderSize && {t('templateList.notSet') || '未设置'}}
+ )} + + {/* 创建时间 */} +
+ {t('common.createdAt') || '创建时间'}: {new Date(template.createdAt).toLocaleString(i18n.language || 'zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })}
- - {/* 操作按钮 */} -
- - + + {/* 图标操作栏 */} +
+ +
navigate(`/templates/edit/${template.id}`)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('common.edit') || '编辑'} +
+
+ + +
handleCopy(template)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('templateList.copy') || '复制'} +
+
+ { okText={t('common.confirm') || '确定'} cancelText={t('common.cancel') || '取消'} > - + +
+ + {t('common.delete') || '删除'} +
+
) - })} -
+ }} + /> )}
) : ( diff --git a/frontend/src/pages/UserList.tsx b/frontend/src/pages/UserList.tsx index 9aa3844..e97bcf0 100644 --- a/frontend/src/pages/UserList.tsx +++ b/frontend/src/pages/UserList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input } from 'antd' -import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, List, Empty, Tooltip, Spin } from 'antd' +import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined, UserOutlined, KeyOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { apiService } from '../services/api' import { useMediaQuery } from 'react-responsive' @@ -219,46 +219,164 @@ const UserList: React.FC = () => { return (
- -
- {t('userList.title') || '用户管理'} - - - - {isDefaultUser && ( +
+ {t('userList.title') || '用户管理'} + + + + {isDefaultUser && ( + + size={isMobile ? 'middle' : 'large'} + style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }} + /> + + )} + +
+ + + {isMobile ? ( + // 移动端卡片布局 +
+ {loading ? ( +
+ +
+ ) : users.length === 0 ? ( + + ) : ( + ( + + {/* 头部区域 - 用户名 */} +
+
+ + {user.username} +
+
+ ID: {user.id} +
+
+ + {/* 角色信息区域 */} +
+
+
+
+ {t('userList.role') || '角色'} +
+ + {user.isDefault ? (t('userList.defaultAccount') || '默认账户') : (t('userList.normalUser') || '普通用户')} + +
+
+
+ {t('common.createdAt') || '创建时间'} +
+
+ {new Date(user.createdAt).toLocaleDateString(i18n.language || 'zh-CN')} +
+
+
+
+ + {/* 操作区域(仅管理员可见) */} + {isDefaultUser && !user.isDefault && ( +
+ +
{ + setSelectedUser(user) + setUpdatePasswordModalVisible(true) + }} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }} + > + + {t('userList.updatePassword') || '改密'} +
+
+ + handleDelete(user)} + okText={t('common.confirm') || '确定'} + cancelText={t('common.cancel') || '取消'} + > + +
+ + {t('common.delete') || '删除'} +
+
+
+
+ )} +
+ )} + /> )} - -
-
t('userList.total', { total }) || `共 ${total} 条` - }} - scroll={isMobile ? { x: 600 } : undefined} - /> + + ) : ( +
t('userList.total', { total }) || `共 ${total} 条` + }} + scroll={isMobile ? { x: 600 } : undefined} + /> + )} {/* 创建用户弹窗 */} From 46e10ebdd8332875755ad9c8ea082ec04df42eaa Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 2 Mar 2026 17:39:23 +0800 Subject: [PATCH 13/26] =?UTF-8?q?feat(crypto-tail):=20=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E4=BB=B7=E5=B7=AE=E7=AD=96=E7=95=A5=E6=94=B6=E7=9B=8A=E6=9B=B2?= =?UTF-8?q?=E7=BA=BF=E4=B8=8E=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 收益曲线 API (pnl-curve)、CryptoTailStrategyService.getPnlCurve、gt 扩展导入 - 前端: CryptoTailPnlCurveModal 弹窗,统计卡片+时间筛选+ECharts 累计收益图 - 策略列表: 桌面/移动端「收益曲线」入口,图标改为蓝色(#1890ff) - 切换时间范围保留旧数据避免图表容器卸载导致空白 - 今日/7天/30天用折线、全部用平滑曲线 - 多语言: viewPnlCurve、pnlCurve.* (zh-CN/zh-TW/en) Made-with: Cursor --- .../CryptoTailStrategyController.kt | 22 ++ .../dto/CryptoTailStrategyDto.kt | 42 +++ .../CryptoTailStrategyTriggerRepository.kt | 12 + .../cryptotail/CryptoTailStrategyService.kt | 56 ++++ frontend/src/locales/en/common.json | 17 ++ frontend/src/locales/zh-CN/common.json | 17 ++ frontend/src/locales/zh-TW/common.json | 17 ++ .../src/pages/CryptoTailPnlCurveModal.tsx | 149 ++++++++++ frontend/src/pages/CryptoTailStrategyList.tsx | 281 +++++++++++++----- frontend/src/services/api.ts | 2 + frontend/src/types/index.ts | 27 ++ 11 files changed, 568 insertions(+), 74 deletions(-) create mode 100644 frontend/src/pages/CryptoTailPnlCurveModal.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 4bc986f..87ea5b5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -15,6 +15,8 @@ import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse +import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest +import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService @@ -130,6 +132,26 @@ class CryptoTailStrategyController( } } + @PostMapping("/pnl-curve") + fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity> { + return try { + if (request.strategyId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)) + } + val result = cryptoTailStrategyService.getPnlCurve(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询收益曲线失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询收益曲线异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource)) + } + } + @PostMapping("/triggers") fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity> { return try { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt index 1ed4c24..7ae88f2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -167,3 +167,45 @@ data class CryptoTailMarketOptionDto( val periodStartUnix: Long = 0L, val endDate: String? = null ) + +/** + * 收益曲线请求 + * @param strategyId 策略ID + * @param startDate 开始时间(毫秒时间戳),null 表示不限制 + * @param endDate 结束时间(毫秒时间戳),null 表示不限制 + */ +data class CryptoTailPnlCurveRequest( + val strategyId: Long = 0L, + val startDate: Long? = null, + val endDate: Long? = null +) + +/** + * 收益曲线单点数据 + */ +data class CryptoTailPnlCurvePoint( + /** 时间点(毫秒时间戳,结算时间或创建时间) */ + val timestamp: Long = 0L, + /** 累计收益 USDC */ + val cumulativePnl: String = "0", + /** 当笔收益 USDC */ + val pointPnl: String = "0", + /** 截至该点累计已结算笔数 */ + val settledCount: Long = 0L +) + +/** + * 收益曲线响应 + */ +data class CryptoTailPnlCurveResponse( + val strategyId: Long = 0L, + val strategyName: String = "", + /** 筛选范围内总已实现收益 USDC */ + val totalRealizedPnl: String = "0", + val settledCount: Long = 0L, + val winCount: Long = 0L, + val winRate: String? = null, + /** 最大回撤 USDC(正数表示回撤幅度) */ + val maxDrawdown: String? = null, + val curveData: List = emptyList() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt index b248ff7..185efa6 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " + + "ORDER BY COALESCE(t.settledAt, t.createdAt) ASC" + ) + fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc( + @Param("strategyId") strategyId: Long, + @Param("start") start: Long, + @Param("end") end: Long + ): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt index aaac15f..c78df33 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -9,6 +9,7 @@ import com.wrbug.polymarketbot.enums.SpreadDirection import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent +import com.wrbug.polymarketbot.util.gt import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher @@ -220,6 +221,61 @@ class CryptoTailStrategyService( } } + fun getPnlCurve(request: CryptoTailPnlCurveRequest): Result { + return try { + val strategy = strategyRepository.findById(request.strategyId).orElse(null) + ?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey)) + val start = request.startDate ?: 0L + val end = request.endDate ?: Long.MAX_VALUE + val triggers = triggerRepository.findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc( + request.strategyId, start, end + ) + var cumulative = BigDecimal.ZERO + var peak = BigDecimal.ZERO + var maxDrawdown = BigDecimal.ZERO + var winCountInRange = 0L + val curveData = triggers.map { t -> + val pnl = t.realizedPnl ?: BigDecimal.ZERO + cumulative = cumulative.add(pnl) + if (cumulative.gt(peak)) peak = cumulative + val drawdown = peak.subtract(cumulative) + if (drawdown.gt(maxDrawdown)) maxDrawdown = drawdown + if (t.winnerOutcomeIndex != null && t.outcomeIndex == t.winnerOutcomeIndex) winCountInRange++ + val ts = t.settledAt ?: t.createdAt + CryptoTailPnlCurvePoint( + timestamp = ts, + cumulativePnl = cumulative.toPlainString(), + pointPnl = pnl.toPlainString(), + settledCount = 0L + ) + }.mapIndexed { index, p -> + p.copy(settledCount = (index + 1).toLong()) + } + val totalPnl = if (curveData.isEmpty()) BigDecimal.ZERO else curveData.last().cumulativePnl.toSafeBigDecimal() + val settledCountInRange = curveData.size.toLong() + val winRateStr = if (settledCountInRange > 0L) { + BigDecimal(winCountInRange).divide(BigDecimal(settledCountInRange), 4, java.math.RoundingMode.HALF_UP).toPlainString() + } else null + Result.success( + CryptoTailPnlCurveResponse( + strategyId = request.strategyId, + strategyName = strategy.name ?: strategy.marketSlugPrefix, + totalRealizedPnl = totalPnl.toPlainString(), + settledCount = settledCountInRange, + winCount = winCountInRange, + winRate = winRateStr, + maxDrawdown = if (maxDrawdown.compareTo(BigDecimal.ZERO) > 0) maxDrawdown.toPlainString() else null, + curveData = curveData + ) + ) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } catch (e: Exception) { + logger.error("查询收益曲线失败: ${e.message}", e) + Result.failure(e) + } + } + fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result { return try { val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100)) diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index fbe3f3a..48fb31b 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1504,7 +1504,9 @@ "strategyName": "Strategy Name", "account": "Account", "market": "Market", + "marketAndTime": "Market / Time", "timeWindow": "Time Window", + "config": "Config", "priceRange": "Price Range", "amountMode": "Amount Mode", "ratio": "Ratio", @@ -1517,6 +1519,7 @@ "disable": "Disable", "delete": "Delete", "viewTriggers": "Orders", + "viewPnlCurve": "PnL Curve", "deleteConfirm": "Delete this strategy?", "fetchFailed": "Failed to fetch list", "configGuide": "Configuration Guide" @@ -1584,6 +1587,20 @@ "emptySuccess": "No success records", "emptyFail": "No failed records", "totalCount": "{count} record(s)" + }, + "pnlCurve": { + "title": "PnL Curve", + "totalPnl": "Total PnL", + "settledCount": "Settled", + "winRate": "Win Rate", + "maxDrawdown": "Max Drawdown", + "timeRange": "Time Range", + "today": "Today", + "last7Days": "Last 7 Days", + "last30Days": "Last 30 Days", + "all": "All", + "customRange": "Custom", + "empty": "No settled orders yet, cannot show PnL curve" } }, "cryptoTailMonitor": { diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index c7f34bd..1509b80 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1504,7 +1504,9 @@ "strategyName": "策略名称", "account": "账户", "market": "关联市场", + "marketAndTime": "市场/时间", "timeWindow": "时间区间", + "config": "配置", "priceRange": "价格区间", "amountMode": "投入方式", "ratio": "比例", @@ -1517,6 +1519,7 @@ "disable": "停用", "delete": "删除", "viewTriggers": "订单", + "viewPnlCurve": "收益曲线", "deleteConfirm": "确定删除该策略?", "fetchFailed": "获取列表失败", "configGuide": "配置指南" @@ -1584,6 +1587,20 @@ "emptySuccess": "暂无成功记录", "emptyFail": "暂无失败记录", "totalCount": "共 {count} 条" + }, + "pnlCurve": { + "title": "收益曲线", + "totalPnl": "总收益", + "settledCount": "已结算笔数", + "winRate": "胜率", + "maxDrawdown": "最大回撤", + "timeRange": "时间范围", + "today": "今日", + "last7Days": "近7天", + "last30Days": "近30天", + "all": "全部", + "customRange": "自定义", + "empty": "暂无已结算订单,无法展示收益曲线" } }, "cryptoTailMonitor": { diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 7b149d0..62fba01 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1504,7 +1504,9 @@ "strategyName": "策略名稱", "account": "賬戶", "market": "關聯市場", + "marketAndTime": "市場/時間", "timeWindow": "時間區間", + "config": "配置", "priceRange": "價格區間", "amountMode": "投入方式", "ratio": "比例", @@ -1517,6 +1519,7 @@ "disable": "停用", "delete": "刪除", "viewTriggers": "訂單", + "viewPnlCurve": "收益曲線", "deleteConfirm": "確定刪除該策略?", "fetchFailed": "獲取列表失敗", "configGuide": "配置指南" @@ -1584,6 +1587,20 @@ "emptySuccess": "暫無成功記錄", "emptyFail": "暫無失敗記錄", "totalCount": "共 {count} 條" + }, + "pnlCurve": { + "title": "收益曲線", + "totalPnl": "總收益", + "settledCount": "已結算筆數", + "winRate": "勝率", + "maxDrawdown": "最大回撤", + "timeRange": "時間範圍", + "today": "今日", + "last7Days": "近7天", + "last30Days": "近30天", + "all": "全部", + "customRange": "自定義", + "empty": "暫無已結算訂單,無法展示收益曲線" } }, "cryptoTailMonitor": { diff --git a/frontend/src/pages/CryptoTailPnlCurveModal.tsx b/frontend/src/pages/CryptoTailPnlCurveModal.tsx new file mode 100644 index 0000000..7732906 --- /dev/null +++ b/frontend/src/pages/CryptoTailPnlCurveModal.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef } from 'react' +import { Modal, Row, Col, Statistic, Button, Space, Empty } from 'antd' +import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import dayjs from 'dayjs' +import * as echarts from 'echarts' +import type { EChartsOption } from 'echarts' +import { formatUSDC } from '../utils' +import type { CryptoTailPnlCurveResponse } from '../types' + +export interface CryptoTailPnlCurveModalProps { + open: boolean + onClose: () => void + data: CryptoTailPnlCurveResponse | null + loading: boolean + strategyName: string + preset: 'today' | '7d' | '30d' | 'all' + onPresetChange: (preset: 'today' | '7d' | '30d' | 'all') => void + onRefresh: () => void +} + +const CryptoTailPnlCurveModal: React.FC = (props) => { + const { open, onClose, data, loading, strategyName, preset, onPresetChange, onRefresh } = props + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const chartRef = useRef(null) + const chartInstance = useRef(null) + + useEffect(() => { + if (!open || !data?.curveData?.length || !chartRef.current) return + if (chartInstance.current) { + const dom = chartInstance.current.getDom() + if (!dom || !document.contains(dom)) { + chartInstance.current.dispose() + chartInstance.current = null + } + } + if (!chartInstance.current) chartInstance.current = echarts.init(chartRef.current) + const option: EChartsOption = { + tooltip: { + trigger: 'axis', + formatter: (params: unknown) => { + const arr = params as Array<{ value: [number, number] }> + const v = arr[0]?.value + if (!v) return '' + const d = data.curveData.find((p) => p.timestamp === v[0]) + if (!d) return '' + return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '
' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC' + } + }, + grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true }, + xAxis: { type: 'time' }, + yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } }, + series: [{ + name: t('cryptoTailStrategy.pnlCurve.totalPnl'), + type: 'line', + data: data.curveData.map((p) => [p.timestamp, parseFloat(p.cumulativePnl)]), + smooth: preset === 'all', + symbol: 'circle', + symbolSize: 4, + lineStyle: { width: 2, color: '#1890ff' }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(24, 144, 255, 0.3)' }, + { offset: 1, color: 'rgba(24, 144, 255, 0.05)' } + ]) + } + }] + } + chartInstance.current.setOption(option, true) + chartInstance.current.resize() + }, [open, data, preset, t]) + + useEffect(() => { + if (!open) { + chartInstance.current?.dispose() + chartInstance.current = null + } + }, [open]) + + useEffect(() => { + if (!open || !chartInstance.current) return + const handleResize = () => chartInstance.current?.resize() + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [open]) + + const pnlColor = (value: string | null | undefined): string | undefined => { + if (value == null || value === '') return undefined + const num = parseFloat(value) + if (Number.isNaN(num)) return undefined + if (num > 0) return '#52c41a' + if (num < 0) return '#ff4d4f' + return undefined + } + + return ( + + +
+ + + + + + + + + + + + + + + + + + + + {!data && loading + ?
{t('common.loading')}
+ : !data?.curveData?.length + ? + :
} + + ) +} + +export default CryptoTailPnlCurveModal diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index f12f5e0..8005cbb 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Card, @@ -25,14 +25,15 @@ import { } from 'antd' import type { Dayjs } from 'dayjs' import dayjs from 'dayjs' -import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons' +import { PlusOutlined, EditOutlined, UnorderedListOutlined, LineChartOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined, DeleteOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import { apiService } from '../services/api' import { useAccountStore } from '../store/accountStore' -import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types' +import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto, CryptoTailPnlCurveResponse } from '../types' import { formatUSDC, formatNumber } from '../utils' import { getVersionInfo } from '../utils/version' +import CryptoTailPnlCurveModal from './CryptoTailPnlCurveModal' const CryptoTailStrategyList: React.FC = () => { const { t, i18n } = useTranslation() @@ -58,6 +59,14 @@ const CryptoTailStrategyList: React.FC = () => { const [triggersLoading, setTriggersLoading] = useState(false) const [form] = Form.useForm() + const [pnlCurveModalOpen, setPnlCurveModalOpen] = useState(false) + const [pnlCurveStrategyId, setPnlCurveStrategyId] = useState(null) + const [pnlCurveStrategyName, setPnlCurveStrategyName] = useState('') + const [pnlCurvePreset, setPnlCurvePreset] = useState<'today' | '7d' | '30d' | 'all'>('all') + const [pnlCurveCustomRange, setPnlCurveCustomRange] = useState<[Dayjs | null, Dayjs | null]>([null, null]) + const [pnlCurveData, setPnlCurveData] = useState(null) + const [pnlCurveLoading, setPnlCurveLoading] = useState(false) + /** 币安 API 健康状态:仅保留「不可用」的项,用于强提醒 */ const [binanceUnhealthy, setBinanceUnhealthy] = useState>([]) const [binanceCheckLoading, setBinanceCheckLoading] = useState(false) @@ -327,6 +336,61 @@ const CryptoTailStrategyList: React.FC = () => { await loadTriggerRecords(strategyId, 'success', { page: 1, pageSize: 20, dateRange: [null, null] }) } + const getPnlCurveTimeRange = (): { startDate?: number; endDate?: number } => { + if (pnlCurvePreset === 'all') return {} + const now = dayjs() + if (pnlCurvePreset === 'today') { + return { startDate: now.startOf('day').valueOf(), endDate: now.valueOf() } + } + if (pnlCurvePreset === '7d') { + return { startDate: now.subtract(7, 'day').startOf('day').valueOf(), endDate: now.valueOf() } + } + if (pnlCurvePreset === '30d') { + return { startDate: now.subtract(30, 'day').startOf('day').valueOf(), endDate: now.valueOf() } + } + if (pnlCurveCustomRange[0] != null && pnlCurveCustomRange[1] != null) { + return { + startDate: pnlCurveCustomRange[0].startOf('day').valueOf(), + endDate: pnlCurveCustomRange[1].endOf('day').valueOf() + } + } + return {} + } + + const loadPnlCurve = async () => { + if (pnlCurveStrategyId == null) return + setPnlCurveLoading(true) + try { + const { startDate, endDate } = getPnlCurveTimeRange() + const res = await apiService.cryptoTailStrategy.pnlCurve({ + strategyId: pnlCurveStrategyId, + startDate, + endDate + }) + if (res.data.code === 0 && res.data.data) { + setPnlCurveData(res.data.data) + } + } catch (e) { + console.error('Failed to load PnL curve:', e) + } finally { + setPnlCurveLoading(false) + } + } + + const openPnlCurve = (record: CryptoTailStrategyDto) => { + setPnlCurveStrategyId(record.id) + setPnlCurveStrategyName(record.name ?? record.marketTitle ?? record.marketSlugPrefix ?? '') + setPnlCurvePreset('all') + setPnlCurveCustomRange([null, null]) + setPnlCurveModalOpen(true) + } + + useEffect(() => { + if (pnlCurveModalOpen && pnlCurveStrategyId != null) { + loadPnlCurve() + } + }, [pnlCurveModalOpen, pnlCurveStrategyId, pnlCurvePreset, pnlCurveCustomRange]) + const onTriggerTabChange = (key: string) => { const next = key === 'success' ? 'success' : 'fail' setTriggerTab(next) @@ -388,7 +452,7 @@ const CryptoTailStrategyList: React.FC = () => { title: t('common.status'), dataIndex: 'enabled', key: 'enabled', - width: 80, + width: 72, render: (enabled: boolean, record: CryptoTailStrategyDto) => ( { title: t('cryptoTailStrategy.list.strategyName'), dataIndex: 'name', key: 'name', - width: isMobile ? 100 : 160, + width: isMobile ? 100 : 140, + ellipsis: true, render: (name: string | undefined, r: CryptoTailStrategyDto) => ( {name || (r.marketTitle ?? r.marketSlugPrefix) || '-'} @@ -412,7 +477,8 @@ const CryptoTailStrategyList: React.FC = () => { title: t('cryptoTailStrategy.list.account'), dataIndex: 'accountId', key: 'accountId', - width: isMobile ? 90 : 120, + width: isMobile ? 90 : 100, + ellipsis: true, render: (_: unknown, r: CryptoTailStrategyDto) => ( {getAccountLabel(r.accountId)} @@ -420,99 +486,149 @@ const CryptoTailStrategyList: React.FC = () => { ) }, { - title: t('cryptoTailStrategy.list.market'), - key: 'market', - width: isMobile ? 120 : 200, - render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'} - - ) - }, - { - title: t('cryptoTailStrategy.list.timeWindow'), - key: 'timeWindow', - width: isMobile ? 100 : 120, - render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds)} - - ) - }, - { - title: t('cryptoTailStrategy.list.priceRange'), - key: 'priceRange', - width: isMobile ? 90 : 120, + title: t('cryptoTailStrategy.list.marketAndTime'), + key: 'marketAndTime', + width: isMobile ? 120 : 180, render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {formatPriceRange(r.minPrice, r.maxPrice)} - +
+ + {marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-'} + + + {formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds, false)} + +
) }, { - title: t('cryptoTailStrategy.list.amountMode'), - key: 'amountMode', - width: isMobile ? 90 : 120, + title: t('cryptoTailStrategy.list.config'), + key: 'config', + width: isMobile ? 100 : 140, render: (_: unknown, r: CryptoTailStrategyDto) => ( - - {(r.amountMode?.toUpperCase() ?? '') === 'RATIO' - ? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%` - : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC`} - +
+ + {formatPriceRange(r.minPrice, r.maxPrice)} + + + {(r.amountMode?.toUpperCase() ?? '') === 'RATIO' + ? `${t('cryptoTailStrategy.list.ratio')} ${formatNumber(r.amountValue, 2) || '0'}%` + : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)}`} + +
) }, { title: t('cryptoTailStrategy.list.totalRealizedPnl'), - key: 'totalRealizedPnl', + key: 'pnl', width: isMobile ? 90 : 120, render: (_: unknown, r: CryptoTailStrategyDto) => { - const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-' + const text = r.totalRealizedPnl != null ? formatUSDC(r.totalRealizedPnl) : '-' const color = pnlColor(r.totalRealizedPnl) - return color ? ( - {text} - ) : ( - {text} + return ( +
+ {color ? ( + {text} + ) : ( + {text} + )} + + {r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'} + +
) } }, - { - title: t('cryptoTailStrategy.list.winRate'), - key: 'winRate', - width: isMobile ? 70 : 90, - render: (_: unknown, r: CryptoTailStrategyDto) => - r.winRate != null ? ( - {(Number(r.winRate) * 100).toFixed(1)}% - ) : ( - - - ) - }, { title: t('cryptoTailStrategy.list.actions'), key: 'actions', - width: isMobile ? 120 : 200, + width: isMobile ? 120 : 140, fixed: 'right' as const, render: (_: unknown, record: CryptoTailStrategyDto) => ( - - - + + +
openEditModal(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} + > + +
+
+ + +
openTriggers(record.id)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} + > + +
+
+ + +
openPnlCurve(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0' }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} + > + +
+
+ handleDelete(record.id)} okText={t('common.confirm')} cancelText={t('common.cancel')} > - + +
{ e.currentTarget.style.backgroundColor = '#fff1f0' }} + onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} + > + +
+
) @@ -688,6 +804,12 @@ const CryptoTailStrategyList: React.FC = () => { {t('cryptoTailStrategy.list.viewTriggers')}
+ +
openPnlCurve(item)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: 'pointer', padding: '4px 8px' }}> + + {t('cryptoTailStrategy.list.viewPnlCurve')} +
+
handleDelete(item.id)} okText={t('common.confirm')} cancelText={t('common.cancel')}>
@@ -707,7 +829,7 @@ const CryptoTailStrategyList: React.FC = () => { columns={columns} dataSource={list} pagination={{ pageSize: 20 }} - scroll={{ x: 900 }} + scroll={{ x: 720 }} /> )} @@ -736,6 +858,17 @@ const CryptoTailStrategyList: React.FC = () => {

{t('cryptoTailStrategy.redeemRequiredModal.description')}

+ setPnlCurveModalOpen(false)} + data={pnlCurveData} + loading={pnlCurveLoading} + strategyName={pnlCurveStrategyName} + preset={pnlCurvePreset} + onPresetChange={setPnlCurvePreset} + onRefresh={loadPnlCurve} + /> + apiClient.post>('/crypto-tail-strategy/triggers', data), + pnlCurve: (data: import('../types').CryptoTailPnlCurveRequest) => + apiClient.post>('/crypto-tail-strategy/pnl-curve', data), marketOptions: () => apiClient.post>('/crypto-tail-strategy/market-options', {}), autoMinSpread: (data: { intervalSeconds: number }) => diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index edbd531..d9f150a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1097,6 +1097,33 @@ export interface CryptoTailStrategyTriggerDto { createdAt: number } +/** 收益曲线请求 */ +export interface CryptoTailPnlCurveRequest { + strategyId: number + startDate?: number + endDate?: number +} + +/** 收益曲线单点 */ +export interface CryptoTailPnlCurvePoint { + timestamp: number + cumulativePnl: string + pointPnl: string + settledCount: number +} + +/** 收益曲线响应 */ +export interface CryptoTailPnlCurveResponse { + strategyId: number + strategyName: string + totalRealizedPnl: string + settledCount: number + winCount: number + winRate: string | null + maxDrawdown: string | null + curveData: CryptoTailPnlCurvePoint[] +} + /** * 加密价差策略市场选项 */ From 97249db546a7a9b41d64f7408a57ccc24c7b7428 Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 2 Mar 2026 17:40:44 +0800 Subject: [PATCH 14/26] =?UTF-8?q?style(copy-trading):=20=E8=B7=9F=E5=8D=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=93=8D=E4=BD=9C=E5=88=97=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=8C=89=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 操作列编辑、统计等改为 Tooltip+图标点击,与策略列表风格统一 - 操作列宽度 200 -> 160 Made-with: Cursor --- frontend/src/pages/CopyTradingList.tsx | 142 ++++++++++++++++--------- 1 file changed, 91 insertions(+), 51 deletions(-) diff --git a/frontend/src/pages/CopyTradingList.tsx b/frontend/src/pages/CopyTradingList.tsx index 58c8c4d..5e204f8 100644 --- a/frontend/src/pages/CopyTradingList.tsx +++ b/frontend/src/pages/CopyTradingList.tsx @@ -297,7 +297,7 @@ const CopyTradingList: React.FC = () => { { title: t('common.actions') || '操作', key: 'action', - width: isMobile ? 100 : 200, + width: isMobile ? 100 : 160, fixed: 'right' as const, render: (_: any, record: CopyTrading) => { const menuItems: MenuProps['items'] = [ @@ -321,61 +321,101 @@ const CopyTradingList: React.FC = () => { } } ] - + return ( - - {!isMobile && ( - <> - - - - )} - - + +
+
- {!isMobile && ( - handleDelete(record.id)} - okText={t('common.confirm') || '确定'} - cancelText={t('common.cancel') || '取消'} - > - - - )} + + + +
) } From 96d224a5a350559b1646c5b2fafae0986239b7d2 Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 2 Mar 2026 17:44:10 +0800 Subject: [PATCH 15/26] =?UTF-8?q?fix(frontend):=20=E7=A7=BB=E9=99=A4=20Cry?= =?UTF-8?q?ptoTailStrategyList=20=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20us?= =?UTF-8?q?eRef=20=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- frontend/src/pages/CryptoTailStrategyList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index 8005cbb..1e37699 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Card, From 9b6cc6315833bee6af633e447f8c2de1c6220647 Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 2 Mar 2026 17:54:03 +0800 Subject: [PATCH 16/26] =?UTF-8?q?refactor(frontend):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=93=E4=BD=8D=E7=AE=A1=E7=90=86=E5=88=97=E8=A1=A8=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=88=97=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将12列精简为8列: - 合并"数量"和"平均价格"为"持仓"列 - 合并"当前价格"、"当前价值"、"盈亏"、"已实现盈亏"为"当前价值/盈亏"列 - 减少列宽度,提升表格紧凑性 Made-with: Cursor --- frontend/src/pages/PositionList.tsx | 168 +++++++++------------------- 1 file changed, 54 insertions(+), 114 deletions(-) diff --git a/frontend/src/pages/PositionList.tsx b/frontend/src/pages/PositionList.tsx index 4d31ac5..5a9748f 100644 --- a/frontend/src/pages/PositionList.tsx +++ b/frontend/src/pages/PositionList.tsx @@ -833,7 +833,7 @@ const PositionList: React.FC = () => { ) } - // 根据仓位类型动态生成列(历史仓位不显示当前价格、当前价值、状态列) + // 根据仓位类型动态生成列(优化后的紧凑布局) const columns = useMemo(() => { const baseColumns: any[] = [ { @@ -853,7 +853,6 @@ const PositionList: React.FC = () => { objectFit: 'cover' }} onError={(e) => { - // 图片加载失败时隐藏 e.currentTarget.style.display = 'none' }} /> @@ -876,7 +875,7 @@ const PositionList: React.FC = () => { ), fixed: isMobile ? ('left' as const) : undefined, - width: isMobile ? 150 : 200 + width: isMobile ? 120 : 160 }, { title: '市场', @@ -924,7 +923,7 @@ const PositionList: React.FC = () => { ) }, - width: isMobile ? 200 : 250 + width: isMobile ? 180 : 220 }, { title: '方向', @@ -933,136 +932,77 @@ const PositionList: React.FC = () => { render: (side: string) => ( {side} ), - width: 80 + width: 70 }, { - title: '数量', - dataIndex: 'quantity', - key: 'quantity', - render: (quantity: string) => formatNumber(quantity, 4), + title: '持仓', + key: 'position', + render: (_: any, record: AccountPosition) => ( +
+
{formatNumber(record.quantity, 4)}
+
@{formatNumber(record.avgPrice, 4)}
+
+ ), align: 'right' as const, width: 100 }, - { - title: '平均价格', - dataIndex: 'avgPrice', - key: 'avgPrice', - render: (price: string) => formatNumber(price, 4), - align: 'right' as const, - width: 120 - }, { title: '开仓价值', dataIndex: 'initialValue', key: 'initialValue', render: (value: string) => ( - - {formatUSDC(value)} USDC - + {formatUSDC(value)} USDC ), align: 'right' as const, - width: 120 + width: 110 }, ] - // 只有当前仓位才显示当前价格和当前价值列 + // 只有当前仓位才显示当前价值/盈亏合并列 if (positionFilter === 'current') { - baseColumns.push( - { - title: '当前价格', - dataIndex: 'currentPrice', - key: 'currentPrice', - render: (price: string) => formatNumber(price, 4), - align: 'right' as const, - width: 120 - }, - { - title: '当前价值', - dataIndex: 'currentValue', - key: 'currentValue', - render: (value: string) => ( - - {formatUSDC(value)} USDC - - ), - align: 'right' as const, - width: 120, - sorter: (a: AccountPosition, b: AccountPosition) => { - const valA = parseFloat(a.currentValue || '0') - const valB = parseFloat(b.currentValue || '0') - return valA - valB - }, - defaultSortOrder: 'descend' as const - } - ) - } + baseColumns.push({ + title: '当前价值 / 盈亏', + key: 'valueAndPnl', + render: (_: any, record: AccountPosition) => { + const pnlNum = parseFloat(record.pnl || '0') + const percentPnl = parseFloat(record.percentPnl || '0') + const realizedPnl = record.realizedPnl ? parseFloat(record.realizedPnl) : null + const percentRealizedPnl = record.percentRealizedPnl ? parseFloat(record.percentRealizedPnl) : null - // 只有当前仓位才显示盈亏和已实现盈亏列 - if (positionFilter === 'current') { - baseColumns.push( - { - title: '盈亏', - dataIndex: 'pnl', - key: 'pnl', - render: (pnl: string, record: AccountPosition) => { - const pnlNum = parseFloat(pnl || '0') - const percentPnl = parseFloat(record.percentPnl || '0') - return ( -
-
= 0 ? '#3f8600' : '#cf1322', - fontWeight: 'bold' - }}> - {pnlNum >= 0 ? '+' : ''}{formatUSDC(pnl)} USDC -
-
= 0 ? '#3f8600' : '#cf1322' - }}> - {formatPercent(record.percentPnl)} -
+ return ( +
+
+ {formatUSDC(record.currentValue)} USDC
- ) - }, - align: 'right' as const, - width: 150, - sorter: (a: AccountPosition, b: AccountPosition) => { - const pnlA = parseFloat(a.pnl || '0') - const pnlB = parseFloat(b.pnl || '0') - return pnlA - pnlB - } - }, - { - title: '已实现盈亏', - dataIndex: 'realizedPnl', - key: 'realizedPnl', - render: (realizedPnl: string | undefined, record: AccountPosition) => { - if (!realizedPnl) return '-' - const pnlNum = parseFloat(realizedPnl) - const percentPnl = parseFloat(record.percentRealizedPnl || '0') - return ( -
+
= 0 ? '#3f8600' : '#cf1322', + fontWeight: '500' + }}> + {pnlNum >= 0 ? '+' : ''}{formatUSDC(record.pnl)} ({formatPercent(record.percentPnl)}) +
+ {realizedPnl !== null && (
= 0 ? '#3f8600' : '#cf1322', - fontWeight: 'bold' + fontSize: '11px', + color: '#999', + marginTop: '2px' }}> - {pnlNum >= 0 ? '+' : ''}{formatUSDC(realizedPnl)} USDC + 已实现: {realizedPnl >= 0 ? '+' : ''}{formatUSDC(record.realizedPnl)} + {percentRealizedPnl !== null && ` (${formatPercent(record.percentRealizedPnl)})`}
- {record.percentRealizedPnl && ( -
= 0 ? '#3f8600' : '#cf1322' - }}> - {formatPercent(record.percentRealizedPnl)} -
- )} -
- ) - }, - align: 'right' as const, - width: 150 - } - ) + )} +
+ ) + }, + align: 'right' as const, + width: 160, + sorter: (a: AccountPosition, b: AccountPosition) => { + const valA = parseFloat(a.currentValue || '0') + const valB = parseFloat(b.currentValue || '0') + return valA - valB + }, + defaultSortOrder: 'descend' as const + }) } // 只有当前仓位才显示操作列 @@ -1084,7 +1024,7 @@ const PositionList: React.FC = () => { )} ), - width: 150, + width: 80, fixed: isMobile ? ('right' as const) : undefined }) } From 83bc2094899a8f4ce3b0bc6d3258969bcfc7c43f Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 2 Mar 2026 17:58:36 +0800 Subject: [PATCH 17/26] refactor(ci): simplify docker-build workflow and update TG notifications - Remove package-only build type, unify to single build flow - Add TG notification after package upload (before Docker build) - Add TG notification after Docker image push - Update notification messages to English - Rename "Tag" to "Version" in notifications Made-with: Cursor --- .github/workflows/docker-build.yml | 151 ++++++++++------------------- 1 file changed, 53 insertions(+), 98 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index df00230..8211e3b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -6,14 +6,6 @@ on: - published # 当通过 GitHub Releases 页面创建 release 时触发 workflow_dispatch: inputs: - build_type: - description: '构建类型' - required: true - type: choice - options: - - package-only # 只打包产物 - - package-and-docker # 打包产物 + Docker 镜像 - default: 'package-and-docker' version: description: '版本号(例如: v1.0.0)' required: false @@ -36,22 +28,6 @@ jobs: with: ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }} - - name: Determine build type - id: build_config - run: | - # 确定构建类型 - if [ "${{ github.event_name }}" = "release" ]; then - # Release 事件:默认只打包产物(不构建 Docker) - BUILD_TYPE="package-only" - echo "📦 Release 事件:将只打包产物(不构建 Docker)" - else - # workflow_dispatch 事件:使用用户输入 - BUILD_TYPE="${{ github.event.inputs.build_type }}" - echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}" - fi - - echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT - - name: Extract version and check if pre-release id: extract_version run: | @@ -100,53 +76,6 @@ jobs: echo "📦 这是正式版本: $TAG_NAME" fi - - name: Send Telegram notification (build started) - if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker' - env: - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - run: | - # 检查必要的环境变量 - if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then - echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知" - exit 0 - fi - - # 获取构建信息 - TAG="${{ steps.extract_version.outputs.TAG }}" - - if [ "${{ github.event_name }}" = "release" ]; then - RELEASE_URL="${{ github.event.release.html_url }}" - MESSAGE="🔨 Release 构建中"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Release" - else - WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - MESSAGE="🔨 构建中"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Workflow" - fi - - # 发送 Telegram 消息(使用 jq 转义 JSON) - curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg chat_id "$TELEGRAM_CHAT_ID" \ - --arg text "$MESSAGE" \ - '{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json - - # 检查发送结果 - if [ $? -eq 0 ]; then - RESPONSE=$(cat /tmp/telegram_response.json) - if echo "$RESPONSE" | grep -q '"ok":true'; then - echo "✅ Telegram 通知发送成功" - else - echo "❌ Telegram 通知发送失败: $RESPONSE" - # 通知失败不应该导致整个 job 失败 - exit 0 - fi - else - echo "❌ 发送 Telegram 消息时发生错误" - # 通知失败不应该导致整个 job 失败 - exit 0 - fi - # ============ 编译前后端产物 ============ - name: Setup JDK 17 uses: actions/setup-java@v4 @@ -263,22 +192,66 @@ jobs: checksums.txt retention-days: 30 + # ============ 发送产物上传成功通知 ============ + - name: Send Telegram notification (package uploaded) + if: steps.extract_version.outputs.IS_PRERELEASE == 'false' + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + # 检查必要的环境变量 + if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then + echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知" + exit 0 + fi + + # 获取构建信息 + TAG="${{ steps.extract_version.outputs.TAG }}" + + if [ "${{ github.event_name }}" = "release" ]; then + RELEASE_URL="${{ github.event.release.html_url }}" + MESSAGE="✅ PolyHermes Package Built"$'\n'$'\n'"🏷️ Version: ${TAG}"$'\n'"🔧 Type: Online Update"$'\n'"🔗 View Release"$'\n'"📍 Update Path: System Management → Overview → Check for Updates"$'\n'$'\n'"🐳 Building Docker image..." + else + WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MESSAGE="✅ PolyHermes Package Built"$'\n'$'\n'"🏷️ Version: ${TAG}"$'\n'"🔧 Type: Online Update"$'\n'"🔗 View Workflow"$'\n'$'\n'"🐳 Building Docker image..." + fi + + # 发送 Telegram 消息(使用 jq 转义 JSON) + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg chat_id "$TELEGRAM_CHAT_ID" \ + --arg text "$MESSAGE" \ + '{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json + + # 检查发送结果 + if [ $? -eq 0 ]; then + RESPONSE=$(cat /tmp/telegram_response.json) + if echo "$RESPONSE" | grep -q '"ok":true'; then + echo "✅ Telegram 通知发送成功" + else + echo "❌ Telegram 通知发送失败: $RESPONSE" + exit 0 + fi + else + echo "❌ 发送 Telegram 消息时发生错误" + exit 0 + fi + + # ============ Docker 构建 ============ - name: Set up Docker Buildx - if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker' uses: docker/setup-buildx-action@v3 with: # 启用多架构构建支持 platforms: linux/amd64,linux/arm64 - name: Log in to Docker Hub - if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Prepare Docker build context - if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker' run: | echo "📦 准备 Docker 构建上下文..." # 确保构建产物存在且可访问 @@ -295,7 +268,7 @@ jobs: ls -lh backend/build/libs/*.jar - name: Build and push Docker image - if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker' + id: docker_build uses: docker/build-push-action@v5 with: context: . @@ -314,13 +287,8 @@ jobs: cache-from: type=registry,ref=wrbug/polyhermes:latest cache-to: type=inline - - name: Skip Docker build notice - if: steps.build_config.outputs.BUILD_TYPE == 'package-only' - run: | - echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only)" - echo "✅ 仅打包产物已完成" - - - name: Send Telegram notification + # ============ 发送 Docker 构建成功通知 ============ + - name: Send Telegram notification (Docker build completed) if: steps.extract_version.outputs.IS_PRERELEASE == 'false' env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} @@ -335,25 +303,14 @@ jobs: # 获取构建信息 VERSION="${{ steps.extract_version.outputs.VERSION }}" TAG="${{ steps.extract_version.outputs.TAG }}" - BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}" - - # 构建消息内容(仅包含关键信息) DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md" if [ "${{ github.event_name }}" = "release" ]; then RELEASE_URL="${{ github.event.release.html_url }}" - if [ "$BUILD_TYPE" = "package-and-docker" ]; then - MESSAGE="✅ Release 构建成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Release"$'\n'"📚 Docker 部署文档" - else - MESSAGE="✅ Release 打包成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: 在线升级"$'\n'"🔗 查看 Release"$'\n'"📍 升级路径: 系统管理 → 概览 → 检查更新" - fi + MESSAGE="🐳 Docker Image Built Successfully"$'\n'$'\n'"🏷️ Version: ${TAG}"$'\n'"📦 Image: wrbug/polyhermes:${TAG}"$'\n'"🔗 View Release"$'\n'"📚 Docker Deployment Guide" else WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - if [ "$BUILD_TYPE" = "package-and-docker" ]; then - MESSAGE="✅ 构建成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: Docker 升级"$'\n'"🔗 查看 Workflow"$'\n'"📚 Docker 部署文档" - else - MESSAGE="✅ 打包成功"$'\n'$'\n'"🏷️ Tag: ${TAG}"$'\n'"🔧 构建类型: 在线升级"$'\n'"🔗 查看 Workflow"$'\n'"📍 升级路径: 系统管理 → 概览 → 检查更新" - fi + MESSAGE="🐳 Docker Image Built Successfully"$'\n'$'\n'"🏷️ Version: ${TAG}"$'\n'"📦 Image: wrbug/polyhermes:${TAG}"$'\n'"🔗 View Workflow"$'\n'"📚 Docker Deployment Guide" fi # 发送 Telegram 消息(使用 jq 转义 JSON) @@ -371,11 +328,9 @@ jobs: echo "✅ Telegram 通知发送成功" else echo "❌ Telegram 通知发送失败: $RESPONSE" - # 构建成功,通知失败不应该导致整个 job 失败 exit 0 fi else echo "❌ 发送 Telegram 消息时发生错误" - # 构建成功,通知失败不应该导致整个 job 失败 exit 0 - fi \ No newline at end of file + fi From e7af4d4821171338b24000419cfcfc5a7f66bff0 Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 2 Mar 2026 21:31:45 +0800 Subject: [PATCH 18/26] =?UTF-8?q?feat:=20=E6=B6=88=E6=81=AF=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1=E6=9D=BF=E4=B8=8E?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增通知模板表与实体、DTO、Repository、NotificationTemplateService - 支持 {{variable}} 模板语法,提供模板类型与变量接口 - TelegramNotificationService 改为通过模板渲染发送(ORDER_SUCCESS/ORDER_FAILED/ORDER_FILTERED/CRYPTO_TAIL_SUCCESS/REDEEM_SUCCESS/REDEEM_NO_RETURN) - 模板 CRUD、重置默认、测试发送接口 - 补全订单过滤相关 i18n key(zh/en/zh-TW) 前端: - 消息推送设置抽离为独立页 /system-settings/notification - 系统设置概览改为入口卡片,侧栏增加「消息推送设置」菜单 - NotificationSettingsPage: 机器人配置 + 模板配置双卡片布局(与概览一致) - 模板配置支持选择类型、编辑内容、变量面板(点击复制、悬停说明)、保存/重置/测试 - 多语言 key 补全(notificationSettings.templates.*、templateTypes.*) Made-with: Cursor --- .../system/NotificationController.kt | 166 ++++ .../dto/NotificationTemplateDto.kt | 67 ++ .../entity/NotificationTemplate.kt | 30 + .../NotificationTemplateRepository.kt | 11 + .../system/NotificationTemplateService.kt | 430 +++++++++++ .../system/TelegramNotificationService.kt | 337 ++++++++- .../V40__create_notification_templates.sql | 97 +++ .../resources/i18n/messages_en.properties | 8 + .../resources/i18n/messages_zh_CN.properties | 8 + .../resources/i18n/messages_zh_TW.properties | 8 + frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 5 + frontend/src/locales/en/common.json | 37 +- frontend/src/locales/zh-CN/common.json | 37 +- frontend/src/locales/zh-TW/common.json | 37 +- .../src/pages/NotificationSettingsPage.tsx | 708 ++++++++++++++++++ frontend/src/pages/SystemSettings.tsx | 336 +-------- frontend/src/services/api.ts | 48 +- frontend/src/types/index.ts | 53 ++ 19 files changed, 2087 insertions(+), 338 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationTemplateRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt create mode 100644 backend/src/main/resources/db/migration/V40__create_notification_templates.sql create mode 100644 frontend/src/pages/NotificationSettingsPage.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt index 0f42d28..03ee5ea 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/system/NotificationController.kt @@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.system.NotificationConfigService +import com.wrbug.polymarketbot.service.system.NotificationTemplateService import com.wrbug.polymarketbot.service.system.TelegramNotificationService import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.* class NotificationController( private val notificationConfigService: NotificationConfigService, private val telegramNotificationService: TelegramNotificationService, + private val notificationTemplateService: NotificationTemplateService, private val messageSource: MessageSource ) { @@ -335,6 +337,155 @@ class NotificationController( )) } } + + // ==================== 模板相关 API ==================== + + /** + * 获取所有模板类型 + */ + @PostMapping("/templates/types") + fun getTemplateTypes(): ResponseEntity>> { + return try { + val types = notificationTemplateService.getTemplateTypes() + ResponseEntity.ok(ApiResponse.success(types)) + } catch (e: Exception) { + logger.error("获取模板类型失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 获取所有模板 + */ + @PostMapping("/templates/list") + fun getTemplates(): ResponseEntity>> { + return try { + val templates = notificationTemplateService.getAllTemplates() + ResponseEntity.ok(ApiResponse.success(templates)) + } catch (e: Exception) { + logger.error("获取模板列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 获取单个模板 + */ + @PostMapping("/templates/detail") + fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val template = notificationTemplateService.getTemplate(request.templateType) + if (template == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(template)) + } + } catch (e: Exception) { + logger.error("获取模板详情失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 获取模板可用变量 + */ + @PostMapping("/templates/variables") + fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val variables = notificationTemplateService.getTemplateVariables(request.templateType) + if (variables == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(variables)) + } + } catch (e: Exception) { + logger.error("获取模板变量失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 更新模板 + */ + @PostMapping("/templates/update") + fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + if (request.templateContent.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空")) + } + + val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent) + ResponseEntity.ok(ApiResponse.success(template)) + } catch (e: Exception) { + logger.error("更新模板失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 重置模板为默认 + */ + @PostMapping("/templates/reset") + fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val template = notificationTemplateService.resetTemplate(request.templateType) + if (template == null) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource)) + } else { + ResponseEntity.ok(ApiResponse.success(template)) + } + } catch (e: Exception) { + logger.error("重置模板失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource)) + } + } + + /** + * 发送模板测试消息 + */ + @PostMapping("/templates/test") + fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity> { + return try { + if (request.templateType.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空")) + } + + val success = runBlocking { + notificationTemplateService.sendTestMessage(request.templateType, request.templateContent) + } + + if (success) { + ResponseEntity.ok(ApiResponse.success(true)) + } else { + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_TEST_FAILED, + messageSource = messageSource + )) + } + } catch (e: Exception) { + logger.error("发送模板测试消息失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error( + ErrorCode.NOTIFICATION_TEST_FAILED, + customMsg = "发送测试消息失败:${e.message}", + messageSource = messageSource + )) + } + } } /** @@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest( val id: Long ) +/** + * 模板详情请求 + */ +data class TemplateDetailRequest( + val templateType: String +) + +/** + * 更新模板请求(带类型) + */ +data class UpdateTemplateRequestWithId( + val templateType: String, + val templateContent: String +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt new file mode 100644 index 0000000..9e1c42b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/NotificationTemplateDto.kt @@ -0,0 +1,67 @@ +package com.wrbug.polymarketbot.dto + +/** + * 消息模板 DTO + */ +data class NotificationTemplateDto( + val id: Long? = null, + val templateType: String, // 模板类型 + val templateContent: String, // 模板内容 + val isDefault: Boolean = false, // 是否使用默认模板 + val createdAt: Long? = null, + val updatedAt: Long? = null +) + +/** + * 模板变量 DTO + */ +data class TemplateVariableDto( + val key: String, // 变量名,如 account_name + val label: String, // 显示名称,如 账户名称 + val description: String, // 变量说明 + val category: String, // 分类:common, order, copy_trading, redeem, error + val sortOrder: Int = 0 // 排序顺序 +) + +/** + * 模板变量分类 DTO + */ +data class TemplateVariableCategoryDto( + val key: String, // 分类 key + val label: String, // 分类名称 + val sortOrder: Int = 0 // 排序顺序 +) + +/** + * 模板变量列表响应 + */ +data class TemplateVariablesResponse( + val templateType: String, // 模板类型 + val templateTypeName: String, // 模板类型名称 + val categories: List, // 分类列表 + val variables: List // 变量列表 +) + +/** + * 更新模板请求 + */ +data class UpdateTemplateRequest( + val templateContent: String // 模板内容 +) + +/** + * 测试模板请求 + */ +data class TestTemplateRequest( + val templateType: String, // 模板类型 + val templateContent: String? = null // 可选,如果不提供则使用已保存的模板 +) + +/** + * 模板类型信息 + */ +data class TemplateTypeInfoDto( + val type: String, // 模板类型 + val name: String, // 类型名称 + val description: String // 类型描述 +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt new file mode 100644 index 0000000..1681b3f --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/NotificationTemplate.kt @@ -0,0 +1,30 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 消息推送模板实体 + * 用于存储用户自定义的消息模板 + */ +@Entity +@Table(name = "notification_templates") +data class NotificationTemplate( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "template_type", unique = true, nullable = false, length = 50) + val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN + + @Column(name = "template_content", nullable = false, columnDefinition = "TEXT") + var templateContent: String, // 模板内容,支持 {{variable}} 变量 + + @Column(name = "is_default", nullable = false) + var 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/NotificationTemplateRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationTemplateRepository.kt new file mode 100644 index 0000000..00b1b4f --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/NotificationTemplateRepository.kt @@ -0,0 +1,11 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.NotificationTemplate +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface NotificationTemplateRepository : JpaRepository { + fun findByTemplateType(templateType: String): NotificationTemplate? + fun existsByTemplateType(templateType: String): Boolean +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt new file mode 100644 index 0000000..caf9225 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt @@ -0,0 +1,430 @@ +package com.wrbug.polymarketbot.service.system + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.NotificationTemplate +import com.wrbug.polymarketbot.repository.NotificationTemplateRepository +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 消息模板服务 + * 负责管理消息模板、渲染模板、提供变量信息 + */ +@Service +class NotificationTemplateService( + private val templateRepository: NotificationTemplateRepository, + @Lazy private val telegramNotificationService: TelegramNotificationService +) { + private val logger = LoggerFactory.getLogger(NotificationTemplateService::class.java) + + companion object { + // 模板类型定义 + val TEMPLATE_TYPES = mapOf( + "ORDER_SUCCESS" to TemplateTypeInfoDto( + type = "ORDER_SUCCESS", + name = "订单成功通知", + description = "订单创建成功时发送的通知" + ), + "ORDER_FAILED" to TemplateTypeInfoDto( + type = "ORDER_FAILED", + name = "订单失败通知", + description = "订单创建失败时发送的通知" + ), + "ORDER_FILTERED" to TemplateTypeInfoDto( + type = "ORDER_FILTERED", + name = "订单过滤通知", + description = "订单被风控过滤时发送的通知" + ), + "CRYPTO_TAIL_SUCCESS" to TemplateTypeInfoDto( + type = "CRYPTO_TAIL_SUCCESS", + name = "加密价差策略成功通知", + description = "加密价差策略下单成功时发送的通知" + ), + "REDEEM_SUCCESS" to TemplateTypeInfoDto( + type = "REDEEM_SUCCESS", + name = "仓位赎回成功通知", + description = "仓位赎回成功时发送的通知" + ), + "REDEEM_NO_RETURN" to TemplateTypeInfoDto( + type = "REDEEM_NO_RETURN", + name = "仓位结算(无收益)通知", + description = "仓位结算但无收益时发送的通知" + ) + ) + + // 变量分类 + val VARIABLE_CATEGORIES = listOf( + TemplateVariableCategoryDto("common", "通用变量", 0), + TemplateVariableCategoryDto("order", "订单变量", 10), + TemplateVariableCategoryDto("copy_trading", "跟单变量", 20), + TemplateVariableCategoryDto("redeem", "赎回变量", 30), + TemplateVariableCategoryDto("error", "错误变量", 40), + TemplateVariableCategoryDto("filter", "过滤变量", 50), + TemplateVariableCategoryDto("strategy", "策略变量", 60) + ) + + // 各模板类型可用的变量 + val TEMPLATE_VARIABLES = mapOf( + "ORDER_SUCCESS" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "账户名称", "执行订单的账户名称", "common", 1), + TemplateVariableDto("wallet_address", "钱包地址", "钱包地址(已脱敏)", "common", 2), + TemplateVariableDto("time", "时间", "通知发送时间", "common", 3), + // 订单变量 + TemplateVariableDto("order_id", "订单ID", "订单唯一标识", "order", 10), + TemplateVariableDto("market_title", "市场标题", "市场/事件名称", "order", 11), + TemplateVariableDto("market_link", "市场链接", "Polymarket 市场链接", "order", 12), + TemplateVariableDto("side", "方向", "订单方向(买入/卖出)", "order", 13), + TemplateVariableDto("outcome", "市场方向", "市场方向(YES/NO 等)", "order", 14), + TemplateVariableDto("price", "价格", "订单价格", "order", 15), + TemplateVariableDto("quantity", "数量", "订单数量(shares)", "order", 16), + TemplateVariableDto("amount", "金额", "订单金额(USDC)", "order", 17), + TemplateVariableDto("available_balance", "可用余额", "账户可用余额(USDC)", "order", 18), + // 跟单变量 + TemplateVariableDto("leader_name", "Leader 名称", "跟单的 Leader 名称/备注", "copy_trading", 21), + TemplateVariableDto("config_name", "跟单配置名", "跟单配置名称", "copy_trading", 22) + ), + "ORDER_FAILED" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "账户名称", "执行订单的账户名称", "common", 1), + TemplateVariableDto("wallet_address", "钱包地址", "钱包地址(已脱敏)", "common", 2), + TemplateVariableDto("time", "时间", "通知发送时间", "common", 3), + // 订单变量 + TemplateVariableDto("market_title", "市场标题", "市场/事件名称", "order", 10), + TemplateVariableDto("market_link", "市场链接", "Polymarket 市场链接", "order", 11), + TemplateVariableDto("side", "方向", "订单方向(买入/卖出)", "order", 12), + TemplateVariableDto("outcome", "市场方向", "市场方向(YES/NO 等)", "order", 13), + TemplateVariableDto("price", "价格", "订单价格", "order", 14), + TemplateVariableDto("quantity", "数量", "订单数量(shares)", "order", 15), + TemplateVariableDto("amount", "金额", "订单金额(USDC)", "order", 16), + // 错误变量 + TemplateVariableDto("error_message", "错误信息", "订单失败原因", "error", 20) + ), + "ORDER_FILTERED" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "账户名称", "执行订单的账户名称", "common", 1), + TemplateVariableDto("wallet_address", "钱包地址", "钱包地址(已脱敏)", "common", 2), + TemplateVariableDto("time", "时间", "通知发送时间", "common", 3), + // 订单变量 + TemplateVariableDto("market_title", "市场标题", "市场/事件名称", "order", 10), + TemplateVariableDto("market_link", "市场链接", "Polymarket 市场链接", "order", 11), + TemplateVariableDto("side", "方向", "订单方向(买入/卖出)", "order", 12), + TemplateVariableDto("outcome", "市场方向", "市场方向(YES/NO 等)", "order", 13), + TemplateVariableDto("price", "价格", "订单价格", "order", 14), + TemplateVariableDto("quantity", "数量", "订单数量(shares)", "order", 15), + TemplateVariableDto("amount", "金额", "订单金额(USDC)", "order", 16), + // 过滤变量 + TemplateVariableDto("filter_type", "过滤类型", "订单被过滤的类型", "filter", 20), + TemplateVariableDto("filter_reason", "过滤原因", "订单被过滤的详细原因", "filter", 21) + ), + "CRYPTO_TAIL_SUCCESS" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "账户名称", "执行订单的账户名称", "common", 1), + TemplateVariableDto("wallet_address", "钱包地址", "钱包地址(已脱敏)", "common", 2), + TemplateVariableDto("time", "时间", "通知发送时间", "common", 3), + // 订单变量 + TemplateVariableDto("order_id", "订单ID", "订单唯一标识", "order", 10), + TemplateVariableDto("market_title", "市场标题", "市场/事件名称", "order", 11), + TemplateVariableDto("market_link", "市场链接", "Polymarket 市场链接", "order", 12), + TemplateVariableDto("side", "方向", "订单方向(买入/卖出)", "order", 13), + TemplateVariableDto("outcome", "市场方向", "市场方向(YES/NO 等)", "order", 14), + TemplateVariableDto("price", "价格", "订单价格", "order", 15), + TemplateVariableDto("quantity", "数量", "订单数量(shares)", "order", 16), + TemplateVariableDto("amount", "金额", "订单金额(USDC)", "order", 17), + // 策略变量 + TemplateVariableDto("strategy_name", "策略名称", "加密价差策略名称", "strategy", 20) + ), + "REDEEM_SUCCESS" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "账户名称", "执行赎回的账户名称", "common", 1), + TemplateVariableDto("wallet_address", "钱包地址", "钱包地址(已脱敏)", "common", 2), + TemplateVariableDto("time", "时间", "通知发送时间", "common", 3), + // 赎回变量 + TemplateVariableDto("transaction_hash", "交易哈希", "赎回交易的哈希值", "redeem", 10), + TemplateVariableDto("total_value", "赎回总价值", "赎回的总价值(USDC)", "redeem", 11), + TemplateVariableDto("available_balance", "可用余额", "账户可用余额(USDC)", "redeem", 12) + ), + "REDEEM_NO_RETURN" to listOf( + // 通用变量 + TemplateVariableDto("account_name", "账户名称", "执行赎回的账户名称", "common", 1), + TemplateVariableDto("wallet_address", "钱包地址", "钱包地址(已脱敏)", "common", 2), + TemplateVariableDto("time", "时间", "通知发送时间", "common", 3), + // 赎回变量 + TemplateVariableDto("transaction_hash", "交易哈希", "赎回交易的哈希值", "redeem", 10), + TemplateVariableDto("available_balance", "可用余额", "账户可用余额(USDC)", "redeem", 11) + ) + ) + + // 默认模板 + val DEFAULT_TEMPLATES = mapOf( + "ORDER_SUCCESS" to """ +🚀 订单创建成功 + +📊 订单信息: +• 订单ID: {{order_id}} +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} +• 可用余额: {{available_balance}} USDC + +⏰ 时间: {{time}} + """.trimIndent(), + "ORDER_FAILED" to """ +❌ 订单创建失败 + +📊 订单信息: +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} + +⚠️ 错误信息: +{{error_message}} + +⏰ 时间: {{time}} + """.trimIndent(), + "ORDER_FILTERED" to """ +🚫 订单被过滤 + +📊 订单信息: +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} + +⚠️ 过滤类型: {{filter_type}} + +📝 过滤原因: +{{filter_reason}} + +⏰ 时间: {{time}} + """.trimIndent(), + "CRYPTO_TAIL_SUCCESS" to """ +🚀 加密价差策略下单成功 + +📊 订单信息: +• 订单ID: {{order_id}} +• 策略: {{strategy_name}} +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} + +⏰ 时间: {{time}} + """.trimIndent(), + "REDEEM_SUCCESS" to """ +💸 仓位赎回成功 + +📊 赎回信息: +• 账户: {{account_name}} +• 交易哈希: {{transaction_hash}} +• 赎回总价值: {{total_value}} USDC +• 可用余额: {{available_balance}} USDC + +⏰ 时间: {{time}} + """.trimIndent(), + "REDEEM_NO_RETURN" to """ +📋 仓位已结算(无收益) + +📊 结算信息: +市场已结算,您的预测未命中,赎回价值为 0。 + +• 账户: {{account_name}} +• 交易哈希: {{transaction_hash}} +• 可用余额: {{available_balance}} USDC + +⏰ 时间: {{time}} + """.trimIndent() + ) + } + + /** + * 获取所有模板类型 + */ + fun getTemplateTypes(): List { + return TEMPLATE_TYPES.values.toList() + } + + /** + * 获取所有模板列表 + */ + fun getAllTemplates(): List { + return templateRepository.findAll().map { it.toDto() } + } + + /** + * 获取单个模板 + */ + fun getTemplate(templateType: String): NotificationTemplateDto? { + return templateRepository.findByTemplateType(templateType)?.toDto() + ?: DEFAULT_TEMPLATES[templateType]?.let { + NotificationTemplateDto( + templateType = templateType, + templateContent = it, + isDefault = true + ) + } + } + + /** + * 获取模板可用变量 + */ + fun getTemplateVariables(templateType: String): TemplateVariablesResponse? { + val typeInfo = TEMPLATE_TYPES[templateType] ?: return null + val variables = TEMPLATE_VARIABLES[templateType] ?: emptyList() + + // 获取使用的分类 + val usedCategories = variables.map { it.category }.toSet() + val categories = VARIABLE_CATEGORIES.filter { usedCategories.contains(it.key) } + + return TemplateVariablesResponse( + templateType = templateType, + templateTypeName = typeInfo.name, + categories = categories, + variables = variables + ) + } + + /** + * 更新模板 + */ + @Transactional + fun updateTemplate(templateType: String, content: String): NotificationTemplateDto { + val template = templateRepository.findByTemplateType(templateType) + val now = System.currentTimeMillis() + + return if (template != null) { + template.templateContent = content + template.isDefault = false + template.updatedAt = now + templateRepository.save(template).toDto() + } else { + val newTemplate = NotificationTemplate( + templateType = templateType, + templateContent = content, + isDefault = false, + createdAt = now, + updatedAt = now + ) + templateRepository.save(newTemplate).toDto() + } + } + + /** + * 重置模板为默认 + */ + @Transactional + fun resetTemplate(templateType: String): NotificationTemplateDto? { + val defaultContent = DEFAULT_TEMPLATES[templateType] ?: return null + val template = templateRepository.findByTemplateType(templateType) + val now = System.currentTimeMillis() + + return if (template != null) { + template.templateContent = defaultContent + template.isDefault = true + template.updatedAt = now + templateRepository.save(template).toDto() + } else { + val newTemplate = NotificationTemplate( + templateType = templateType, + templateContent = defaultContent, + isDefault = true, + createdAt = now, + updatedAt = now + ) + templateRepository.save(newTemplate).toDto() + } + } + + /** + * 渲染模板(按类型取模板内容后替换变量) + */ + fun renderTemplate(templateType: String, variables: Map): String { + val template = getTemplate(templateType) + val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: "" + return renderTemplateContent(content, variables) + } + + /** + * 对给定模板内容做变量替换(不查库) + */ + fun renderTemplateContent(content: String, variables: Map): String { + var result = content + variables.forEach { (key, value) -> + result = result.replace("{{$key}}", value) + } + result = result.replace(Regex("\\{\\{[^}]+}}"), "-") + return result + } + + /** + * 发送测试消息 + */ + suspend fun sendTestMessage(templateType: String, content: String? = null): Boolean { + val templateContent = content ?: getTemplate(templateType)?.templateContent ?: return false + val testVariables = generateTestVariables(templateType) + val message = renderTemplateContent(templateContent, testVariables) + return try { + telegramNotificationService.sendMessage(message) + true + } catch (e: Exception) { + logger.error("发送测试消息失败: ${e.message}", e) + false + } + } + + /** + * 生成测试变量数据 + */ + private fun generateTestVariables(templateType: String): Map { + return mapOf( + "account_name" to "测试账户", + "wallet_address" to "0x1234...5678", + "time" to "2024-01-15 12:30:00", + "order_id" to "12345678", + "market_title" to "测试市场标题", + "market_link" to "https://polymarket.com/event/test", + "side" to "买入", + "outcome" to "YES", + "price" to "0.55", + "quantity" to "100", + "amount" to "55.00", + "available_balance" to "1000.00", + "leader_name" to "测试Leader", + "config_name" to "测试配置", + "error_message" to "余额不足", + "filter_type" to "价差过大", + "filter_reason" to "当前市场价差为 5%,超过设定的 3% 限制", + "strategy_name" to "BTC价差策略", + "transaction_hash" to "0xabcd...efgh", + "total_value" to "100.00" + ) + } + + /** + * Entity 转 DTO + */ + private fun NotificationTemplate.toDto() = NotificationTemplateDto( + id = id, + templateType = templateType, + templateContent = templateContent, + isDefault = isDefault, + createdAt = createdAt, + updatedAt = updatedAt + ) +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index c53256d..72c1dd3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit @Service class TelegramNotificationService( private val notificationConfigService: NotificationConfigService, + private val notificationTemplateService: NotificationTemplateService, private val objectMapper: ObjectMapper, private val messageSource: MessageSource ) { @@ -178,7 +179,9 @@ class TelegramNotificationService( null } - val message = buildOrderSuccessMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildOrderSuccessVariables( orderId = orderId, marketTitle = marketTitle, marketId = marketId, @@ -194,8 +197,11 @@ class TelegramNotificationService( leaderName = leaderName, configName = configName, orderTime = orderTime, - availableBalance = availableBalance + availableBalance = availableBalance, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed ) + val message = notificationTemplateService.renderTemplate("ORDER_SUCCESS", vars) sendMessage(message) } @@ -234,7 +240,9 @@ class TelegramNotificationService( null } - val message = buildOrderFailureMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildOrderFailureVariables( marketTitle = marketTitle, marketId = marketId, marketSlug = marketSlug, @@ -246,11 +254,65 @@ class TelegramNotificationService( errorMessage = errorMessage, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale + locale = currentLocale, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed ) + val message = notificationTemplateService.renderTemplate("ORDER_FAILED", vars) sendMessage(message) } + /** + * 构建订单失败通知的变量 Map + */ + private fun buildOrderFailureVariables( + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + errorMessage: String, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + unknownAccount: String, + calculateFailed: String + ): Map { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = amount?.let { am -> + try { + val amountDecimal = am.toSafeBigDecimal() + (if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { am } + } ?: calculateFailed + val shortError = if (errorMessage.length > 500) errorMessage.substring(0, 500) + "..." else errorMessage + return mapOf( + "market_title" to marketTitle.replace("<", "<").replace(">", ">"), + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""), + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "error_message" to shortError.replace("<", "<").replace(">", ">"), + "time" to DateUtils.formatDateTime() + ) + } + /** * 发送订单被过滤通知 * @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取) @@ -287,7 +349,9 @@ class TelegramNotificationService( null } - val message = buildOrderFilteredMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildOrderFilteredVariables( marketTitle = marketTitle, marketId = marketId, marketSlug = marketSlug, @@ -300,11 +364,70 @@ class TelegramNotificationService( filterType = filterType, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale + locale = currentLocale, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed ) + val message = notificationTemplateService.renderTemplate("ORDER_FILTERED", vars) sendMessage(message) } + private fun buildOrderFilteredVariables( + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + filterReason: String, + filterType: String, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + unknownAccount: String, + calculateFailed: String + ): Map { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val filterTypeDisplay = when (filterType.uppercase()) { + "ORDER_DEPTH" -> messageSource.getMessage("notification.filter.type.order_depth", null, "订单深度不足", locale).orEmpty().ifEmpty { "订单深度不足" } + "SPREAD" -> messageSource.getMessage("notification.filter.type.spread", null, "价差过大", locale).orEmpty().ifEmpty { "价差过大" } + "ORDERBOOK_DEPTH" -> messageSource.getMessage("notification.filter.type.orderbook_depth", null, "订单簿深度不足", locale).orEmpty().ifEmpty { "订单簿深度不足" } + "PRICE_VALIDITY" -> messageSource.getMessage("notification.filter.type.price_validity", null, "价格不合理", locale).orEmpty().ifEmpty { "价格不合理" } + "MARKET_STATUS" -> messageSource.getMessage("notification.filter.type.market_status", null, "市场状态不可交易", locale).orEmpty().ifEmpty { "市场状态不可交易" } + else -> filterType + } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = amount?.let { am -> + try { + (am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString()) + } catch (e: Exception) { am } + } ?: calculateFailed + return mapOf( + "market_title" to marketTitle.replace("<", "<").replace(">", ">"), + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""), + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "filter_type" to filterTypeDisplay, + "filter_reason" to filterReason.replace("<", "<").replace(">", ">"), + "time" to DateUtils.formatDateTime() + ) + } + /** * 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用) */ @@ -349,7 +472,10 @@ class TelegramNotificationService( logger.warn("计算订单金额失败: ${e.message}", e) null } - val message = buildCryptoTailOrderSuccessMessage( + val unknown = messageSource.getMessage("common.unknown", null, "未知", currentLocale).orEmpty().ifEmpty { "未知" } + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" } + val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" } + val vars = buildCryptoTailOrderSuccessVariables( orderId = orderId, marketTitle = marketTitle, marketId = marketId, @@ -362,12 +488,67 @@ class TelegramNotificationService( strategyName = strategyName, accountName = accountName, walletAddress = walletAddress, - locale = currentLocale, - orderTime = orderTime + orderTime = orderTime, + unknown = unknown, + unknownAccount = unknownAccount, + calculateFailed = calculateFailed, + locale = currentLocale ) + val message = notificationTemplateService.renderTemplate("CRYPTO_TAIL_SUCCESS", vars) sendMessage(message) } + private fun buildCryptoTailOrderSuccessVariables( + orderId: String?, + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + strategyName: String?, + accountName: String?, + walletAddress: String?, + orderTime: Long?, + unknown: String, + unknownAccount: String, + calculateFailed: String, + locale: java.util.Locale + ): Map { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime() + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = amount?.let { am -> + try { + (am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString()) + } catch (e: Exception) { am } + } ?: calculateFailed + return mapOf( + "order_id" to (orderId ?: unknown), + "market_title" to marketTitle.replace("<", "<").replace(">", ">"), + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""), + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "strategy_name" to (strategyName?.takeIf { it.isNotBlank() } ?: unknown), + "time" to time + ) + } + /** * 构建订单被过滤消息 */ @@ -750,6 +931,76 @@ class TelegramNotificationService( } } + /** + * 构建订单成功通知的变量 Map(供模板渲染) + */ + private fun buildOrderSuccessVariables( + orderId: String?, + marketTitle: String, + marketId: String?, + marketSlug: String?, + side: String, + outcome: String?, + price: String, + size: String, + amount: String?, + accountName: String?, + walletAddress: String?, + locale: java.util.Locale, + leaderName: String?, + configName: String?, + orderTime: Long?, + availableBalance: String?, + unknownAccount: String, + calculateFailed: String + ): Map { + val sideDisplay = when (side.uppercase()) { + "BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" } + "SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" } + else -> side + } + val unknown = messageSource.getMessage("common.unknown", null, "未知", locale).orEmpty().ifEmpty { "未知" } + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime() + val marketLink = when { + !marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug" + !marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId" + else -> "" + } + val amountDisplay = when { + amount != null -> try { + val amountDecimal = amount.toSafeBigDecimal() + val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros() + formatted.toPlainString() + } catch (e: Exception) { amount ?: calculateFailed } + else -> calculateFailed + } + val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) { + try { + val balanceDecimal = availableBalance.toSafeBigDecimal() + val formatted = if (balanceDecimal.scale() > 4) balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else balanceDecimal.stripTrailingZeros() + formatted.toPlainString() + } catch (e: Exception) { availableBalance ?: "" } + } else { "" } + val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">") + val escapedOutcome = outcome?.replace("<", "<")?.replace(">", ">") ?: "" + return mapOf( + "order_id" to (orderId ?: unknown), + "market_title" to escapedMarketTitle, + "market_link" to marketLink, + "side" to sideDisplay, + "outcome" to escapedOutcome, + "price" to formatPrice(price), + "quantity" to formatQuantity(size), + "amount" to amountDisplay, + "account_name" to accountInfo, + "available_balance" to availableBalanceDisplay, + "leader_name" to (leaderName ?: ""), + "config_name" to (configName ?: ""), + "time" to time + ) + } + /** * 构建订单成功消息 */ @@ -1135,17 +1386,46 @@ class TelegramNotificationService( java.util.Locale("zh", "CN") // 默认简体中文 } - val message = buildRedeemMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户" + val vars = buildRedeemSuccessVariables( accountName = accountName, walletAddress = walletAddress, transactionHash = transactionHash, totalRedeemedValue = totalRedeemedValue, - positions = positions, - locale = currentLocale, - availableBalance = availableBalance + availableBalance = availableBalance, + unknownAccount = unknownAccount ) + val message = notificationTemplateService.renderTemplate("REDEEM_SUCCESS", vars) sendMessage(message) } + + private fun buildRedeemSuccessVariables( + accountName: String?, + walletAddress: String?, + transactionHash: String, + totalRedeemedValue: String, + availableBalance: String?, + unknownAccount: String + ): Map { + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val totalValueDisplay = try { + val d = totalRedeemedValue.toSafeBigDecimal() + (if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { totalRedeemedValue } + val availableBalanceDisplay = availableBalance?.let { ab -> + try { + val d = ab.toSafeBigDecimal() + (if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { ab } + } ?: "" + return mapOf( + "account_name" to accountInfo, + "transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"), + "total_value" to totalValueDisplay, + "available_balance" to availableBalanceDisplay, + "time" to DateUtils.formatDateTime() + ) + } /** * 构建仓位赎回消息 @@ -1261,17 +1541,40 @@ $positionsText java.util.Locale("zh", "CN") } - val message = buildRedeemNoReturnMessage( + val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户" + val vars = buildRedeemNoReturnVariables( accountName = accountName, walletAddress = walletAddress, transactionHash = transactionHash, - positions = positions, - locale = currentLocale, - availableBalance = availableBalance + availableBalance = availableBalance, + unknownAccount = unknownAccount ) + val message = notificationTemplateService.renderTemplate("REDEEM_NO_RETURN", vars) sendMessage(message) } + private fun buildRedeemNoReturnVariables( + accountName: String?, + walletAddress: String?, + transactionHash: String, + availableBalance: String?, + unknownAccount: String + ): Map { + val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount) + val availableBalanceDisplay = availableBalance?.let { ab -> + try { + val d = ab.toSafeBigDecimal() + (if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString() + } catch (e: Exception) { ab } + } ?: "" + return mapOf( + "account_name" to accountInfo, + "transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"), + "available_balance" to availableBalanceDisplay, + "time" to DateUtils.formatDateTime() + ) + } + /** * 构建仓位已结算(无收益)消息 */ diff --git a/backend/src/main/resources/db/migration/V40__create_notification_templates.sql b/backend/src/main/resources/db/migration/V40__create_notification_templates.sql new file mode 100644 index 0000000..4438944 --- /dev/null +++ b/backend/src/main/resources/db/migration/V40__create_notification_templates.sql @@ -0,0 +1,97 @@ +-- 消息模板表 +CREATE TABLE notification_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_type VARCHAR(50) NOT NULL COMMENT '模板类型', + template_content TEXT NOT NULL COMMENT '模板内容,支持 {{variable}} 变量', + is_default TINYINT(1) DEFAULT 0 COMMENT '是否使用默认模板(0=自定义,1=默认)', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uk_template_type (template_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送模板'; + +-- 插入默认模板 +INSERT INTO notification_templates (template_type, template_content, is_default, created_at, updated_at) VALUES +('ORDER_SUCCESS', '🚀 订单创建成功 + +📊 订单信息: +• 订单ID: {{order_id}} +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} +• 可用余额: {{available_balance}} USDC + +⏰ 时间: {{time}}', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('ORDER_FAILED', '❌ 订单创建失败 + +📊 订单信息: +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} + +⚠️ 错误信息: +{{error_message}} + +⏰ 时间: {{time}}', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('ORDER_FILTERED', '🚫 订单被过滤 + +📊 订单信息: +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} + +⚠️ 过滤类型: {{filter_type}} + +📝 过滤原因: +{{filter_reason}} + +⏰ 时间: {{time}}', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('CRYPTO_TAIL_SUCCESS', '🚀 加密价差策略下单成功 + +📊 订单信息: +• 订单ID: {{order_id}} +• 策略: {{strategy_name}} +• 市场: {{market_title}} +• 市场方向: {{outcome}} +• 方向: {{side}} +• 价格: {{price}} +• 数量: {{quantity}} shares +• 金额: {{amount}} USDC +• 账户: {{account_name}} + +⏰ 时间: {{time}}', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('REDEEM_SUCCESS', '💸 仓位赎回成功 + +📊 赎回信息: +• 账户: {{account_name}} +• 交易哈希: {{transaction_hash}} +• 赎回总价值: {{total_value}} USDC +• 可用余额: {{available_balance}} USDC + +⏰ 时间: {{time}}', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000), + +('REDEEM_NO_RETURN', '📋 仓位已结算(无收益) + +📊 结算信息: +市场已结算,您的预测未命中,赎回价值为 0。 + +• 账户: {{account_name}} +• 交易哈希: {{transaction_hash}} +• 可用余额: {{available_balance}} USDC + +⏰ 时间: {{time}}', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000); diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 960115e..58c3d18 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -17,6 +17,14 @@ notification.order.available_balance=Available Balance notification.order.error_info=Error Information notification.order.unknown_account=Unknown Account notification.order.calculate_failed=Calculation Failed +notification.order.filtered=Order Filtered +notification.order.filter_reason=Filter Reason +notification.order.filter_type=Filter Type +notification.filter.type.order_depth=Insufficient Order Depth +notification.filter.type.spread=Spread Too Large +notification.filter.type.orderbook_depth=Insufficient Orderbook Depth +notification.filter.type.price_validity=Invalid Price +notification.filter.type.market_status=Market Not Tradable notification.tail.order.success=Crypto spread strategy order success notification.tail.strategy=Strategy notification.redeem.success=Position Redeemed Successfully diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 7fc04f6..63eede7 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -17,6 +17,14 @@ notification.order.available_balance=可用余额 notification.order.error_info=错误信息 notification.order.unknown_account=未知账户 notification.order.calculate_failed=计算失败 +notification.order.filtered=订单被过滤 +notification.order.filter_reason=过滤原因 +notification.order.filter_type=过滤类型 +notification.filter.type.order_depth=订单深度不足 +notification.filter.type.spread=价差过大 +notification.filter.type.orderbook_depth=订单簿深度不足 +notification.filter.type.price_validity=价格不合理 +notification.filter.type.market_status=市场状态不可交易 notification.tail.order.success=加密价差策略下单成功 notification.tail.strategy=策略 notification.redeem.success=仓位赎回成功 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index a71d80c..43d493a 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -17,6 +17,14 @@ notification.order.available_balance=可用餘額 notification.order.error_info=錯誤信息 notification.order.unknown_account=未知賬戶 notification.order.calculate_failed=計算失敗 +notification.order.filtered=訂單被過濾 +notification.order.filter_reason=過濾原因 +notification.order.filter_type=過濾類型 +notification.filter.type.order_depth=訂單深度不足 +notification.filter.type.spread=價差過大 +notification.filter.type.orderbook_depth=訂單簿深度不足 +notification.filter.type.price_validity=價格不合理 +notification.filter.type.market_status=市場狀態不可交易 notification.tail.order.success=加密價差策略下單成功 notification.tail.strategy=策略 notification.redeem.success=倉位贖回成功 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8c0faa..fd602a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders' import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' +import NotificationSettingsPage from './pages/NotificationSettingsPage' import ApiHealthStatus from './pages/ApiHealthStatus' import RpcNodeSettings from './pages/RpcNodeSettings' import Announcements from './pages/Announcements' @@ -268,6 +269,7 @@ function App() { } /> } /> } /> + } /> } /> } /> {/* 默认重定向到登录页 */} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6f4239a..f0e91bd 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -216,6 +216,11 @@ const Layout: React.FC = ({ children }) => { key: '/system-settings/api-health', icon: , label: t('menu.apiHealth') || 'API健康' + }, + { + key: '/system-settings/notification', + icon: , + label: t('menu.notifications') || '消息推送设置' } ] }, diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 48fb31b..3f5db71 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1167,7 +1167,42 @@ "getChatIdsFailed": "Failed to get Chat IDs", "getChatIdsNoToken": "Please enter Bot Token first", "getChatIdsNoMessage": "Chat ID not found, please send a message to the bot first (e.g., /start), then retry", - "getChatIdsButton": "Get Chat ID" + "getChatIdsButton": "Get Chat ID", + "botConfig": "Bot Configuration", + "templateConfig": "Template Configuration", + "templates": { + "title": "Message Template Configuration", + "templateType": "Template Type", + "templateContent": "Template Content", + "isDefault": "Default Template", + "isCustom": "Custom Template", + "resetToDefault": "Reset to Default", + "resetConfirm": "Are you sure you want to reset to default? Your custom content will be lost.", + "resetSuccess": "Reset successfully", + "resetFailed": "Reset failed", + "saveSuccess": "Saved successfully", + "saveFailed": "Save failed", + "testSuccess": "Test message sent successfully, please check Telegram", + "testFailed": "Failed to send test message", + "variables": "Available Variables", + "clickToCopy": "Click to copy", + "copied": "Copied", + "commonVariables": "Common Variables", + "orderVariables": "Order Variables", + "copyTradingVariables": "Copy Trading Variables", + "redeemVariables": "Redeem Variables", + "errorVariables": "Error Variables", + "filterVariables": "Filter Variables", + "strategyVariables": "Strategy Variables" + }, + "templateTypes": { + "ORDER_SUCCESS": "Order Success", + "ORDER_FAILED": "Order Failed", + "ORDER_FILTERED": "Order Filtered", + "CRYPTO_TAIL_SUCCESS": "Crypto Spread Strategy Success", + "REDEEM_SUCCESS": "Position Redeem Success", + "REDEEM_NO_RETURN": "Position Settled (No Return)" + } }, "telegramConfig": { "title": "Telegram Configuration Guide", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 1509b80..452234e 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1167,7 +1167,42 @@ "getChatIdsFailed": "获取 Chat IDs 失败", "getChatIdsNoToken": "请先填写 Bot Token", "getChatIdsNoMessage": "未找到 Chat ID,请先向机器人发送一条消息(如 /start),然后重试", - "getChatIdsButton": "获取 Chat ID" + "getChatIdsButton": "获取 Chat ID", + "botConfig": "机器人配置", + "templateConfig": "模板配置", + "templates": { + "title": "消息模板配置", + "templateType": "模板类型", + "templateContent": "模板内容", + "isDefault": "默认模板", + "isCustom": "自定义模板", + "resetToDefault": "重置为默认", + "resetConfirm": "确定要重置为默认模板吗?您的自定义内容将丢失。", + "resetSuccess": "重置成功", + "resetFailed": "重置失败", + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "testSuccess": "测试消息发送成功,请检查 Telegram", + "testFailed": "测试消息发送失败", + "variables": "可用变量", + "clickToCopy": "点击复制", + "copied": "已复制", + "commonVariables": "通用变量", + "orderVariables": "订单变量", + "copyTradingVariables": "跟单变量", + "redeemVariables": "赎回变量", + "errorVariables": "错误变量", + "filterVariables": "过滤变量", + "strategyVariables": "策略变量" + }, + "templateTypes": { + "ORDER_SUCCESS": "订单成功通知", + "ORDER_FAILED": "订单失败通知", + "ORDER_FILTERED": "订单过滤通知", + "CRYPTO_TAIL_SUCCESS": "加密价差策略成功通知", + "REDEEM_SUCCESS": "仓位赎回成功通知", + "REDEEM_NO_RETURN": "仓位结算(无收益)通知" + } }, "telegramConfig": { "title": "Telegram 配置说明", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 62fba01..a2d7f9d 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1167,7 +1167,42 @@ "getChatIdsFailed": "獲取 Chat IDs 失敗", "getChatIdsNoToken": "請先填寫 Bot Token", "getChatIdsNoMessage": "未找到 Chat ID,請先向機器人發送一條消息(如 /start),然後重試", - "getChatIdsButton": "獲取 Chat ID" + "getChatIdsButton": "獲取 Chat ID", + "botConfig": "機器人配置", + "templateConfig": "模板配置", + "templates": { + "title": "消息模板配置", + "templateType": "模板類型", + "templateContent": "模板內容", + "isDefault": "默認模板", + "isCustom": "自定義模板", + "resetToDefault": "重置為默認", + "resetConfirm": "確定要重置為默認模板嗎?您的自定義內容將丟失。", + "resetSuccess": "重置成功", + "resetFailed": "重置失敗", + "saveSuccess": "保存成功", + "saveFailed": "保存失敗", + "testSuccess": "測試消息發送成功,請檢查 Telegram", + "testFailed": "測試消息發送失敗", + "variables": "可用變量", + "clickToCopy": "點擊複製", + "copied": "已複製", + "commonVariables": "通用變量", + "orderVariables": "訂單變量", + "copyTradingVariables": "跟單變量", + "redeemVariables": "贖回變量", + "errorVariables": "錯誤變量", + "filterVariables": "過濾變量", + "strategyVariables": "策略變量" + }, + "templateTypes": { + "ORDER_SUCCESS": "訂單成功通知", + "ORDER_FAILED": "訂單失敗通知", + "ORDER_FILTERED": "訂單過濾通知", + "CRYPTO_TAIL_SUCCESS": "加密價差策略成功通知", + "REDEEM_SUCCESS": "倉位贖回成功通知", + "REDEEM_NO_RETURN": "倉位結算(無收益)通知" + } }, "telegramConfig": { "title": "Telegram 配置說明", diff --git a/frontend/src/pages/NotificationSettingsPage.tsx b/frontend/src/pages/NotificationSettingsPage.tsx new file mode 100644 index 0000000..2be6d60 --- /dev/null +++ b/frontend/src/pages/NotificationSettingsPage.tsx @@ -0,0 +1,708 @@ +import React, { useEffect, useState, useCallback } from 'react' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input, Switch, Tooltip, Row, Col, Menu } from 'antd' +import { PlusOutlined, EditOutlined, DeleteOutlined, SendOutlined, CopyOutlined, ReloadOutlined, CheckOutlined, RobotOutlined, FormOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { apiService } from '../services/api' +import type { NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse, TemplateVariable } from '../types' +import { useMediaQuery } from 'react-responsive' +import { TelegramConfigForm } from '../components/notifications' +import TextArea from 'antd/es/input/TextArea' + +const { Title, Text, Paragraph } = Typography + +const templateTypeMenuStyle: React.CSSProperties = { + border: 'none', + background: 'transparent', +} + +const variableChipStyle: React.CSSProperties = { + display: 'inline-block', + cursor: 'pointer', + marginBottom: 8, + marginRight: 8, + borderRadius: 16, + padding: '6px 12px', + fontSize: 13, + transition: 'all 0.2s', + border: '1px solid #d9d9d9', + background: '#fafafa', +} + +const variableChipHoverStyle: React.CSSProperties = { + borderColor: '#1890ff', + background: '#e6f7ff', + color: '#1890ff', +} + +/** + * 变量分类标签映射 + */ +const CATEGORY_LABELS: Record = { + common: 'notificationSettings.templates.commonVariables', + order: 'notificationSettings.templates.orderVariables', + copy_trading: 'notificationSettings.templates.copyTradingVariables', + redeem: 'notificationSettings.templates.redeemVariables', + error: 'notificationSettings.templates.errorVariables', + filter: 'notificationSettings.templates.filterVariables', + strategy: 'notificationSettings.templates.strategyVariables' +} + +const NotificationSettingsPage: React.FC = () => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + + // 机器人配置相关状态 + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [editingConfig, setEditingConfig] = useState(null) + const [form] = Form.useForm() + const [testLoading, setTestLoading] = useState(false) + + // 模板配置相关状态 + const [templateTypes, setTemplateTypes] = useState([]) + const [templates, setTemplates] = useState([]) + const [selectedTemplateType, setSelectedTemplateType] = useState('ORDER_SUCCESS') + const [currentTemplate, setCurrentTemplate] = useState(null) + const [templateVariables, setTemplateVariables] = useState(null) + const [templateContent, setTemplateContent] = useState('') + const [templateLoading, setTemplateLoading] = useState(false) + const [testTemplateLoading, setTestTemplateLoading] = useState(false) + + // 加载机器人配置 + useEffect(() => { + fetchConfigs() + }, []) + + // 加载模板类型 + useEffect(() => { + fetchTemplateTypes() + }, []) + + // 加载模板数据 + useEffect(() => { + fetchTemplates() + }, []) + + // 当选中的模板类型改变时,加载模板详情和变量 + useEffect(() => { + if (selectedTemplateType) { + fetchTemplateDetail(selectedTemplateType) + fetchTemplateVariables(selectedTemplateType) + } + }, [selectedTemplateType]) + + const fetchConfigs = async () => { + setLoading(true) + try { + const response = await apiService.notifications.list({ type: 'telegram' }) + if (response.data.code === 0 && response.data.data) { + setConfigs(response.data.data) + } else { + message.error(response.data.msg || t('notificationSettings.fetchFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.fetchFailed')) + } finally { + setLoading(false) + } + } + + const fetchTemplateTypes = async () => { + try { + const response = await apiService.notifications.getTemplateTypes() + if (response.data.code === 0 && response.data.data) { + setTemplateTypes(response.data.data) + } + } catch (error) { + console.error('获取模板类型失败:', error) + } + } + + const fetchTemplates = async () => { + setTemplateLoading(true) + try { + const response = await apiService.notifications.getTemplates() + if (response.data.code === 0 && response.data.data) { + setTemplates(response.data.data) + } + } catch (error) { + console.error('获取模板列表失败:', error) + } finally { + setTemplateLoading(false) + } + } + + const fetchTemplateDetail = async (templateType: string) => { + try { + const response = await apiService.notifications.getTemplateDetail({ templateType }) + if (response.data.code === 0 && response.data.data) { + setCurrentTemplate(response.data.data) + setTemplateContent(response.data.data.templateContent) + } + } catch (error) { + console.error('获取模板详情失败:', error) + } + } + + const fetchTemplateVariables = async (templateType: string) => { + try { + const response = await apiService.notifications.getTemplateVariables({ templateType }) + if (response.data.code === 0 && response.data.data) { + setTemplateVariables(response.data.data) + } + } catch (error) { + console.error('获取模板变量失败:', error) + } + } + + // 机器人配置相关方法 + const handleCreate = () => { + setEditingConfig(null) + form.resetFields() + form.setFieldsValue({ + type: 'telegram', + enabled: true, + config: { + botToken: '', + chatIds: [] + } + }) + setModalVisible(true) + } + + const handleEdit = (config: NotificationConfig) => { + setEditingConfig(config) + let botToken = '' + let chatIds = '' + + if (config.config) { + if ('data' in config.config && config.config.data) { + const data = config.config.data as any + botToken = data.botToken || '' + if (data.chatIds) { + if (Array.isArray(data.chatIds)) { + chatIds = data.chatIds.join(',') + } else if (typeof data.chatIds === 'string') { + chatIds = data.chatIds + } + } + } else { + if ('botToken' in config.config) { + botToken = (config.config as any).botToken || '' + } + if ('chatIds' in config.config) { + const ids = (config.config as any).chatIds + if (Array.isArray(ids)) { + chatIds = ids.join(',') + } else if (typeof ids === 'string') { + chatIds = ids + } + } + } + } + + form.setFieldsValue({ + type: config.type, + name: config.name, + enabled: config.enabled, + config: { + botToken: botToken, + chatIds: chatIds + } + }) + setModalVisible(true) + } + + const handleDelete = async (id: number) => { + try { + const response = await apiService.notifications.delete({ id }) + if (response.data.code === 0) { + message.success(t('notificationSettings.deleteSuccess')) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.deleteFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.deleteFailed')) + } + } + + const handleUpdateEnabled = async (id: number, enabled: boolean) => { + try { + const response = await apiService.notifications.updateEnabled({ id, enabled }) + if (response.data.code === 0) { + message.success(enabled ? t('notificationSettings.enableSuccess') : t('notificationSettings.disableSuccess')) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.updateStatusFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.updateStatusFailed')) + } + } + + const handleTest = async () => { + setTestLoading(true) + try { + const response = await apiService.notifications.test({ message: '这是一条测试消息' }) + if (response.data.code === 0 && response.data.data) { + message.success(t('notificationSettings.testSuccess')) + } else { + message.error(response.data.msg || t('notificationSettings.testFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.testFailed')) + } finally { + setTestLoading(false) + } + } + + const handleSubmit = async () => { + try { + const values = await form.validateFields() + const chatIds = typeof values.config.chatIds === 'string' + ? values.config.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id) + : values.config.chatIds || [] + + const configData: NotificationConfigRequest | NotificationConfigUpdateRequest = { + type: values.type, + name: values.name, + enabled: values.enabled, + config: { + botToken: values.config.botToken, + chatIds: chatIds + } + } + + if (editingConfig?.id) { + const updateData = { + ...configData, + id: editingConfig.id + } as NotificationConfigUpdateRequest + const response = await apiService.notifications.update(updateData) + if (response.data.code === 0) { + message.success(t('notificationSettings.updateSuccess')) + setModalVisible(false) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.updateFailed')) + } + } else { + const response = await apiService.notifications.create(configData) + if (response.data.code === 0) { + message.success(t('notificationSettings.createSuccess')) + setModalVisible(false) + fetchConfigs() + } else { + message.error(response.data.msg || t('notificationSettings.createFailed')) + } + } + } catch (error: any) { + if (error.errorFields) { + return + } + message.error(error.message || t('message.error')) + } + } + + const getConfigFormComponent = (type: string) => { + switch (type?.toLowerCase()) { + case 'telegram': + return + default: + return null + } + } + + // 模板配置相关方法 + const handleTemplateTypeChange = (type: string) => { + setSelectedTemplateType(type) + } + + const handleTemplateContentChange = (e: React.ChangeEvent) => { + setTemplateContent(e.target.value) + } + + const handleSaveTemplate = async () => { + try { + const response = await apiService.notifications.updateTemplate({ + templateType: selectedTemplateType, + templateContent: templateContent + }) + if (response.data.code === 0) { + message.success(t('notificationSettings.templates.saveSuccess')) + fetchTemplates() + fetchTemplateDetail(selectedTemplateType) + } else { + message.error(response.data.msg || t('notificationSettings.templates.saveFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.templates.saveFailed')) + } + } + + const handleResetTemplate = async () => { + try { + const response = await apiService.notifications.resetTemplate({ + templateType: selectedTemplateType + }) + if (response.data.code === 0) { + message.success(t('notificationSettings.templates.resetSuccess')) + fetchTemplates() + fetchTemplateDetail(selectedTemplateType) + } else { + message.error(response.data.msg || t('notificationSettings.templates.resetFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.templates.resetFailed')) + } + } + + const handleTestTemplate = async () => { + setTestTemplateLoading(true) + try { + const response = await apiService.notifications.testTemplate({ + templateType: selectedTemplateType, + templateContent: templateContent + }) + if (response.data.code === 0 && response.data.data) { + message.success(t('notificationSettings.templates.testSuccess')) + } else { + message.error(response.data.msg || t('notificationSettings.templates.testFailed')) + } + } catch (error: any) { + message.error(error.message || t('notificationSettings.templates.testFailed')) + } finally { + setTestTemplateLoading(false) + } + } + + const handleCopyVariable = useCallback((variable: string) => { + navigator.clipboard.writeText(`{{${variable}}}`) + message.success(t('notificationSettings.templates.copied')) + }, [t]) + + const [variableHoverKey, setVariableHoverKey] = useState(null) + + const renderVariableItem = (variable: TemplateVariable) => { + const isHover = variableHoverKey === variable.key + return ( + + handleCopyVariable(variable.key)} + onMouseEnter={() => setVariableHoverKey(variable.key)} + onMouseLeave={() => setVariableHoverKey(null)} + onKeyDown={(e) => e.key === 'Enter' && handleCopyVariable(variable.key)} + > + + {variable.label} + + + ) + } + + const renderVariablesPanel = () => { + if (!templateVariables) return null + + return ( + + {t('notificationSettings.templates.variables')} + + } + style={{ height: '100%', borderRadius: 8 }} + bodyStyle={{ paddingTop: 12 }} + > + {templateVariables.categories.map(category => { + const categoryVariables = templateVariables.variables.filter(v => v.category === category.key) + if (categoryVariables.length === 0) return null + return ( +
+ + {t(CATEGORY_LABELS[category.key] || category.label)} + +
+ {categoryVariables.sort((a, b) => a.sortOrder - b.sortOrder).map(renderVariableItem)} +
+
+ ) + })} + + {t('notificationSettings.templates.clickToCopy')} + +
+ ) + } + + // 机器人配置表格列 + const configColumns = [ + { + title: t('notificationSettings.configName'), + dataIndex: 'name', + key: 'name', + }, + { + title: t('notificationSettings.type'), + dataIndex: 'type', + key: 'type', + render: (type: string) => {type.toUpperCase()} + }, + { + title: t('notificationSettings.status'), + dataIndex: 'enabled', + key: 'enabled', + render: (enabled: boolean) => ( + + {enabled ? t('notificationSettings.enabledStatus') : t('notificationSettings.disabledStatus')} + + ) + }, + { + title: t('notificationSettings.chatIds'), + key: 'chatIds', + render: (_: any, record: NotificationConfig) => { + let chatIds: string[] = [] + if (record.config) { + if ('data' in record.config && record.config.data) { + const data = (record.config as any).data + if (data.chatIds) { + if (Array.isArray(data.chatIds)) { + chatIds = data.chatIds.filter((id: any) => id && String(id).trim()) + } else if (typeof data.chatIds === 'string') { + chatIds = data.chatIds.split(',').map((id: string) => id.trim()).filter((id: string) => id) + } + } + } else if ('chatIds' in record.config) { + const ids = (record.config as any).chatIds + if (Array.isArray(ids)) { + chatIds = ids.filter((id: any) => id && String(id).trim()) + } else if (typeof ids === 'string') { + chatIds = (ids as string).split(',').map((id: string) => id.trim()).filter((id: string) => id) + } + } + } + return chatIds.length > 0 ? ( + + {chatIds.join(', ')} + + ) : ( + {t('notificationSettings.chatIdsNotConfigured')} + ) + } + }, + { + title: t('common.actions'), + key: 'action', + width: isMobile ? 120 : 200, + render: (_: any, record: NotificationConfig) => ( + + + handleUpdateEnabled(record.id!, checked)} + /> + + handleDelete(record.id!)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + + ) + } + ] + + const templateTypeMenuItems = templateTypes.map(type => ({ + key: type.type, + icon: , + label: ( +
+
{t(`notificationSettings.templateTypes.${type.type}`)}
+
{type.description}
+
+ ), + })) + + return ( +
+
+ {t('notificationSettings.title')} +
+ + {/* 机器人配置 */} + + + {t('notificationSettings.botConfig')} + + } + style={{ marginBottom: '16px' }} + extra={ + + } + > +
+ + + {/* 模板配置 */} + + + {t('notificationSettings.templateConfig')} + + } + loading={templateLoading} + style={{ marginBottom: '16px' }} + > + + +
+ + {t('notificationSettings.templates.templateType')} + + handleTemplateTypeChange(key)} + /> +
+ + + +
+ + {t('notificationSettings.templates.templateContent')} + {currentTemplate && ( + + {currentTemplate.isDefault ? t('notificationSettings.templates.isDefault') : t('notificationSettings.templates.isCustom')} + + )} + + + + + + + + +
+
+