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')} + + )} + + + + + + + + +
+
+