From 540cf6c9912c8f94e5e11bcad1285cba266d032f Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:37:31 +0800 Subject: [PATCH] fix: channel affinity (#2799) * fix: channel affinity log styles * fix: Issue with incorrect data storage when switching key sources * feat: support not retrying after a single rule configuration fails * fix: render channel affinity tooltip as multiline content * feat: channel affinity cache hit * fix: prevent ChannelAffinityUsageCacheModal infinite loading and hide data before fetch * chore: format backend with gofmt and frontend with prettier/eslint autofix --- controller/channel_affinity_cache.go | 28 + controller/misc.go | 2 +- controller/performance.go | 11 +- controller/relay.go | 6 + main.go | 2 +- relay/channel/ali/adaptor.go | 2 +- relay/compatible_handler.go | 5 + router/api-router.go | 1 + service/channel_affinity.go | 298 ++++++- .../channel_affinity_setting.go | 2 + web/i18next.config.js | 121 ++- web/src/components/auth/LoginForm.jsx | 38 +- .../common/DocumentRenderer/index.jsx | 40 +- .../layout/components/SkeletonWrapper.jsx | 12 +- web/src/components/playground/CodeViewer.jsx | 3 +- .../playground/CustomInputRender.jsx | 97 +-- .../playground/CustomRequestEditor.jsx | 8 +- web/src/components/playground/DebugPanel.jsx | 5 +- web/src/components/playground/SSEViewer.jsx | 102 ++- .../settings/HttpStatusCodeRulesInput.jsx | 1 - .../settings/ModelDeploymentSetting.jsx | 4 +- .../components/settings/OperationSetting.jsx | 3 +- web/src/components/settings/OtherSetting.jsx | 10 +- web/src/components/settings/RatioSetting.jsx | 4 +- web/src/components/settings/SystemSetting.jsx | 12 +- .../table/channels/ChannelsColumnDefs.jsx | 4 +- .../table/channels/modals/CodexOAuthModal.jsx | 31 +- .../table/channels/modals/CodexUsageModal.jsx | 23 +- .../channels/modals/EditChannelModal.jsx | 313 ++++---- .../table/channels/modals/EditTagModal.jsx | 10 +- .../channels/modals/ModelSelectModal.jsx | 4 +- .../modals/ConfirmationDialog.jsx | 12 +- .../modals/ExtendDurationModal.jsx | 32 +- .../table/tokens/modals/EditTokenModal.jsx | 11 +- .../table/usage-logs/UsageLogsColumnDefs.jsx | 97 ++- .../table/usage-logs/UsageLogsTable.jsx | 11 +- web/src/components/table/usage-logs/index.jsx | 2 + .../modals/ChannelAffinityUsageCacheModal.jsx | 200 +++++ web/src/components/topup/RechargeCard.jsx | 10 +- web/src/components/topup/index.jsx | 3 +- web/src/helpers/api.js | 4 +- web/src/helpers/dashboard.jsx | 18 +- web/src/helpers/statusCodeRules.js | 26 +- web/src/helpers/utils.jsx | 14 +- .../useModelDeploymentSettings.js | 26 +- web/src/hooks/playground/useApiRequest.jsx | 9 +- .../hooks/playground/usePlaygroundState.js | 21 +- web/src/hooks/usage-logs/useUsageLogsData.jsx | 37 +- web/src/pages/Playground/index.jsx | 169 ++-- web/src/pages/PrivacyPolicy/index.jsx | 6 +- .../Setting/Model/SettingGlobalModel.jsx | 28 +- .../pages/Setting/Model/SettingGrokModel.jsx | 3 +- .../Setting/Model/SettingModelDeployment.jsx | 57 +- .../Operation/SettingsChannelAffinity.jsx | 27 +- .../Setting/Operation/SettingsCreditLimit.jsx | 4 +- .../Setting/Operation/SettingsMonitoring.jsx | 11 +- .../Operation/SettingsSidebarModulesAdmin.jsx | 6 +- .../Payment/SettingsPaymentGatewayCreem.jsx | 755 +++++++++--------- .../Performance/SettingsPerformance.jsx | 204 ++++- .../Setting/Ratio/GroupRatioSettings.jsx | 5 +- web/src/pages/UserAgreement/index.jsx | 6 +- 61 files changed, 2012 insertions(+), 1004 deletions(-) create mode 100644 web/src/components/table/usage-logs/modals/ChannelAffinityUsageCacheModal.jsx diff --git a/controller/channel_affinity_cache.go b/controller/channel_affinity_cache.go index bb5cab20a..a72b04b8b 100644 --- a/controller/channel_affinity_cache.go +++ b/controller/channel_affinity_cache.go @@ -58,3 +58,31 @@ func ClearChannelAffinityCache(c *gin.Context) { }, }) } + +func GetChannelAffinityUsageCacheStats(c *gin.Context) { + ruleName := strings.TrimSpace(c.Query("rule_name")) + usingGroup := strings.TrimSpace(c.Query("using_group")) + keyFp := strings.TrimSpace(c.Query("key_fp")) + + if ruleName == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing param: rule_name", + }) + return + } + if keyFp == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "missing param: key_fp", + }) + return + } + + stats := service.GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": stats, + }) +} diff --git a/controller/misc.go b/controller/misc.go index 6219676e7..e76ca51bb 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -115,7 +115,7 @@ func GetStatus(c *gin.Context) { "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, - "_qn": "new-api", + "_qn": "new-api", } // 根据启用状态注入可选内容 diff --git a/controller/performance.go b/controller/performance.go index c7e853548..a6fedc46c 100644 --- a/controller/performance.go +++ b/controller/performance.go @@ -91,11 +91,11 @@ func GetPerformanceStats(c *gin.Context) { // 获取配置信息 diskConfig := common.GetDiskCacheConfig() config := PerformanceConfig{ - DiskCacheEnabled: diskConfig.Enabled, - DiskCacheThresholdMB: diskConfig.ThresholdMB, - DiskCacheMaxSizeMB: diskConfig.MaxSizeMB, - DiskCachePath: diskConfig.Path, - IsRunningInContainer: common.IsRunningInContainer(), + DiskCacheEnabled: diskConfig.Enabled, + DiskCacheThresholdMB: diskConfig.ThresholdMB, + DiskCacheMaxSizeMB: diskConfig.MaxSizeMB, + DiskCachePath: diskConfig.Path, + IsRunningInContainer: common.IsRunningInContainer(), } // 获取磁盘空间信息 @@ -199,4 +199,3 @@ func getDiskCacheInfo() DiskCacheInfo { return info } - diff --git a/controller/relay.go b/controller/relay.go index 387fe47f8..089fd143f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -311,6 +311,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if openaiErr == nil { return false } + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + return false + } if types.IsChannelError(openaiErr) { return true } @@ -514,6 +517,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, if taskErr == nil { return false } + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + return false + } if retryTimes <= 0 { return false } diff --git a/main.go b/main.go index ae391ac3c..dd5fa14b5 100644 --- a/main.go +++ b/main.go @@ -19,8 +19,8 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/router" "github.com/QuantumNous/new-api/service" + _ "github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" - _ "github.com/QuantumNous/new-api/setting/performance_setting" // 注册性能设置 "github.com/bytedance/gopkg/util/gopool" "github.com/gin-contrib/sessions" diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 23ef5f4be..17869a06d 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -13,8 +13,8 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" - "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index b2706730d..74abfe5b2 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -219,6 +219,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) { + originUsage := usage if usage == nil { usage = &dto.Usage{ PromptTokens: relayInfo.GetEstimatePromptTokens(), @@ -228,6 +229,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage extraContent = append(extraContent, "上游无计费信息") } + if originUsage != nil { + service.ObserveChannelAffinityUsageCacheFromContext(ctx, usage) + } + adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason) useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() diff --git a/router/api-router.go b/router/api-router.go index 973684958..1ef181884 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -220,6 +220,7 @@ func SetApiRouter(router *gin.Engine) { logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) + logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats) logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs) diff --git a/service/channel_affinity.go b/service/channel_affinity.go index 5aa50adb6..a94eb29e8 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "hash/fnv" "regexp" "strconv" "strings" @@ -9,6 +10,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/pkg/cachex" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/gin-gonic/gin" @@ -21,14 +23,19 @@ const ( ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds" ginKeyChannelAffinityMeta = "channel_affinity_meta" ginKeyChannelAffinityLogInfo = "channel_affinity_log_info" + ginKeyChannelAffinitySkipRetry = "channel_affinity_skip_retry_on_failure" - channelAffinityCacheNamespace = "new-api:channel_affinity:v1" + channelAffinityCacheNamespace = "new-api:channel_affinity:v1" + channelAffinityUsageCacheStatsNamespace = "new-api:channel_affinity_usage_cache_stats:v1" ) var ( channelAffinityCacheOnce sync.Once channelAffinityCache *cachex.HybridCache[int] + channelAffinityUsageCacheStatsOnce sync.Once + channelAffinityUsageCacheStatsCache *cachex.HybridCache[ChannelAffinityUsageCacheCounters] + channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp ) @@ -36,15 +43,24 @@ type channelAffinityMeta struct { CacheKey string TTLSeconds int RuleName string + SkipRetry bool KeySourceType string KeySourceKey string KeySourcePath string + KeyHint string KeyFingerprint string UsingGroup string ModelName string RequestPath string } +type ChannelAffinityStatsContext struct { + RuleName string + UsingGroup string + KeyFingerprint string + TTLSeconds int64 +} + type ChannelAffinityCacheStats struct { Enabled bool `json:"enabled"` Total int `json:"total"` @@ -338,6 +354,32 @@ func getChannelAffinityMeta(c *gin.Context) (channelAffinityMeta, bool) { return meta, true } +func GetChannelAffinityStatsContext(c *gin.Context) (ChannelAffinityStatsContext, bool) { + if c == nil { + return ChannelAffinityStatsContext{}, false + } + meta, ok := getChannelAffinityMeta(c) + if !ok { + return ChannelAffinityStatsContext{}, false + } + ruleName := strings.TrimSpace(meta.RuleName) + keyFp := strings.TrimSpace(meta.KeyFingerprint) + usingGroup := strings.TrimSpace(meta.UsingGroup) + if ruleName == "" || keyFp == "" { + return ChannelAffinityStatsContext{}, false + } + ttlSeconds := int64(meta.TTLSeconds) + if ttlSeconds <= 0 { + return ChannelAffinityStatsContext{}, false + } + return ChannelAffinityStatsContext{ + RuleName: ruleName, + UsingGroup: usingGroup, + KeyFingerprint: keyFp, + TTLSeconds: ttlSeconds, + }, true +} + func affinityFingerprint(s string) string { if s == "" { return "" @@ -349,6 +391,19 @@ func affinityFingerprint(s string) string { return hex } +func buildChannelAffinityKeyHint(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + if len(s) <= 12 { + return s + } + return s[:4] + "..." + s[len(s)-4:] +} + func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) { setting := operation_setting.GetChannelAffinitySetting() if setting == nil || !setting.Enabled { @@ -399,9 +454,11 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup CacheKey: cacheKeyFull, TTLSeconds: ttlSeconds, RuleName: rule.Name, + SkipRetry: rule.SkipRetryOnFailure, KeySourceType: strings.TrimSpace(usedSource.Type), KeySourceKey: strings.TrimSpace(usedSource.Key), KeySourcePath: strings.TrimSpace(usedSource.Path), + KeyHint: buildChannelAffinityKeyHint(affinityValue), KeyFingerprint: affinityFingerprint(affinityValue), UsingGroup: usingGroup, ModelName: modelName, @@ -422,6 +479,21 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup return 0, false } +func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool { + if c == nil { + return false + } + v, ok := c.Get(ginKeyChannelAffinitySkipRetry) + if !ok { + return false + } + b, ok := v.(bool) + if !ok { + return false + } + return b +} + func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) { if c == nil || channelID <= 0 { return @@ -430,6 +502,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int if !ok { return } + c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry) info := map[string]interface{}{ "reason": meta.RuleName, "rule_name": meta.RuleName, @@ -441,6 +514,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int "key_source": meta.KeySourceType, "key_key": meta.KeySourceKey, "key_path": meta.KeySourcePath, + "key_hint": meta.KeyHint, "key_fp": meta.KeyFingerprint, } c.Set(ginKeyChannelAffinityLogInfo, info) @@ -485,3 +559,225 @@ func RecordChannelAffinity(c *gin.Context, channelID int) { common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err)) } } + +type ChannelAffinityUsageCacheStats struct { + RuleName string `json:"rule_name"` + UsingGroup string `json:"using_group"` + KeyFingerprint string `json:"key_fp"` + + Hit int64 `json:"hit"` + Total int64 `json:"total"` + WindowSeconds int64 `json:"window_seconds"` + + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + CachedTokens int64 `json:"cached_tokens"` + PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"` + LastSeenAt int64 `json:"last_seen_at"` +} + +type ChannelAffinityUsageCacheCounters struct { + Hit int64 `json:"hit"` + Total int64 `json:"total"` + WindowSeconds int64 `json:"window_seconds"` + + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + CachedTokens int64 `json:"cached_tokens"` + PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"` + LastSeenAt int64 `json:"last_seen_at"` +} + +var channelAffinityUsageCacheStatsLocks [64]sync.Mutex + +func ObserveChannelAffinityUsageCacheFromContext(c *gin.Context, usage *dto.Usage) { + statsCtx, ok := GetChannelAffinityStatsContext(c) + if !ok { + return + } + observeChannelAffinityUsageCache(statsCtx, usage) +} + +func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) ChannelAffinityUsageCacheStats { + ruleName = strings.TrimSpace(ruleName) + usingGroup = strings.TrimSpace(usingGroup) + keyFp = strings.TrimSpace(keyFp) + + entryKey := channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp) + if entryKey == "" { + return ChannelAffinityUsageCacheStats{ + RuleName: ruleName, + UsingGroup: usingGroup, + KeyFingerprint: keyFp, + } + } + + cache := getChannelAffinityUsageCacheStatsCache() + v, found, err := cache.Get(entryKey) + if err != nil || !found { + return ChannelAffinityUsageCacheStats{ + RuleName: ruleName, + UsingGroup: usingGroup, + KeyFingerprint: keyFp, + } + } + return ChannelAffinityUsageCacheStats{ + RuleName: ruleName, + UsingGroup: usingGroup, + KeyFingerprint: keyFp, + Hit: v.Hit, + Total: v.Total, + WindowSeconds: v.WindowSeconds, + PromptTokens: v.PromptTokens, + CompletionTokens: v.CompletionTokens, + TotalTokens: v.TotalTokens, + CachedTokens: v.CachedTokens, + PromptCacheHitTokens: v.PromptCacheHitTokens, + LastSeenAt: v.LastSeenAt, + } +} + +func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usage *dto.Usage) { + entryKey := channelAffinityUsageCacheEntryKey(statsCtx.RuleName, statsCtx.UsingGroup, statsCtx.KeyFingerprint) + if entryKey == "" { + return + } + + windowSeconds := statsCtx.TTLSeconds + if windowSeconds <= 0 { + return + } + + cache := getChannelAffinityUsageCacheStatsCache() + ttl := time.Duration(windowSeconds) * time.Second + + lock := channelAffinityUsageCacheStatsLock(entryKey) + lock.Lock() + defer lock.Unlock() + + prev, found, err := cache.Get(entryKey) + if err != nil { + return + } + next := prev + if !found { + next = ChannelAffinityUsageCacheCounters{} + } + next.Total++ + hit, cachedTokens, promptCacheHitTokens := usageCacheSignals(usage) + if hit { + next.Hit++ + } + next.WindowSeconds = windowSeconds + next.LastSeenAt = time.Now().Unix() + next.CachedTokens += cachedTokens + next.PromptCacheHitTokens += promptCacheHitTokens + next.PromptTokens += int64(usagePromptTokens(usage)) + next.CompletionTokens += int64(usageCompletionTokens(usage)) + next.TotalTokens += int64(usageTotalTokens(usage)) + _ = cache.SetWithTTL(entryKey, next, ttl) +} + +func channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp string) string { + ruleName = strings.TrimSpace(ruleName) + usingGroup = strings.TrimSpace(usingGroup) + keyFp = strings.TrimSpace(keyFp) + if ruleName == "" || keyFp == "" { + return "" + } + return ruleName + "\n" + usingGroup + "\n" + keyFp +} + +func usageCacheSignals(usage *dto.Usage) (hit bool, cachedTokens int64, promptCacheHitTokens int64) { + if usage == nil { + return false, 0, 0 + } + + cached := int64(0) + if usage.PromptTokensDetails.CachedTokens > 0 { + cached = int64(usage.PromptTokensDetails.CachedTokens) + } else if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { + cached = int64(usage.InputTokensDetails.CachedTokens) + } + pcht := int64(0) + if usage.PromptCacheHitTokens > 0 { + pcht = int64(usage.PromptCacheHitTokens) + } + return cached > 0 || pcht > 0, cached, pcht +} + +func usagePromptTokens(usage *dto.Usage) int { + if usage == nil { + return 0 + } + if usage.PromptTokens > 0 { + return usage.PromptTokens + } + return usage.InputTokens +} + +func usageCompletionTokens(usage *dto.Usage) int { + if usage == nil { + return 0 + } + if usage.CompletionTokens > 0 { + return usage.CompletionTokens + } + return usage.OutputTokens +} + +func usageTotalTokens(usage *dto.Usage) int { + if usage == nil { + return 0 + } + if usage.TotalTokens > 0 { + return usage.TotalTokens + } + pt := usagePromptTokens(usage) + ct := usageCompletionTokens(usage) + if pt > 0 || ct > 0 { + return pt + ct + } + return 0 +} + +func getChannelAffinityUsageCacheStatsCache() *cachex.HybridCache[ChannelAffinityUsageCacheCounters] { + channelAffinityUsageCacheStatsOnce.Do(func() { + setting := operation_setting.GetChannelAffinitySetting() + capacity := 100_000 + defaultTTLSeconds := 3600 + if setting != nil { + if setting.MaxEntries > 0 { + capacity = setting.MaxEntries + } + if setting.DefaultTTLSeconds > 0 { + defaultTTLSeconds = setting.DefaultTTLSeconds + } + } + + channelAffinityUsageCacheStatsCache = cachex.NewHybridCache[ChannelAffinityUsageCacheCounters](cachex.HybridCacheConfig[ChannelAffinityUsageCacheCounters]{ + Namespace: cachex.Namespace(channelAffinityUsageCacheStatsNamespace), + Redis: common.RDB, + RedisEnabled: func() bool { + return common.RedisEnabled && common.RDB != nil + }, + RedisCodec: cachex.JSONCodec[ChannelAffinityUsageCacheCounters]{}, + Memory: func() *hot.HotCache[string, ChannelAffinityUsageCacheCounters] { + return hot.NewHotCache[string, ChannelAffinityUsageCacheCounters](hot.LRU, capacity). + WithTTL(time.Duration(defaultTTLSeconds) * time.Second). + WithJanitor(). + Build() + }, + }) + }) + return channelAffinityUsageCacheStatsCache +} + +func channelAffinityUsageCacheStatsLock(key string) *sync.Mutex { + h := fnv.New32a() + _, _ = h.Write([]byte(key)) + idx := h.Sum32() % uint32(len(channelAffinityUsageCacheStatsLocks)) + return &channelAffinityUsageCacheStatsLocks[idx] +} diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go index f95ac6969..7173f7b78 100644 --- a/setting/operation_setting/channel_affinity_setting.go +++ b/setting/operation_setting/channel_affinity_setting.go @@ -18,6 +18,8 @@ type ChannelAffinityRule struct { ValueRegex string `json:"value_regex"` TTLSeconds int `json:"ttl_seconds"` + SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"` + IncludeUsingGroup bool `json:"include_using_group"` IncludeRuleName bool `json:"include_rule_name"` } diff --git a/web/i18next.config.js b/web/i18next.config.js index 4e138bdfc..40808629b 100644 --- a/web/i18next.config.js +++ b/web/i18next.config.js @@ -21,77 +21,66 @@ import { defineConfig } from 'i18next-cli'; /** @type {import('i18next-cli').I18nextToolkitConfig} */ export default defineConfig({ - locales: [ - "zh", - "en", - "fr", - "ru", - "ja", - "vi" - ], + locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'], extract: { - input: [ - "src/**/*.{js,jsx,ts,tsx}" - ], - ignore: [ - "src/i18n/**/*" - ], - output: "src/i18n/locales/{{language}}.json", + input: ['src/**/*.{js,jsx,ts,tsx}'], + ignore: ['src/i18n/**/*'], + output: 'src/i18n/locales/{{language}}.json', ignoredAttributes: [ - "accept", - "align", - "aria-label", - "autoComplete", - "className", - "clipRule", - "color", - "crossOrigin", - "data-index", - "data-name", - "data-testid", - "data-type", - "defaultActiveKey", - "direction", - "editorType", - "field", - "fill", - "fillRule", - "height", - "hoverStyle", - "htmlType", - "id", - "itemKey", - "key", - "keyPrefix", - "layout", - "margin", - "maxHeight", - "mode", - "name", - "overflow", - "placement", - "position", - "rel", - "role", - "rowKey", - "searchPosition", - "selectedStyle", - "shape", - "size", - "style", - "theme", - "trigger", - "uploadTrigger", - "validateStatus", - "value", - "viewBox", - "width" + 'accept', + 'align', + 'aria-label', + 'autoComplete', + 'className', + 'clipRule', + 'color', + 'crossOrigin', + 'data-index', + 'data-name', + 'data-testid', + 'data-type', + 'defaultActiveKey', + 'direction', + 'editorType', + 'field', + 'fill', + 'fillRule', + 'height', + 'hoverStyle', + 'htmlType', + 'id', + 'itemKey', + 'key', + 'keyPrefix', + 'layout', + 'margin', + 'maxHeight', + 'mode', + 'name', + 'overflow', + 'placement', + 'position', + 'rel', + 'role', + 'rowKey', + 'searchPosition', + 'selectedStyle', + 'shape', + 'size', + 'style', + 'theme', + 'trigger', + 'uploadTrigger', + 'validateStatus', + 'value', + 'viewBox', + 'width', ], sort: true, disablePlurals: false, removeUnusedKeys: false, nsSeparator: false, keySeparator: false, - mergeNamespaces: true - } -}); \ No newline at end of file + mergeNamespaces: true, + }, +}); diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 5111f1f68..134451ec3 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -39,7 +39,15 @@ import { isPasskeySupported, } from '../../helpers'; import Turnstile from 'react-turnstile'; -import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; +import { + Button, + Card, + Checkbox, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import TelegramLoginButton from 'react-telegram-login'; @@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; import TwoFAVerification from './TwoFAVerification'; import { useTranslation } from 'react-i18next'; -import { SiDiscord }from 'react-icons/si'; +import { SiDiscord } from 'react-icons/si'; const LoginForm = () => { let navigate = useNavigate(); @@ -126,7 +134,7 @@ const LoginForm = () => { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } - + // 从 status 获取用户协议和隐私政策的启用状态 setHasUserAgreement(status?.user_agreement_enabled || false); setHasPrivacyPolicy(status?.privacy_policy_enabled || false); @@ -514,7 +522,15 @@ const LoginForm = () => { theme='outline' className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors' type='tertiary' - icon={} + icon={ + + } onClick={handleDiscordClick} loading={discordLoading} > @@ -626,11 +642,11 @@ const LoginForm = () => { {t('隐私政策')} - )} - - - - )} + )} + + + + )} {!status.self_use_mode_enabled && (
@@ -746,7 +762,9 @@ const LoginForm = () => { htmlType='submit' onClick={handleSubmit} loading={loginLoading} - disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms} + disabled={ + (hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms + } > {t('继续')} diff --git a/web/src/components/common/DocumentRenderer/index.jsx b/web/src/components/common/DocumentRenderer/index.jsx index 383afc11d..68e868c51 100644 --- a/web/src/components/common/DocumentRenderer/index.jsx +++ b/web/src/components/common/DocumentRenderer/index.jsx @@ -41,7 +41,7 @@ const isUrl = (content) => { // 检查是否为 HTML 内容 const isHtmlContent = (content) => { if (!content || typeof content !== 'string') return false; - + // 检查是否包含HTML标签 const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; return htmlTagRegex.test(content); @@ -52,16 +52,16 @@ const sanitizeHtml = (html) => { // 创建一个临时元素来解析HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; - + // 提取样式 const styles = Array.from(tempDiv.querySelectorAll('style')) - .map(style => style.innerHTML) + .map((style) => style.innerHTML) .join('\n'); - + // 提取body内容,如果没有body标签则使用全部内容 const bodyContent = tempDiv.querySelector('body'); const content = bodyContent ? bodyContent.innerHTML : html; - + return { content, styles }; }; @@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { // 处理HTML样式注入 useEffect(() => { const styleId = `document-renderer-styles-${cacheKey}`; - + if (htmlStyles) { let styleEl = document.getElementById(styleId); if (!styleEl) { @@ -165,8 +165,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
} - darkModeImage={} + image={ + + } + darkModeImage={ + + } className='p-8' />
@@ -179,7 +183,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
- {title} + + {title} +

{t('管理员设置了外部链接,点击下方按钮访问')}

@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { // 如果是 HTML 内容,直接渲染 if (isHtmlContent(content)) { const { content: htmlContent, styles } = sanitizeHtml(content); - + // 设置样式(如果有的话) useEffect(() => { if (styles && styles !== htmlStyles) { setHtmlStyles(styles); } }, [content, styles, htmlStyles]); - + return (
- {title} -
+ {title} + +
@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
- {title} + + {title} +
@@ -240,4 +250,4 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { ); }; -export default DocumentRenderer; \ No newline at end of file +export default DocumentRenderer; diff --git a/web/src/components/layout/components/SkeletonWrapper.jsx b/web/src/components/layout/components/SkeletonWrapper.jsx index eec4be9a7..3d4981668 100644 --- a/web/src/components/layout/components/SkeletonWrapper.jsx +++ b/web/src/components/layout/components/SkeletonWrapper.jsx @@ -136,9 +136,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } />
@@ -186,7 +184,9 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } />
@@ -221,9 +221,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } /> ); diff --git a/web/src/components/playground/CodeViewer.jsx b/web/src/components/playground/CodeViewer.jsx index 9f728c2ba..ce21d43cc 100644 --- a/web/src/components/playground/CodeViewer.jsx +++ b/web/src/components/playground/CodeViewer.jsx @@ -115,8 +115,7 @@ const linkifyHtml = (html) => { if (part.startsWith('<')) return part; return part.replace( linkRegex, - (url) => - `${url}`, + (url) => `${url}`, ); }) .join(''); diff --git a/web/src/components/playground/CustomInputRender.jsx b/web/src/components/playground/CustomInputRender.jsx index f83d6bcbf..c995773a7 100644 --- a/web/src/components/playground/CustomInputRender.jsx +++ b/web/src/components/playground/CustomInputRender.jsx @@ -30,64 +30,67 @@ const CustomInputRender = (props) => { detailProps; const containerRef = useRef(null); - const handlePaste = useCallback(async (e) => { - const items = e.clipboardData?.items; - if (!items) return; + const handlePaste = useCallback( + async (e) => { + const items = e.clipboardData?.items; + if (!items) return; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - if (item.type.indexOf('image') !== -1) { - e.preventDefault(); - const file = item.getAsFile(); - - if (file) { - try { - if (!imageEnabled) { - Toast.warning({ - content: t('请先在设置中启用图片功能'), - duration: 3, - }); - return; - } + for (let i = 0; i < items.length; i++) { + const item = items[i]; - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target.result; - - if (onPasteImage) { - onPasteImage(base64); - Toast.success({ - content: t('图片已添加'), - duration: 2, - }); - } else { - Toast.error({ - content: t('无法添加图片'), - duration: 2, + if (item.type.indexOf('image') !== -1) { + e.preventDefault(); + const file = item.getAsFile(); + + if (file) { + try { + if (!imageEnabled) { + Toast.warning({ + content: t('请先在设置中启用图片功能'), + duration: 3, }); + return; } - }; - reader.onerror = () => { - console.error('Failed to read image file:', reader.error); + + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target.result; + + if (onPasteImage) { + onPasteImage(base64); + Toast.success({ + content: t('图片已添加'), + duration: 2, + }); + } else { + Toast.error({ + content: t('无法添加图片'), + duration: 2, + }); + } + }; + reader.onerror = () => { + console.error('Failed to read image file:', reader.error); + Toast.error({ + content: t('粘贴图片失败'), + duration: 2, + }); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Failed to paste image:', error); Toast.error({ content: t('粘贴图片失败'), duration: 2, }); - }; - reader.readAsDataURL(file); - } catch (error) { - console.error('Failed to paste image:', error); - Toast.error({ - content: t('粘贴图片失败'), - duration: 2, - }); + } } + break; } - break; } - } - }, [onPasteImage, imageEnabled, t]); + }, + [onPasteImage, imageEnabled, t], + ); useEffect(() => { const container = containerRef.current; diff --git a/web/src/components/playground/CustomRequestEditor.jsx b/web/src/components/playground/CustomRequestEditor.jsx index 786ccdc4a..68f0ae871 100644 --- a/web/src/components/playground/CustomRequestEditor.jsx +++ b/web/src/components/playground/CustomRequestEditor.jsx @@ -140,7 +140,9 @@ const CustomRequestEditor = ({ {/* 提示信息 */} } className='!rounded-lg' closeIcon={null} @@ -201,7 +203,9 @@ const CustomRequestEditor = ({ )} - {t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')} + {t( + '请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。', + )}
diff --git a/web/src/components/playground/DebugPanel.jsx b/web/src/components/playground/DebugPanel.jsx index ee4a0b60d..28de0153b 100644 --- a/web/src/components/playground/DebugPanel.jsx +++ b/web/src/components/playground/DebugPanel.jsx @@ -191,10 +191,7 @@ const DebugPanel = ({ itemKey='response' > {debugData.sseMessages && debugData.sseMessages.length > 0 ? ( - + ) : ( { const stats = useMemo(() => { const total = parsedSSEData.length; - const errors = parsedSSEData.filter(item => item.error).length; - const done = parsedSSEData.filter(item => item.isDone).length; + const errors = parsedSSEData.filter((item) => item.error).length; + const done = parsedSSEData.filter((item) => item.isDone).length; const valid = total - errors - done; return { total, errors, done, valid }; }, [parsedSSEData]); const handleToggleAll = useCallback(() => { - setExpandedKeys(prev => { + setExpandedKeys((prev) => { if (prev.length === parsedSSEData.length) { return []; } else { - return parsedSSEData.map(item => item.key); + return parsedSSEData.map((item) => item.key); } }); }, [parsedSSEData]); @@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => { const handleCopyAll = useCallback(async () => { try { const allData = parsedSSEData - .map(item => (item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw)) + .map((item) => + item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw, + ) .join('\n\n'); await copy(allData); @@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => { } }, [parsedSSEData, t]); - const handleCopySingle = useCallback(async (item) => { - try { - const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw; - await copy(textToCopy); - Toast.success(t('已复制')); - } catch (err) { - Toast.error(t('复制失败')); - } - }, [t]); + const handleCopySingle = useCallback( + async (item) => { + try { + const textToCopy = item.parsed + ? JSON.stringify(item.parsed, null, 2) + : item.raw; + await copy(textToCopy); + Toast.success(t('已复制')); + } catch (err) { + Toast.error(t('复制失败')); + } + }, + [t], + ); const renderSSEItem = (item) => { if (item.isDone) { @@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => { {item.parsed?.choices?.[0] && (
{item.parsed.choices[0].delta?.content && ( - + )} {item.parsed.choices[0].delta?.reasoning_content && ( )} {item.parsed.choices[0].finish_reason && ( - + )} {item.parsed.usage && ( - )}
@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => { {t('SSE数据流')} - {stats.errors > 0 && } + {stats.errors > 0 && ( + + )}
@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => { {copied ? t('已复制') : t('复制全部')} - +
@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => { ) : ( <> - {item.parsed?.id || item.parsed?.object || t('SSE 事件')} + {item.parsed?.id || + item.parsed?.object || + t('SSE 事件')} {item.parsed?.choices?.[0]?.delta && ( - • {Object.keys(item.parsed.choices[0].delta).filter(k => item.parsed.choices[0].delta[k]).join(', ')} + •{' '} + {Object.keys(item.parsed.choices[0].delta) + .filter((k) => item.parsed.choices[0].delta[k]) + .join(', ')} )} diff --git a/web/src/components/settings/HttpStatusCodeRulesInput.jsx b/web/src/components/settings/HttpStatusCodeRulesInput.jsx index 361bc19e6..5faf93af9 100644 --- a/web/src/components/settings/HttpStatusCodeRulesInput.jsx +++ b/web/src/components/settings/HttpStatusCodeRulesInput.jsx @@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) { ); } - diff --git a/web/src/components/settings/ModelDeploymentSetting.jsx b/web/src/components/settings/ModelDeploymentSetting.jsx index 941f640a4..c872f631d 100644 --- a/web/src/components/settings/ModelDeploymentSetting.jsx +++ b/web/src/components/settings/ModelDeploymentSetting.jsx @@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => { 'model_deployment.ionet.api_key': '', 'model_deployment.ionet.enabled': false, }; - + data.forEach((item) => { if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) { newInputs[item.key] = toBoolean(item.value); @@ -82,4 +82,4 @@ const ModelDeploymentSetting = () => { ); }; -export default ModelDeploymentSetting; \ No newline at end of file +export default ModelDeploymentSetting; diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 9ee5fd007..171c29f26 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -71,7 +71,8 @@ const OperationSetting = () => { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', - AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', + AutomaticRetryStatusCodes: + '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, 'checkin_setting.enabled': false, diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx index 646d21e74..f8e0b5375 100644 --- a/web/src/components/settings/OtherSetting.jsx +++ b/web/src/components/settings/OtherSetting.jsx @@ -378,13 +378,15 @@ const OtherSetting = () => { - @@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => { /> - {t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')} + {t( + '说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。', + )} diff --git a/web/src/components/table/channels/modals/CodexUsageModal.jsx b/web/src/components/table/channels/modals/CodexUsageModal.jsx index 16ad07610..5e1317ac6 100644 --- a/web/src/components/table/channels/modals/CodexUsageModal.jsx +++ b/web/src/components/table/channels/modals/CodexUsageModal.jsx @@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Modal, Button, Progress, Tag, Typography, Spin } from '@douyinfe/semi-ui'; +import { + Modal, + Button, + Progress, + Tag, + Typography, + Spin, +} from '@douyinfe/semi-ui'; import { API, showError } from '../../../../helpers'; const { Text } = Typography; @@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
{statusTag} -
@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
{tt('获取用量失败')}
-
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 683362c32..141cd5626 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -2000,171 +2000,180 @@ const EditChannelModal = (props) => { autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} disabled={isIonetLocked} - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + )} - {isEdit && ( - - )} - {batchExtra} -
- } - showClear - /> - ) - ) : ( - <> - {inputs.type === 57 ? ( - <> - handleInputChange('key', value)} - disabled={isIonetLocked} - extraText={ -
- - {t( - '仅支持 JSON 对象,必须包含 access_token 与 account_id', - )} - + {batchExtra} +
+ } + showClear + /> + ) + ) : ( + <> + {inputs.type === 57 ? ( + <> + + handleInputChange('key', value) + } + disabled={isIonetLocked} + extraText={ +
+ + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} + - + + + {isEdit && ( + + )} + + {isEdit && ( + + )} + {batchExtra} + +
+ } + autosize + showClear + /> + + setCodexOAuthModalVisible(false)} + onSuccess={handleCodexOAuthGenerated} + /> + + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + - {isEdit && ( - - )} - {isEdit && ( - - )} - {batchExtra}
- } - autosize - showClear - /> - - setCodexOAuthModalVisible(false)} - onSuccess={handleCodexOAuthGenerated} - /> - - ) : inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - - - -
- )} + )} {batch && ( { {/* Header: Advanced Settings */}
- +
@@ -549,9 +553,7 @@ const EditTagModal = (props) => { field='param_override' label={t('参数覆盖')} placeholder={ - t( - '此项可选,用于覆盖请求参数。不支持覆盖 stream 参数', - ) + + t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') + '\n' + t('旧格式(直接覆盖):') + '\n{\n "temperature": 0,\n "max_tokens": 1000\n}' + diff --git a/web/src/components/table/channels/modals/ModelSelectModal.jsx b/web/src/components/table/channels/modals/ModelSelectModal.jsx index eda7f80b5..b38580b66 100644 --- a/web/src/components/table/channels/modals/ModelSelectModal.jsx +++ b/web/src/components/table/channels/modals/ModelSelectModal.jsx @@ -104,7 +104,9 @@ const ModelSelectModal = ({ }, [normalizedRedirectModels, normalizedSelectedSet]); const filteredModels = models.filter((m) => - String(m || '').toLowerCase().includes(keyword.toLowerCase()), + String(m || '') + .toLowerCase() + .includes(keyword.toLowerCase()), ); // 分类模型:新获取的模型和已有模型 diff --git a/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx index f462292a3..5e90b153b 100644 --- a/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx +++ b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx @@ -30,7 +30,7 @@ const ConfirmationDialog = ({ type = 'danger', deployment, t, - loading = false + loading = false, }) => { const [confirmText, setConfirmText] = useState(''); @@ -66,17 +66,17 @@ const ConfirmationDialog = ({ okButtonProps={{ disabled: !isConfirmed, type: type === 'danger' ? 'danger' : 'primary', - loading + loading, }} width={480} > -
- +
+ {t('此操作具有风险,请确认要继续执行')}。 {t('请输入部署名称以完成二次确认')}: - + {requiredText || t('未知部署')} @@ -87,7 +87,7 @@ const ConfirmationDialog = ({ autoFocus /> {!isConfirmed && confirmText && ( - + {t('部署名称不匹配,请检查后重新输入')} )} diff --git a/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx index 3b357bc94..e4e9b7bb9 100644 --- a/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx +++ b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx @@ -130,9 +130,7 @@ const ExtendDurationModal = ({ ? details.locations .map((location) => Number( - location?.id ?? - location?.location_id ?? - location?.locationId, + location?.id ?? location?.location_id ?? location?.locationId, ), ) .filter((id) => Number.isInteger(id) && id > 0) @@ -181,9 +179,7 @@ const ExtendDurationModal = ({ } else { const message = response.data.message || ''; setPriceEstimation(null); - setPriceError( - t('价格计算失败') + (message ? `: ${message}` : ''), - ); + setPriceError(t('价格计算失败') + (message ? `: ${message}` : '')); } } catch (error) { if (costRequestIdRef.current !== requestId) { @@ -192,9 +188,7 @@ const ExtendDurationModal = ({ const message = error?.response?.data?.message || error.message || ''; setPriceEstimation(null); - setPriceError( - t('价格计算失败') + (message ? `: ${message}` : ''), - ); + setPriceError(t('价格计算失败') + (message ? `: ${message}` : '')); } finally { if (costRequestIdRef.current === requestId) { setCostLoading(false); @@ -269,11 +263,8 @@ const ExtendDurationModal = ({ const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`; const priceData = priceEstimation || {}; - const breakdown = - priceData.price_breakdown || priceData.PriceBreakdown || {}; - const currencyLabel = ( - priceData.currency || priceData.Currency || 'USDC' - ) + const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {}; + const currencyLabel = (priceData.currency || priceData.Currency || 'USDC') .toString() .toUpperCase(); @@ -316,7 +307,10 @@ const ExtendDurationModal = ({ confirmLoading={loading} okButtonProps={{ disabled: - !deployment?.id || detailsLoading || !durationHours || durationHours < 1, + !deployment?.id || + detailsLoading || + !durationHours || + durationHours < 1, }} width={600} className='extend-duration-modal' @@ -357,9 +351,7 @@ const ExtendDurationModal = ({

{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}

-

- {t('延长操作一旦确认无法撤销,费用将立即扣除。')} -

+

{t('延长操作一旦确认无法撤销,费用将立即扣除。')}

} /> @@ -370,7 +362,9 @@ const ExtendDurationModal = ({ onValueChange={(values) => { if (values.duration_hours !== undefined) { const numericValue = Number(values.duration_hours); - setDurationHours(Number.isFinite(numericValue) ? numericValue : 0); + setDurationHours( + Number.isFinite(numericValue) ? numericValue : 0, + ); } }} > diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index cc9f51b0e..fce482014 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -378,7 +378,12 @@ const EditTokenModal = (props) => { /> )} - + { placeholder={t('允许的IP,一行一个,不填写则不限制')} autosize rows={1} - extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')} + extraText={t( + '请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用', + )} showClear style={{ width: '100%' }} /> diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 2fb0cde8b..f298fa078 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Avatar, + Button, Space, Tag, Tooltip, @@ -71,6 +72,34 @@ function formatRatio(ratio) { return String(ratio); } +function buildChannelAffinityTooltip(affinity, t) { + if (!affinity) { + return null; + } + + const keySource = affinity.key_source || '-'; + const keyPath = affinity.key_path || affinity.key_key || '-'; + const keyHint = affinity.key_hint || ''; + const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : ''; + const keyText = `${keySource}:${keyPath}${keyFp}`; + + const lines = [ + t('渠道亲和性'), + `${t('规则')}:${affinity.rule_name || '-'}`, + `${t('分组')}:${affinity.selected_group || '-'}`, + `${t('Key')}:${keyText}`, + ...(keyHint ? [`${t('Key 摘要')}:${keyHint}`] : []), + ]; + + return ( +
+ {lines.map((line, i) => ( +
{line}
+ ))} +
+ ); +} + // Render functions function renderType(type, t) { switch (type) { @@ -250,6 +279,7 @@ export const getLogsColumns = ({ COLUMN_KEYS, copyText, showUserInfoFunc, + openChannelAffinityUsageCacheModal, isAdminUser, }) => { return [ @@ -532,42 +562,39 @@ export const getLogsColumns = ({ return isAdminUser ? (
{content}
- {affinity ? ( - - {t('渠道亲和性')} -
- - {t('规则')}:{affinity.rule_name || '-'} - -
-
- - {t('分组')}:{affinity.selected_group || '-'} - -
-
- - {t('Key')}: - {(affinity.key_source || '-') + - ':' + - (affinity.key_path || affinity.key_key || '-') + - (affinity.key_fp ? `#${affinity.key_fp}` : '')} - + {affinity ? ( + + {buildChannelAffinityTooltip(affinity, t)} +
+
-
- } - > - - - - - {t('优选')} - - - -
+
+ } + > + + + + + {t('优选')} + + + + ) : null} ) : ( diff --git a/web/src/components/table/usage-logs/UsageLogsTable.jsx b/web/src/components/table/usage-logs/UsageLogsTable.jsx index 5218a622e..103dc9302 100644 --- a/web/src/components/table/usage-logs/UsageLogsTable.jsx +++ b/web/src/components/table/usage-logs/UsageLogsTable.jsx @@ -40,6 +40,7 @@ const LogsTable = (logsData) => { handlePageSizeChange, copyText, showUserInfoFunc, + openChannelAffinityUsageCacheModal, hasExpandableRows, isAdminUser, t, @@ -53,9 +54,17 @@ const LogsTable = (logsData) => { COLUMN_KEYS, copyText, showUserInfoFunc, + openChannelAffinityUsageCacheModal, isAdminUser, }); - }, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]); + }, [ + t, + COLUMN_KEYS, + copyText, + showUserInfoFunc, + openChannelAffinityUsageCacheModal, + isAdminUser, + ]); // Filter columns based on visibility settings const getVisibleColumns = () => { diff --git a/web/src/components/table/usage-logs/index.jsx b/web/src/components/table/usage-logs/index.jsx index 21e934340..7d2d47c37 100644 --- a/web/src/components/table/usage-logs/index.jsx +++ b/web/src/components/table/usage-logs/index.jsx @@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions'; import LogsFilters from './UsageLogsFilters'; import ColumnSelectorModal from './modals/ColumnSelectorModal'; import UserInfoModal from './modals/UserInfoModal'; +import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal'; import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData'; import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { createCardProPagination } from '../../../helpers/utils'; @@ -37,6 +38,7 @@ const LogsPage = () => { {/* Modals */} + {/* Main Content */} . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Modal, Descriptions, Spin, Typography } from '@douyinfe/semi-ui'; +import { API, showError, timestamp2string } from '../../../../helpers'; + +const { Text } = Typography; + +function formatRate(hit, total) { + if (!total || total <= 0) return '-'; + const r = (Number(hit || 0) / Number(total || 0)) * 100; + if (!Number.isFinite(r)) return '-'; + return `${r.toFixed(2)}%`; +} + +function formatTokenRate(n, d) { + const nn = Number(n || 0); + const dd = Number(d || 0); + if (!dd || dd <= 0) return '-'; + const r = (nn / dd) * 100; + if (!Number.isFinite(r)) return '-'; + return `${r.toFixed(2)}%`; +} + +const ChannelAffinityUsageCacheModal = ({ + t, + showChannelAffinityUsageCacheModal, + setShowChannelAffinityUsageCacheModal, + channelAffinityUsageCacheTarget, +}) => { + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const requestSeqRef = useRef(0); + + const params = useMemo(() => { + const x = channelAffinityUsageCacheTarget || {}; + return { + rule_name: (x.rule_name || '').trim(), + using_group: (x.using_group || '').trim(), + key_hint: (x.key_hint || '').trim(), + key_fp: (x.key_fp || '').trim(), + }; + }, [channelAffinityUsageCacheTarget]); + + useEffect(() => { + if (!showChannelAffinityUsageCacheModal) { + requestSeqRef.current += 1; // invalidate inflight request + setLoading(false); + setStats(null); + return; + } + if (!params.rule_name || !params.key_fp) { + setLoading(false); + setStats(null); + return; + } + + const reqSeq = (requestSeqRef.current += 1); + setStats(null); + setLoading(true); + (async () => { + try { + const res = await API.get('/api/log/channel_affinity_usage_cache', { + params, + disableDuplicate: true, + }); + if (reqSeq !== requestSeqRef.current) return; + const { success, message, data } = res.data || {}; + if (!success) { + setStats(null); + showError(t(message || '请求失败')); + return; + } + setStats(data || {}); + } catch (e) { + if (reqSeq !== requestSeqRef.current) return; + setStats(null); + showError(t('请求失败')); + } finally { + if (reqSeq !== requestSeqRef.current) return; + setLoading(false); + } + })(); + }, [ + showChannelAffinityUsageCacheModal, + params.rule_name, + params.using_group, + params.key_hint, + params.key_fp, + t, + ]); + + const rows = useMemo(() => { + const s = stats || {}; + const hit = Number(s.hit || 0); + const total = Number(s.total || 0); + const windowSeconds = Number(s.window_seconds || 0); + const lastSeenAt = Number(s.last_seen_at || 0); + const promptTokens = Number(s.prompt_tokens || 0); + const completionTokens = Number(s.completion_tokens || 0); + const totalTokens = Number(s.total_tokens || 0); + const cachedTokens = Number(s.cached_tokens || 0); + const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0); + + return [ + { key: t('规则'), value: s.rule_name || params.rule_name || '-' }, + { key: t('分组'), value: s.using_group || params.using_group || '-' }, + { + key: t('Key 摘要'), + value: params.key_hint || '-', + }, + { + key: t('Key 指纹'), + value: s.key_fp || params.key_fp || '-', + }, + { key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' }, + { + key: t('命中率'), + value: `${hit}/${total} (${formatRate(hit, total)})`, + }, + { + key: t('Prompt tokens'), + value: promptTokens, + }, + { + key: t('Cached tokens'), + value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`, + }, + { + key: t('Prompt cache hit tokens'), + value: promptCacheHitTokens, + }, + { + key: t('Completion tokens'), + value: completionTokens, + }, + { + key: t('Total tokens'), + value: totalTokens, + }, + { + key: t('最近一次'), + value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-', + }, + ]; + }, [stats, params, t]); + + return ( + setShowChannelAffinityUsageCacheModal(false)} + footer={null} + centered + closable + maskClosable + width={640} + > +
+
+ + {t( + '命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。', + )} + +
+ + {stats ? ( + + ) : ( +
+ + {loading ? t('加载中...') : t('暂无数据')} + +
+ )} +
+
+
+ ); +}; + +export default ChannelAffinityUsageCacheModal; diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index 15c37dffb..264c965b7 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -87,7 +87,12 @@ const RechargeCard = ({ const onlineFormApiRef = useRef(null); const redeemFormApiRef = useRef(null); const showAmountSkeleton = useMinimumLoadingTime(amountLoading); - console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts); + console.log( + ' enabled screem ?', + enableCreemTopUp, + ' products ?', + creemProducts, + ); return ( {/* 卡片头部 */} @@ -503,7 +508,8 @@ const RechargeCard = ({ {t('充值额度')}: {product.quota}
- {product.currency === 'EUR' ? '€' : '$'}{product.price} + {product.currency === 'EUR' ? '€' : '$'} + {product.price}
))} diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 7618d7778..2392b45ba 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -651,7 +651,8 @@ const TopUp = () => { {t('产品名称')}:{selectedCreemProduct.name}

- {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price} + {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'} + {selectedCreemProduct.price}

{t('充值额度')}:{selectedCreemProduct.quota} diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 6e09bf43c..666d6b7b0 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) { if (shouldLogout) { try { await API.get('/api/user/logout', { skipErrorHandler: true }); - } catch (err) { - - } + } catch (err) {} localStorage.removeItem('user'); updateAPI(); } diff --git a/web/src/helpers/dashboard.jsx b/web/src/helpers/dashboard.jsx index 8df375f11..d93d04619 100644 --- a/web/src/helpers/dashboard.jsx +++ b/web/src/helpers/dashboard.jsx @@ -261,7 +261,7 @@ export const processRawData = ( }; // 检查数据是否跨年 - const showYear = isDataCrossYear(data.map(item => item.created_at)); + const showYear = isDataCrossYear(data.map((item) => item.created_at)); data.forEach((item) => { result.uniqueModels.add(item.model_name); @@ -269,7 +269,11 @@ export const processRawData = ( result.totalQuota += item.quota; result.totalTimes += item.count; - const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear); + const timeKey = timestamp2string1( + item.created_at, + dataExportDefaultTime, + showYear, + ); if (!result.timePoints.includes(timeKey)) { result.timePoints.push(timeKey); } @@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => { const aggregatedData = new Map(); // 检查数据是否跨年 - const showYear = isDataCrossYear(data.map(item => item.created_at)); + const showYear = isDataCrossYear(data.map((item) => item.created_at)); data.forEach((item) => { - const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear); + const timeKey = timestamp2string1( + item.created_at, + dataExportDefaultTime, + showYear, + ); const modelKey = item.model_name; const key = `${timeKey}-${modelKey}`; @@ -372,7 +380,7 @@ export const generateChartTimePoints = ( ); const showYear = isDataCrossYear(generatedTimestamps); - chartTimePoints = generatedTimestamps.map(ts => + chartTimePoints = generatedTimestamps.map((ts) => timestamp2string1(ts, dataExportDefaultTime, showYear), ); } diff --git a/web/src/helpers/statusCodeRules.js b/web/src/helpers/statusCodeRules.js index a0d5e75f9..fc60a5a59 100644 --- a/web/src/helpers/statusCodeRules.js +++ b/web/src/helpers/statusCodeRules.js @@ -1,3 +1,21 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ export function parseHttpStatusCodeRules(input) { const raw = (input ?? '').toString().trim(); if (raw.length === 0) { @@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) { } const merged = mergeRanges(ranges); - const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)); + const tokens = merged.map((r) => + r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`, + ); const normalized = tokens.join(','); return { @@ -78,7 +98,9 @@ function isNumber(s) { function mergeRanges(ranges) { if (!Array.isArray(ranges) || ranges.length === 0) return []; - const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end)); + const sorted = [...ranges].sort((a, b) => + a.start !== b.start ? a.start - b.start : a.end - b.end, + ); const merged = [sorted[0]]; for (let i = 1; i < sorted.length; i += 1) { diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index a54676e47..5ce83e678 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -217,7 +217,11 @@ export function timestamp2string(timestamp) { ); } -export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', showYear = false) { +export function timestamp2string1( + timestamp, + dataExportDefaultTime = 'hour', + showYear = false, +) { let date = new Date(timestamp * 1000); let year = date.getFullYear(); let month = (date.getMonth() + 1).toString(); @@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho nextDay = '0' + nextDay; } // 周视图结束日期也仅在跨年时显示年份 - let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay; + let nextStr = showYear + ? nextWeekYear + '-' + nextMonth + '-' + nextDay + : nextMonth + '-' + nextDay; str += ' - ' + nextStr; } return str; @@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho // 检查时间戳数组是否跨年 export function isDataCrossYear(timestamps) { if (!timestamps || timestamps.length === 0) return false; - const years = new Set(timestamps.map(ts => new Date(ts * 1000).getFullYear())); + const years = new Set( + timestamps.map((ts) => new Date(ts * 1000).getFullYear()), + ); return years.size > 1; } diff --git a/web/src/hooks/model-deployments/useModelDeploymentSettings.js b/web/src/hooks/model-deployments/useModelDeploymentSettings.js index c53fe55b8..e3578006b 100644 --- a/web/src/hooks/model-deployments/useModelDeploymentSettings.js +++ b/web/src/hooks/model-deployments/useModelDeploymentSettings.js @@ -55,13 +55,20 @@ export const useModelDeploymentSettings = () => { const isIoNetEnabled = settings['model_deployment.ionet.enabled']; - const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => { + const buildConnectionError = ( + rawMessage, + fallbackMessage = 'Connection failed', + ) => { const message = (rawMessage || fallbackMessage).trim(); const normalized = message.toLowerCase(); if (normalized.includes('expired') || normalized.includes('expire')) { return { type: 'expired', message }; } - if (normalized.includes('invalid') || normalized.includes('unauthorized') || normalized.includes('api key')) { + if ( + normalized.includes('invalid') || + normalized.includes('unauthorized') || + normalized.includes('api key') + ) { return { type: 'invalid', message }; } if (normalized.includes('network') || normalized.includes('timeout')) { @@ -85,7 +92,11 @@ export const useModelDeploymentSettings = () => { } const message = response?.data?.message || 'Connection failed'; - setConnectionState({ loading: false, ok: false, error: buildConnectionError(message) }); + setConnectionState({ + loading: false, + ok: false, + error: buildConnectionError(message), + }); } catch (error) { if (error?.code === 'ERR_NETWORK') { setConnectionState({ @@ -95,8 +106,13 @@ export const useModelDeploymentSettings = () => { }); return; } - const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error'; - setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') }); + const rawMessage = + error?.response?.data?.message || error?.message || 'Unknown error'; + setConnectionState({ + loading: false, + ok: false, + error: buildConnectionError(rawMessage, 'Connection failed'), + }); } }, []); diff --git a/web/src/hooks/playground/useApiRequest.jsx b/web/src/hooks/playground/useApiRequest.jsx index 12db9f5ca..8ec50cf45 100644 --- a/web/src/hooks/playground/useApiRequest.jsx +++ b/web/src/hooks/playground/useApiRequest.jsx @@ -231,7 +231,10 @@ export const useApiRequest = ( if (data.choices?.[0]) { const choice = data.choices[0]; let content = choice.message?.content || ''; - let reasoningContent = choice.message?.reasoning_content || choice.message?.reasoning || ''; + let reasoningContent = + choice.message?.reasoning_content || + choice.message?.reasoning || + ''; const processed = processThinkTags(content, reasoningContent); @@ -318,8 +321,8 @@ export const useApiRequest = ( isStreamComplete = true; // 标记流正常完成 source.close(); sseSourceRef.current = null; - setDebugData((prev) => ({ - ...prev, + setDebugData((prev) => ({ + ...prev, response: responseData, sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记 isStreaming: false, diff --git a/web/src/hooks/playground/usePlaygroundState.js b/web/src/hooks/playground/usePlaygroundState.js index 9574a4c3c..79be10134 100644 --- a/web/src/hooks/playground/usePlaygroundState.js +++ b/web/src/hooks/playground/usePlaygroundState.js @@ -36,18 +36,23 @@ import { processIncompleteThinkTags } from '../../helpers'; export const usePlaygroundState = () => { const { t } = useTranslation(); - + // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息 const [savedConfig] = useState(() => loadConfig()); const [initialMessages] = useState(() => { const loaded = loadMessages(); // 检查是否是旧的中文默认消息,如果是则清除 - if (loaded && loaded.length === 2 && loaded[0].id === '2' && loaded[1].id === '3') { - const hasOldChinese = - loaded[0].content === '你好' || + if ( + loaded && + loaded.length === 2 && + loaded[0].id === '2' && + loaded[1].id === '3' + ) { + const hasOldChinese = + loaded[0].content === '你好' || loaded[1].content === '你好,请问有什么可以帮助您的吗?' || loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?'; - + if (hasOldChinese) { // 清除旧的默认消息 localStorage.removeItem('playground_messages'); @@ -81,8 +86,10 @@ export const usePlaygroundState = () => { const [status, setStatus] = useState({}); // 消息相关状态 - 使用加载的消息或默认消息初始化 - const [message, setMessage] = useState(() => initialMessages || getDefaultMessages(t)); - + const [message, setMessage] = useState( + () => initialMessages || getDefaultMessages(t), + ); + // 当语言改变时,如果是默认消息则更新 useEffect(() => { // 只在没有保存的消息时才更新默认消息 diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 4c7fa147c..13437774c 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -112,6 +112,14 @@ export const useLogsData = () => { const [showUserInfo, setShowUserInfoModal] = useState(false); const [userInfoData, setUserInfoData] = useState(null); + // Channel affinity usage cache stats modal state (admin only) + const [ + showChannelAffinityUsageCacheModal, + setShowChannelAffinityUsageCacheModal, + ] = useState(false); + const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] = + useState(null); + // Load saved column preferences from localStorage useEffect(() => { const savedColumns = localStorage.getItem(STORAGE_KEY); @@ -304,6 +312,17 @@ export const useLogsData = () => { } }; + const openChannelAffinityUsageCacheModal = (affinity) => { + const a = affinity || {}; + setChannelAffinityUsageCacheTarget({ + rule_name: a.rule_name || a.reason || '', + using_group: a.using_group || '', + key_hint: a.key_hint || '', + key_fp: a.key_fp || '', + }); + setShowChannelAffinityUsageCacheModal(true); + }; + // Format logs data const setLogsFormat = (logs) => { const requestConversionDisplayValue = (conversionChain) => { @@ -372,9 +391,13 @@ export const useLogsData = () => { other.cache_ratio || 1.0, other.cache_creation_ratio || 1.0, other.cache_creation_tokens_5m || 0, - other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, + other.cache_creation_ratio_5m || + other.cache_creation_ratio || + 1.0, other.cache_creation_tokens_1h || 0, - other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, + other.cache_creation_ratio_1h || + other.cache_creation_ratio || + 1.0, ) : renderLogContent( other?.model_ratio, @@ -524,8 +547,8 @@ export const useLogsData = () => { localCountMode = t('上游返回'); } expandDataLocal.push({ - key: t('计费模式'), - value: localCountMode, + key: t('计费模式'), + value: localCountMode, }); } expandDatesLocal[logs[i].key] = expandDataLocal; @@ -680,6 +703,12 @@ export const useLogsData = () => { userInfoData, showUserInfoFunc, + // Channel affinity usage cache stats modal + showChannelAffinityUsageCacheModal, + setShowChannelAffinityUsageCacheModal, + channelAffinityUsageCacheTarget, + openChannelAffinityUsageCacheModal, + // Functions loadLogs, handlePageChange, diff --git a/web/src/pages/Playground/index.jsx b/web/src/pages/Playground/index.jsx index 6b7f8d16a..68a97c335 100644 --- a/web/src/pages/Playground/index.jsx +++ b/web/src/pages/Playground/index.jsx @@ -438,14 +438,17 @@ const Playground = () => { }, [setMessage, saveMessagesImmediately]); // 处理粘贴图片 - const handlePasteImage = useCallback((base64Data) => { - if (!inputs.imageEnabled) { - return; - } - // 添加图片到 imageUrls 数组 - const newUrls = [...(inputs.imageUrls || []), base64Data]; - handleInputChange('imageUrls', newUrls); - }, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]); + const handlePasteImage = useCallback( + (base64Data) => { + if (!inputs.imageEnabled) { + return; + } + // 添加图片到 imageUrls 数组 + const newUrls = [...(inputs.imageUrls || []), base64Data]; + handleInputChange('imageUrls', newUrls); + }, + [inputs.imageEnabled, inputs.imageUrls, handleInputChange], + ); // Playground Context 值 const playgroundContextValue = { @@ -457,10 +460,10 @@ const Playground = () => { return (

- - {(showSettings || !isMobile) && ( - + {(showSettings || !isMobile) && ( + { : 'relative z-[1] w-80 h-[calc(100vh-66px)]' } `} - width={isMobile ? '100%' : 320} - > - setShowSettings(false)} - onConfigImport={handleConfigImport} - onConfigReset={handleConfigReset} - onCustomRequestModeChange={setCustomRequestMode} - onCustomRequestBodyChange={setCustomRequestBody} - previewPayload={previewPayload} - messages={message} - /> - - )} - - -
-
- + setShowDebugPanel(!showDebugPanel)} - renderCustomChatContent={renderCustomChatContent} - renderChatBoxAction={renderChatBoxAction} + customRequestMode={customRequestMode} + customRequestBody={customRequestBody} + onInputChange={handleInputChange} + onParameterToggle={handleParameterToggle} + onCloseSettings={() => setShowSettings(false)} + onConfigImport={handleConfigImport} + onConfigReset={handleConfigReset} + onCustomRequestModeChange={setCustomRequestMode} + onCustomRequestBodyChange={setCustomRequestBody} + previewPayload={previewPayload} + messages={message} /> + + )} + + +
+
+ setShowDebugPanel(!showDebugPanel)} + renderCustomChatContent={renderCustomChatContent} + renderChatBoxAction={renderChatBoxAction} + /> +
+ + {/* 调试面板 - 桌面端 */} + {showDebugPanel && !isMobile && ( +
+ +
+ )}
- {/* 调试面板 - 桌面端 */} - {showDebugPanel && !isMobile && ( -
+ {/* 调试面板 - 移动端覆盖层 */} + {showDebugPanel && isMobile && ( +
setShowDebugPanel(false)} customRequestMode={customRequestMode} />
)} -
- {/* 调试面板 - 移动端覆盖层 */} - {showDebugPanel && isMobile && ( -
- setShowDebugPanel(false)} - customRequestMode={customRequestMode} - /> -
- )} - - {/* 浮动按钮 */} - setShowSettings(!showSettings)} - onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)} - /> -
- -
+ {/* 浮动按钮 */} + setShowSettings(!showSettings)} + onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)} + /> + + +
); }; diff --git a/web/src/pages/PrivacyPolicy/index.jsx b/web/src/pages/PrivacyPolicy/index.jsx index 026290b18..d2c4a872d 100644 --- a/web/src/pages/PrivacyPolicy/index.jsx +++ b/web/src/pages/PrivacyPolicy/index.jsx @@ -26,12 +26,12 @@ const PrivacyPolicy = () => { return ( ); }; -export default PrivacyPolicy; \ No newline at end of file +export default PrivacyPolicy; diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/src/pages/Setting/Model/SettingGlobalModel.jsx index 9878875c7..4b8f9f4d9 100644 --- a/web/src/pages/Setting/Model/SettingGlobalModel.jsx +++ b/web/src/pages/Setting/Model/SettingGlobalModel.jsx @@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) { 'global.pass_through_request_enabled': value, }) } - extraText={ - t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启') - } + extraText={t( + '开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启', + )} /> @@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) { - diff --git a/web/src/pages/Setting/Model/SettingGrokModel.jsx b/web/src/pages/Setting/Model/SettingGrokModel.jsx index 3fdf2ca09..3d721c798 100644 --- a/web/src/pages/Setting/Model/SettingGrokModel.jsx +++ b/web/src/pages/Setting/Model/SettingGrokModel.jsx @@ -49,8 +49,7 @@ export default function SettingGrokModel(props) { .validate() .then(() => { const updateArray = compareObjects(inputs, inputsRow); - if (!updateArray.length) - return showWarning(t('你似乎并没有修改什么')); + if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); const requestQueue = updateArray.map((item) => { const value = String(inputs[item.key]); diff --git a/web/src/pages/Setting/Model/SettingModelDeployment.jsx b/web/src/pages/Setting/Model/SettingModelDeployment.jsx index 88a043b10..fdfbb448e 100644 --- a/web/src/pages/Setting/Model/SettingModelDeployment.jsx +++ b/web/src/pages/Setting/Model/SettingModelDeployment.jsx @@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui'; +import { + Button, + Col, + Form, + Row, + Spin, + Card, + Typography, +} from '@douyinfe/semi-ui'; import { compareObjects, API, @@ -88,9 +96,7 @@ export default function SettingModelDeployment(props) { showError(t('网络连接失败,请检查网络设置或稍后重试')); } else { const rawMessage = - error?.response?.data?.message || - error?.message || - ''; + error?.response?.data?.message || error?.message || ''; const localizedMessage = rawMessage ? getLocalizedMessage(rawMessage) : t('未知错误'); @@ -104,7 +110,7 @@ export default function SettingModelDeployment(props) { function onSubmit() { const updateArray = compareObjects(inputs, inputsRow); if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); - + const requestQueue = updateArray.map((item) => { let value = String(inputs[item.key]); return API.put('/api/option/', { @@ -112,7 +118,7 @@ export default function SettingModelDeployment(props) { value, }); }); - + setLoading(true); Promise.all(requestQueue) .then((res) => { @@ -141,7 +147,7 @@ export default function SettingModelDeployment(props) { 'model_deployment.ionet.api_key': '', 'model_deployment.ionet.enabled': false, }; - + const currentInputs = {}; for (let key in defaultInputs) { if (props.options.hasOwnProperty(key)) { @@ -150,7 +156,7 @@ export default function SettingModelDeployment(props) { currentInputs[key] = defaultInputs[key]; } } - + setInputs(currentInputs); setInputsRow(structuredClone(currentInputs)); refForm.current?.setValues(currentInputs); @@ -165,9 +171,11 @@ export default function SettingModelDeployment(props) { getFormApi={(formAPI) => (refForm.current = formAPI)} style={{ marginBottom: 15 }} > - +
{t('模型部署设置')}
} @@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) { +
io.net
@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) { } disabled={!inputs['model_deployment.ionet.enabled']} extraText={t('请使用 Project 为 io.cloud 的密钥')} - mode="password" + mode='password' />
diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx index 86c2bc321..b89f99113 100644 --- a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx +++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx @@ -73,6 +73,7 @@ const RULE_TEMPLATES = { key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }], value_regex: '', ttl_seconds: 0, + skip_retry_on_failure: false, include_using_group: true, include_rule_name: true, }, @@ -83,6 +84,7 @@ const RULE_TEMPLATES = { key_sources: [{ type: 'gjson', path: 'metadata.user_id' }], value_regex: '', ttl_seconds: 0, + skip_retry_on_failure: false, include_using_group: true, include_rule_name: true, }, @@ -112,6 +114,7 @@ const RULES_JSON_PLACEHOLDER = `[ ], "value_regex": "^[-0-9A-Za-z._:]{1,128}$", "ttl_seconds": 600, + "skip_retry_on_failure": false, "include_using_group": true, "include_rule_name": true } @@ -153,7 +156,12 @@ const normalizeKeySource = (src) => { const type = (src?.type || '').trim(); const key = (src?.key || '').trim(); const path = (src?.path || '').trim(); - return { type, key, path }; + + if (type === 'gjson') { + return { type, key: '', path }; + } + + return { type, key, path: '' }; }; const makeUniqueName = (existingNames, baseName) => { @@ -229,6 +237,7 @@ export default function SettingsChannelAffinity(props) { user_agent_include_text: (r.user_agent_include || []).join('\n'), value_regex: r.value_regex || '', ttl_seconds: Number(r.ttl_seconds || 0), + skip_retry_on_failure: !!r.skip_retry_on_failure, include_using_group: r.include_using_group ?? true, include_rule_name: r.include_rule_name ?? true, }; @@ -523,6 +532,7 @@ export default function SettingsChannelAffinity(props) { key_sources: [{ type: 'gjson', path: '' }], value_regex: '', ttl_seconds: 0, + skip_retry_on_failure: false, include_using_group: true, include_rule_name: true, }; @@ -583,6 +593,9 @@ export default function SettingsChannelAffinity(props) { ttl_seconds: Number(values.ttl_seconds || 0), include_using_group: !!values.include_using_group, include_rule_name: !!values.include_rule_name, + ...(values.skip_retry_on_failure + ? { skip_retry_on_failure: true } + : {}), ...(userAgentInclude.length > 0 ? { user_agent_include: userAgentInclude } : {}), @@ -1041,6 +1054,18 @@ export default function SettingsChannelAffinity(props) { + + + + + + {t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')} + + + diff --git a/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx b/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx index a5208a502..52476932b 100644 --- a/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx +++ b/web/src/pages/Setting/Operation/SettingsCreditLimit.jsx @@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) { setInputs({ ...inputs, diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx index 6e1743478..29b55e56c 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx @@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { - Button, - Col, - Form, - Row, - Spin, -} from '@douyinfe/semi-ui'; +import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; import { compareObjects, API, @@ -46,7 +40,8 @@ export default function SettingsMonitoring(props) { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', - AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', + AutomaticRetryStatusCodes: + '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10, }); diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx index 5f351c105..817bca51a 100644 --- a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx +++ b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx @@ -252,7 +252,11 @@ export default function SettingsSidebarModulesAdmin(props) { modules: [ { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') }, { key: 'models', title: t('模型管理'), description: t('AI模型配置') }, - { key: 'deployment', title: t('模型部署'), description: t('模型部署管理') }, + { + key: 'deployment', + title: t('模型部署'), + description: t('模型部署管理'), + }, { key: 'redemption', title: t('兑换码管理'), diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx index 32e2e6fbc..41de8d20e 100644 --- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx +++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx @@ -1,385 +1,422 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ import React, { useEffect, useState, useRef } from 'react'; import { - Banner, - Button, - Form, - Row, - Col, - Typography, - Spin, - Table, - Modal, - Input, - InputNumber, - Select, + Banner, + Button, + Form, + Row, + Col, + Typography, + Spin, + Table, + Modal, + Input, + InputNumber, + Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; -import { - API, - showError, - showSuccess, -} from '../../../helpers'; +import { API, showError, showSuccess } from '../../../helpers'; import { useTranslation } from 'react-i18next'; import { Plus, Trash2 } from 'lucide-react'; export default function SettingsPaymentGatewayCreem(props) { - const { t } = useTranslation(); - const [loading, setLoading] = useState(false); - const [inputs, setInputs] = useState({ - CreemApiKey: '', - CreemWebhookSecret: '', - CreemProducts: '[]', - CreemTestMode: false, - }); - const [originInputs, setOriginInputs] = useState({}); - const [products, setProducts] = useState([]); - const [showProductModal, setShowProductModal] = useState(false); - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + CreemApiKey: '', + CreemWebhookSecret: '', + CreemProducts: '[]', + CreemTestMode: false, + }); + const [originInputs, setOriginInputs] = useState({}); + const [products, setProducts] = useState([]); + const [showProductModal, setShowProductModal] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + productId: '', + price: 0, + quota: 0, + currency: 'USD', + }); + const formApiRef = useRef(null); + + useEffect(() => { + if (props.options && formApiRef.current) { + const currentInputs = { + CreemApiKey: props.options.CreemApiKey || '', + CreemWebhookSecret: props.options.CreemWebhookSecret || '', + CreemProducts: props.options.CreemProducts || '[]', + CreemTestMode: props.options.CreemTestMode === 'true', + }; + setInputs(currentInputs); + setOriginInputs({ ...currentInputs }); + formApiRef.current.setValues(currentInputs); + + // Parse products + try { + const parsedProducts = JSON.parse(currentInputs.CreemProducts); + setProducts(parsedProducts); + } catch (e) { + setProducts([]); + } + } + }, [props.options]); + + const handleFormChange = (values) => { + setInputs(values); + }; + + const submitCreemSetting = async () => { + setLoading(true); + try { + const options = []; + + if (inputs.CreemApiKey && inputs.CreemApiKey !== '') { + options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey }); + } + + if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') { + options.push({ + key: 'CreemWebhookSecret', + value: inputs.CreemWebhookSecret, + }); + } + + // Save test mode setting + options.push({ + key: 'CreemTestMode', + value: inputs.CreemTestMode ? 'true' : 'false', + }); + + // Save products as JSON string + options.push({ key: 'CreemProducts', value: JSON.stringify(products) }); + + // 发送请求 + const requestQueue = options.map((opt) => + API.put('/api/option/', { + key: opt.key, + value: opt.value, + }), + ); + + const results = await Promise.all(requestQueue); + + // 检查所有请求是否成功 + const errorResults = results.filter((res) => !res.data.success); + if (errorResults.length > 0) { + errorResults.forEach((res) => { + showError(res.data.message); + }); + } else { + showSuccess(t('更新成功')); + // 更新本地存储的原始值 + setOriginInputs({ ...inputs }); + props.refresh?.(); + } + } catch (error) { + showError(t('更新失败')); + } + setLoading(false); + }; + + const openProductModal = (product = null) => { + if (product) { + setEditingProduct(product); + setProductForm({ ...product }); + } else { + setEditingProduct(null); + setProductForm({ name: '', productId: '', price: 0, quota: 0, currency: 'USD', + }); + } + setShowProductModal(true); + }; + + const closeProductModal = () => { + setShowProductModal(false); + setEditingProduct(null); + setProductForm({ + name: '', + productId: '', + price: 0, + quota: 0, + currency: 'USD', }); - const formApiRef = useRef(null); + }; - useEffect(() => { - if (props.options && formApiRef.current) { - const currentInputs = { - CreemApiKey: props.options.CreemApiKey || '', - CreemWebhookSecret: props.options.CreemWebhookSecret || '', - CreemProducts: props.options.CreemProducts || '[]', - CreemTestMode: props.options.CreemTestMode === 'true', - }; - setInputs(currentInputs); - setOriginInputs({ ...currentInputs }); - formApiRef.current.setValues(currentInputs); + const saveProduct = () => { + if ( + !productForm.name || + !productForm.productId || + productForm.price <= 0 || + productForm.quota <= 0 || + !productForm.currency + ) { + showError(t('请填写完整的产品信息')); + return; + } - // Parse products - try { - const parsedProducts = JSON.parse(currentInputs.CreemProducts); - setProducts(parsedProducts); - } catch (e) { - setProducts([]); - } - } - }, [props.options]); + let newProducts = [...products]; + if (editingProduct) { + // 编辑现有产品 + const index = newProducts.findIndex( + (p) => p.productId === editingProduct.productId, + ); + if (index !== -1) { + newProducts[index] = { ...productForm }; + } + } else { + // 添加新产品 + if (newProducts.find((p) => p.productId === productForm.productId)) { + showError(t('产品ID已存在')); + return; + } + newProducts.push({ ...productForm }); + } - const handleFormChange = (values) => { - setInputs(values); - }; + setProducts(newProducts); + closeProductModal(); + }; - const submitCreemSetting = async () => { - setLoading(true); - try { - const options = []; + const deleteProduct = (productId) => { + const newProducts = products.filter((p) => p.productId !== productId); + setProducts(newProducts); + }; - if (inputs.CreemApiKey && inputs.CreemApiKey !== '') { - options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey }); - } + const columns = [ + { + title: t('产品名称'), + dataIndex: 'name', + key: 'name', + }, + { + title: t('产品ID'), + dataIndex: 'productId', + key: 'productId', + }, + { + title: t('展示价格'), + dataIndex: 'price', + key: 'price', + render: (price, record) => + `${record.currency === 'EUR' ? '€' : '$'}${price}`, + }, + { + title: t('充值额度'), + dataIndex: 'quota', + key: 'quota', + }, + { + title: t('操作'), + key: 'action', + render: (_, record) => ( +
+ +
+ ), + }, + ]; - if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') { - options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret }); - } + return ( + +
(formApiRef.current = api)} + > + + + {t('Creem 介绍')} + + Creem Official Site + +
+
+ - // Save test mode setting - options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' }); + + + + + + + + + + + - // Save products as JSON string - options.push({ key: 'CreemProducts', value: JSON.stringify(products) }); +
+
+ {t('产品配置')} + +
- // 发送请求 - const requestQueue = options.map(opt => - API.put('/api/option/', { - key: opt.key, - value: opt.value, - }) - ); - - const results = await Promise.all(requestQueue); - - // 检查所有请求是否成功 - const errorResults = results.filter(res => !res.data.success); - if (errorResults.length > 0) { - errorResults.forEach(res => { - showError(res.data.message); - }); - } else { - showSuccess(t('更新成功')); - // 更新本地存储的原始值 - setOriginInputs({ ...inputs }); - props.refresh?.(); - } - } catch (error) { - showError(t('更新失败')); - } - setLoading(false); - }; - - const openProductModal = (product = null) => { - if (product) { - setEditingProduct(product); - setProductForm({ ...product }); - } else { - setEditingProduct(null); - setProductForm({ - name: '', - productId: '', - price: 0, - quota: 0, - currency: 'USD', - }); - } - setShowProductModal(true); - }; - - const closeProductModal = () => { - setShowProductModal(false); - setEditingProduct(null); - setProductForm({ - name: '', - productId: '', - price: 0, - quota: 0, - currency: 'USD', - }); - }; - - const saveProduct = () => { - if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) { - showError(t('请填写完整的产品信息')); - return; - } - - let newProducts = [...products]; - if (editingProduct) { - // 编辑现有产品 - const index = newProducts.findIndex(p => p.productId === editingProduct.productId); - if (index !== -1) { - newProducts[index] = { ...productForm }; - } - } else { - // 添加新产品 - if (newProducts.find(p => p.productId === productForm.productId)) { - showError(t('产品ID已存在')); - return; - } - newProducts.push({ ...productForm }); - } - - setProducts(newProducts); - closeProductModal(); - }; - - const deleteProduct = (productId) => { - const newProducts = products.filter(p => p.productId !== productId); - setProducts(newProducts); - }; - - const columns = [ - { - title: t('产品名称'), - dataIndex: 'name', - key: 'name', - }, - { - title: t('产品ID'), - dataIndex: 'productId', - key: 'productId', - }, - { - title: t('展示价格'), - dataIndex: 'price', - key: 'price', - render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`, - }, - { - title: t('充值额度'), - dataIndex: 'quota', - key: 'quota', - }, - { - title: t('操作'), - key: 'action', - render: (_, record) => ( -
- -