diff --git a/common/redis.go b/common/redis.go index 49d3ec78..ba35331a 100644 --- a/common/redis.go +++ b/common/redis.go @@ -92,12 +92,12 @@ func RedisDel(key string) error { return RDB.Del(ctx, key).Err() } -func RedisHDelObj(key string) error { +func RedisDelKey(key string) error { if DebugEnabled { - SysLog(fmt.Sprintf("Redis HDEL: key=%s", key)) + SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key)) } ctx := context.Background() - return RDB.HDel(ctx, key).Err() + return RDB.Del(ctx, key).Err() } func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { diff --git a/controller/channel-test.go b/controller/channel-test.go index d1cb4093..970c1768 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest { } else { testRequest.MaxTokens = 10 } - content, _ := json.Marshal("hi") + testMessage := dto.Message{ Role: "user", - Content: content, + Content: "hi", } testRequest.Model = model testRequest.Messages = append(testRequest.Messages, testMessage) diff --git a/controller/channel.go b/controller/channel.go index a31e1f47..a4ef87c3 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -623,3 +623,44 @@ func BatchSetChannelTag(c *gin.Context) { }) return } + +func GetTagModels(c *gin.Context) { + tag := c.Query("tag") + if tag == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + + channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var longestModels string + maxLength := 0 + + // Find the longest models string among all channels with the given tag + for _, channel := range channels { + if channel.Models != "" { + currentModels := strings.Split(channel.Models, ",") + if len(currentModels) > maxLength { + maxLength = len(currentModels) + longestModels = channel.Models + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": longestModels, + }) + return +} diff --git a/controller/misc.go b/controller/misc.go index 4d265c3f..be76cab5 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -74,6 +74,7 @@ func GetStatus(c *gin.Context) { "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, "setup": constant.Setup, + "api_info": setting.GetApiInfo(), }, }) return diff --git a/controller/option.go b/controller/option.go index 250f16bb..f33b877a 100644 --- a/controller/option.go +++ b/controller/option.go @@ -119,7 +119,15 @@ func UpdateOption(c *gin.Context) { }) return } - + case "ApiInfo": + err = setting.ValidateApiInfo(option.Value) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } } err = model.UpdateOption(option.Key, option.Value) if err != nil { diff --git a/dto/claude.go b/dto/claude.go index 36dfc02e..4d24bc70 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -1,6 +1,9 @@ package dto -import "encoding/json" +import ( + "encoding/json" + "one-api/common" +) type ClaudeMetadata struct { UserId string `json:"user_id"` @@ -20,11 +23,11 @@ type ClaudeMediaMessage struct { Delta string `json:"delta,omitempty"` CacheControl json.RawMessage `json:"cache_control,omitempty"` // tool_calls - Id string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input any `json:"input,omitempty"` - Content json.RawMessage `json:"content,omitempty"` - ToolUseId string `json:"tool_use_id,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Content any `json:"content,omitempty"` + ToolUseId string `json:"tool_use_id,omitempty"` } func (c *ClaudeMediaMessage) SetText(s string) { @@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string { } func (c *ClaudeMediaMessage) IsStringContent() bool { - var content string - return json.Unmarshal(c.Content, &content) == nil + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + if ok { + return true + } + return false } func (c *ClaudeMediaMessage) GetStringContent() string { - var content string - if err := json.Unmarshal(c.Content, &content); err == nil { - return content + if c.Content == nil { + return "" } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" } @@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string { } func (c *ClaudeMediaMessage) SetContent(content any) { - jsonContent, _ := json.Marshal(content) - c.Content = jsonContent + c.Content = content } func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { - var mediaContent []ClaudeMediaMessage - if err := json.Unmarshal(c.Content, &mediaContent); err == nil { - return mediaContent - } - return make([]ClaudeMediaMessage, 0) + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content) + return mediaContent } type ClaudeMessageSource struct { @@ -82,14 +105,36 @@ type ClaudeMessage struct { } func (c *ClaudeMessage) IsStringContent() bool { + if c.Content == nil { + return false + } _, ok := c.Content.(string) return ok } func (c *ClaudeMessage) GetStringContent() string { - if c.IsStringContent() { - return c.Content.(string) + if c.Content == nil { + return "" } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" } @@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) { } func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { - // map content to []ClaudeMediaMessage - // parse to json - jsonContent, _ := json.Marshal(c.Content) - var contentList []ClaudeMediaMessage - err := json.Unmarshal(jsonContent, &contentList) - if err != nil { - return make([]ClaudeMediaMessage, 0), err - } - return contentList, nil + return common.Any2Type[[]ClaudeMediaMessage](c.Content) } type Tool struct { @@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) { } func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { - // map content to []ClaudeMediaMessage - // parse to json - jsonContent, _ := json.Marshal(c.System) - var contentList []ClaudeMediaMessage - if err := json.Unmarshal(jsonContent, &contentList); err == nil { - return contentList - } - return make([]ClaudeMediaMessage, 0) + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System) + return mediaContent } type ClaudeError struct { diff --git a/dto/openai_request.go b/dto/openai_request.go index a7325fe8..38f63903 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -19,43 +19,43 @@ type FormatJsonSchema struct { } type GeneralOpenAIRequest struct { - Model string `json:"model,omitempty"` - Messages []Message `json:"messages,omitempty"` - Prompt any `json:"prompt,omitempty"` - Prefix any `json:"prefix,omitempty"` - Suffix any `json:"suffix,omitempty"` - Stream bool `json:"stream,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - MaxTokens uint `json:"max_tokens,omitempty"` - MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` - TopK int `json:"top_k,omitempty"` - Stop any `json:"stop,omitempty"` - N int `json:"n,omitempty"` - Input any `json:"input,omitempty"` - Instruction string `json:"instruction,omitempty"` - Size string `json:"size,omitempty"` - Functions any `json:"functions,omitempty"` - FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` - PresencePenalty float64 `json:"presence_penalty,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - EncodingFormat any `json:"encoding_format,omitempty"` - Seed float64 `json:"seed,omitempty"` - ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` - Tools []ToolCallRequest `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - User string `json:"user,omitempty"` - LogProbs bool `json:"logprobs,omitempty"` - TopLogProbs int `json:"top_logprobs,omitempty"` - Dimensions int `json:"dimensions,omitempty"` - Modalities any `json:"modalities,omitempty"` - Audio any `json:"audio,omitempty"` - EnableThinking any `json:"enable_thinking,omitempty"` // ali - ExtraBody any `json:"extra_body,omitempty"` - WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` - // OpenRouter Params + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Prompt any `json:"prompt,omitempty"` + Prefix any `json:"prefix,omitempty"` + Suffix any `json:"suffix,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stop any `json:"stop,omitempty"` + N int `json:"n,omitempty"` + Input any `json:"input,omitempty"` + Instruction string `json:"instruction,omitempty"` + Size string `json:"size,omitempty"` + Functions json.RawMessage `json:"functions,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + EncodingFormat json.RawMessage `json:"encoding_format,omitempty"` + Seed float64 `json:"seed,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` + Tools []ToolCallRequest `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + User string `json:"user,omitempty"` + LogProbs bool `json:"logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + Modalities json.RawMessage `json:"modalities,omitempty"` + Audio json.RawMessage `json:"audio,omitempty"` + EnableThinking any `json:"enable_thinking,omitempty"` // ali + ExtraBody json.RawMessage `json:"extra_body,omitempty"` + WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` + // OpenRouter Params Reasoning json.RawMessage `json:"reasoning,omitempty"` } @@ -107,16 +107,16 @@ func (r *GeneralOpenAIRequest) ParseInput() []string { } type Message struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Name *string `json:"name,omitempty"` - Prefix *bool `json:"prefix,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls json.RawMessage `json:"tool_calls,omitempty"` - ToolCallId string `json:"tool_call_id,omitempty"` - parsedContent []MediaContent - parsedStringContent *string + Role string `json:"role"` + Content any `json:"content"` + Name *string `json:"name,omitempty"` + Prefix *bool `json:"prefix,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` + parsedContent []MediaContent + //parsedStringContent *string } type MediaContent struct { @@ -132,21 +132,50 @@ type MediaContent struct { func (m *MediaContent) GetImageMedia() *MessageImageUrl { if m.ImageUrl != nil { - return m.ImageUrl.(*MessageImageUrl) + if _, ok := m.ImageUrl.(*MessageImageUrl); ok { + return m.ImageUrl.(*MessageImageUrl) + } + if itemMap, ok := m.ImageUrl.(map[string]any); ok { + out := &MessageImageUrl{ + Url: common.Interface2String(itemMap["url"]), + Detail: common.Interface2String(itemMap["detail"]), + MimeType: common.Interface2String(itemMap["mime_type"]), + } + return out + } } return nil } func (m *MediaContent) GetInputAudio() *MessageInputAudio { if m.InputAudio != nil { - return m.InputAudio.(*MessageInputAudio) + if _, ok := m.InputAudio.(*MessageInputAudio); ok { + return m.InputAudio.(*MessageInputAudio) + } + if itemMap, ok := m.InputAudio.(map[string]any); ok { + out := &MessageInputAudio{ + Data: common.Interface2String(itemMap["data"]), + Format: common.Interface2String(itemMap["format"]), + } + return out + } } return nil } func (m *MediaContent) GetFile() *MessageFile { if m.File != nil { - return m.File.(*MessageFile) + if _, ok := m.File.(*MessageFile); ok { + return m.File.(*MessageFile) + } + if itemMap, ok := m.File.(map[string]any); ok { + out := &MessageFile{ + FileName: common.Interface2String(itemMap["file_name"]), + FileData: common.Interface2String(itemMap["file_data"]), + FileId: common.Interface2String(itemMap["file_id"]), + } + return out + } } return nil } @@ -212,6 +241,186 @@ func (m *Message) SetToolCalls(toolCalls any) { } func (m *Message) StringContent() string { + switch m.Content.(type) { + case string: + return m.Content.(string) + case []any: + var contentStr string + for _, contentItem := range m.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + m.Content = content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + m.Content = content + m.parsedContent = content +} + +func (m *Message) IsStringContent() bool { + _, ok := m.Content.(string) + if ok { + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.Content == nil { + return nil + } + if len(m.parsedContent) > 0 { + return m.parsedContent + } + + var contentList []MediaContent + // 先尝试解析为字符串 + content, ok := m.Content.(string) + if ok { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: content, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + //var arrayContent []map[string]interface{} + + arrayContent, ok := m.Content.([]any) + if !ok { + return contentList + } + + for _, contentItemAny := range arrayContent { + mediaItem, ok := contentItemAny.(MediaContent) + if ok { + contentList = append(contentList, mediaItem) + continue + } + + contentItem, ok := contentItemAny.(map[string]any) + if !ok { + continue + } + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +} + +// old code +/*func (m *Message) StringContent() string { if m.parsedStringContent != nil { return *m.parsedStringContent } @@ -382,7 +591,7 @@ func (m *Message) ParseContent() []MediaContent { m.parsedContent = contentList } return contentList -} +}*/ type WebSearchOptions struct { SearchContextSize string `json:"search_context_size,omitempty"` diff --git a/makefile b/makefile index 5042723c..cbc4ea6a 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ all: build-frontend start-backend build-frontend: @echo "Building frontend..." - @cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) npm run build + @cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build start-backend: @echo "Starting backend dev server..." diff --git a/model/option.go b/model/option.go index d892b120..7bab819b 100644 --- a/model/option.go +++ b/model/option.go @@ -122,6 +122,7 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ApiInfo"] = "" // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() diff --git a/model/token_cache.go b/model/token_cache.go index 0fe02fea..b2e0c951 100644 --- a/model/token_cache.go +++ b/model/token_cache.go @@ -19,7 +19,7 @@ func cacheSetToken(token Token) error { func cacheDeleteToken(key string) error { key = common.GenerateHMAC(key) - err := common.RedisHDelObj(fmt.Sprintf("token:%s", key)) + err := common.RedisDelKey(fmt.Sprintf("token:%s", key)) if err != nil { return err } diff --git a/model/user_cache.go b/model/user_cache.go index bc412e77..d74877bd 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -3,11 +3,12 @@ package model import ( "encoding/json" "fmt" - "github.com/gin-gonic/gin" "one-api/common" "one-api/constant" "time" + "github.com/gin-gonic/gin" + "github.com/bytedance/gopkg/util/gopool" ) @@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error { if !common.RedisEnabled { return nil } - return common.RedisHDelObj(getUserCacheKey(userId)) + return common.RedisDelKey(getUserCacheKey(userId)) } // updateUserCache updates all user cache fields using hash diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go index 5706f8b0..2f1387c5 100644 --- a/relay/channel/ali/text.go +++ b/relay/channel/ali/text.go @@ -96,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) * } func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse { - content, _ := json.Marshal(response.Output.Text) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Output.Text, }, FinishReason: response.Output.FinishReason, } diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index 62b06413..55b6c137 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { } func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { - content, _ := json.Marshal(response.Result) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Result, }, FinishReason: "stop", } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 95e7c4be..cb2c75b1 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla prompt := "" for _, message := range textRequest.Messages { if message.Role == "user" { - prompt += fmt.Sprintf("\n\nHuman: %s", message.Content) + prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent()) } else if message.Role == "assistant" { - prompt += fmt.Sprintf("\n\nAssistant: %s", message.Content) + prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent()) } else if message.Role == "system" { if prompt == "" { prompt = message.StringContent() @@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla } if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.IsStringContent() && message.IsStringContent() { - content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) - fmtMessage.Content = content + fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) // delete last message formatMessages = formatMessages[:len(formatMessages)-1] } } if fmtMessage.Content == nil { - content, _ := json.Marshal("...") - fmtMessage.Content = content + fmtMessage.SetStringContent("...") } formatMessages = append(formatMessages, fmtMessage) lastMessage = fmtMessage @@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto thinkingContent := "" if reqMode == RequestModeCompletion { - content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " ")) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: strings.TrimPrefix(claudeResponse.Completion, " "), Name: nil, }, FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go index 17b58dbc..10c4328b 100644 --- a/relay/channel/cohere/relay-cohere.go +++ b/relay/channel/cohere/relay-cohere.go @@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt openaiResp.Model = modelName openaiResp.Usage = usage - content, _ := json.Marshal(cohereResp.Text) openaiResp.Choices = []dto.OpenAITextResponseChoice{ { Index: 0, - Message: dto.Message{Content: content, Role: "assistant"}, + Message: dto.Message{Content: cohereResp.Text, Role: "assistant"}, FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), }, } diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go index 4e9afa23..d5dc9a81 100644 --- a/relay/channel/coze/dto.go +++ b/relay/channel/coze/dto.go @@ -10,7 +10,7 @@ type CozeError struct { type CozeEnterMessage struct { Role string `json:"role"` Type string `json:"type,omitempty"` - Content json.RawMessage `json:"content,omitempty"` + Content any `json:"content,omitempty"` MetaData json.RawMessage `json:"meta_data,omitempty"` ContentType string `json:"content_type,omitempty"` } diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go index b58fbe53..93e3e8d6 100644 --- a/relay/channel/dify/relay-dify.go +++ b/relay/channel/dify/relay-dify.go @@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf Created: common.GetTimestamp(), Usage: difyResponse.MetaData.Usage, } - content, _ := json.Marshal(difyResponse.Answer) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: difyResponse.Answer, }, FinishReason: "stop", } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 4022c9b0..45c41e60 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -175,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon // common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools)) // json_data, _ := json.Marshal(geminiRequest.Tools) // common.SysLog("tools_json: " + string(json_data)) - } else if textRequest.Functions != nil { - //geminiRequest.Tools = []GeminiChatTool{ - // { - // FunctionDeclarations: textRequest.Functions, - // }, - //} } if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { @@ -609,14 +603,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp Created: common.GetTimestamp(), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), } - content, _ := json.Marshal("") isToolCall := false for _, candidate := range response.Candidates { choice := dto.OpenAITextResponseChoice{ Index: int(candidate.Index), Message: dto.Message{ Role: "assistant", - Content: content, + Content: "", }, FinishReason: constant.FinishReasonStop, } diff --git a/relay/channel/mistral/text.go b/relay/channel/mistral/text.go index a25c1492..e26c6101 100644 --- a/relay/channel/mistral/text.go +++ b/relay/channel/mistral/text.go @@ -47,7 +47,7 @@ func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAI } mediaMessages := message.ParseContent() - if message.Role == "assistant" && message.ToolCalls != nil && string(message.Content) == "null" { + if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" { mediaMessages = []dto.MediaContent{} } for j, mediaMessage := range mediaMessages { diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index c8e337de..5c398b5e 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), } for i, candidate := range response.Candidates { - content, _ := json.Marshal(candidate.Content) choice := dto.OpenAITextResponseChoice{ Index: i, Message: dto.Message{ Role: "assistant", - Content: content, + Content: candidate.Content, }, FinishReason: "stop", } diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go index 5630650f..1446e06e 100644 --- a/relay/channel/tencent/relay-tencent.go +++ b/relay/channel/tencent/relay-tencent.go @@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon }, } if len(response.Choices) > 0 { - content, _ := json.Marshal(response.Choices[0].Messages.Content) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Choices[0].Messages.Content, }, FinishReason: response.Choices[0].FinishReason, } diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go index 15d33510..c6ef722c 100644 --- a/relay/channel/xunfei/relay-xunfei.go +++ b/relay/channel/xunfei/relay-xunfei.go @@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse }, } } - content, _ := json.Marshal(response.Payload.Choices.Text[0].Content) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Payload.Choices.Text[0].Content, }, FinishReason: constant.FinishReasonStop, } diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go index b0cac858..744538e3 100644 --- a/relay/channel/zhipu/relay-zhipu.go +++ b/relay/channel/zhipu/relay-zhipu.go @@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse { Usage: response.Data.Usage, } for i, choice := range response.Data.Choices { - content, _ := json.Marshal(strings.Trim(choice.Content, "\"")) openaiChoice := dto.OpenAITextResponseChoice{ Index: i, Message: dto.Message{ Role: choice.Role, - Content: content, + Content: strings.Trim(choice.Content, "\""), }, FinishReason: "", } diff --git a/router/api-router.go b/router/api-router.go index 1720ff57..6251c8a2 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -105,6 +105,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.POST("/fetch_models", controller.FetchModels) channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) + channelRoute.GET("/tag/models", controller.GetTagModels) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/service/token_counter.go b/service/token_counter.go index d63b54ad..e1722013 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -261,12 +261,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream //} tokenNum += 1000 case "tool_use": - tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) - inputJSON, _ := json.Marshal(mediaMessage.Input) - tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + if mediaMessage.Input != nil { + tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) + inputJSON, _ := json.Marshal(mediaMessage.Input) + tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + } case "tool_result": - contentJSON, _ := json.Marshal(mediaMessage.Content) - tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + if mediaMessage.Content != nil { + contentJSON, _ := json.Marshal(mediaMessage.Content) + tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + } } } } @@ -386,7 +390,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod for _, message := range messages { tokenNum += tokensPerMessage tokenNum += getTokenNum(tokenEncoder, message.Role) - if len(message.Content) > 0 { + if message.Content != nil { if message.Name != nil { tokenNum += tokensPerName tokenNum += getTokenNum(tokenEncoder, *message.Name) diff --git a/setting/api_info.go b/setting/api_info.go new file mode 100644 index 00000000..0d7ffcfd --- /dev/null +++ b/setting/api_info.go @@ -0,0 +1,124 @@ +package setting + +import ( + "encoding/json" + "fmt" + "net/url" + "one-api/common" + "regexp" + "strings" +) + +// ValidateApiInfo 验证API信息格式 +func ValidateApiInfo(apiInfoStr string) error { + if apiInfoStr == "" { + return nil // 空字符串是合法的 + } + + var apiInfoList []map[string]interface{} + if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil { + return fmt.Errorf("API信息格式错误:%s", err.Error()) + } + + // 验证数组长度 + if len(apiInfoList) > 50 { + return fmt.Errorf("API信息数量不能超过50个") + } + + // 允许的颜色值 + validColors := map[string]bool{ + "blue": true, "green": true, "cyan": true, "purple": true, "pink": true, + "red": true, "orange": true, "amber": true, "yellow": true, "lime": true, + "light-green": true, "teal": true, "light-blue": true, "indigo": true, + "violet": true, "grey": true, + } + + // URL正则表达式 + urlRegex := regexp.MustCompile(`^https?://[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(/.*)?$`) + + for i, apiInfo := range apiInfoList { + // 检查必填字段 + urlStr, ok := apiInfo["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个API信息缺少URL字段", i+1) + } + + route, ok := apiInfo["route"].(string) + if !ok || route == "" { + return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) + } + + description, ok := apiInfo["description"].(string) + if !ok || description == "" { + return fmt.Errorf("第%d个API信息缺少说明字段", i+1) + } + + color, ok := apiInfo["color"].(string) + if !ok || color == "" { + return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) + } + + // 验证URL格式 + if !urlRegex.MatchString(urlStr) { + return fmt.Errorf("第%d个API信息的URL格式不正确", i+1) + } + + // 验证URL可解析性 + if _, err := url.Parse(urlStr); err != nil { + return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error()) + } + + // 验证字段长度 + if len(urlStr) > 500 { + return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) + } + + if len(route) > 100 { + return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) + } + + if len(description) > 200 { + return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) + } + + // 验证颜色值 + if !validColors[color] { + return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) + } + + // 检查并过滤危险字符(防止XSS) + dangerousChars := []string{" { const [inputs, setInputs] = useState({ @@ -266,7 +265,7 @@ const LoginForm = () => {
Logo - {systemName} + {systemName}
@@ -500,19 +499,8 @@ const LoginForm = () => { }; return ( -
- {/* 背景图片容器 - 放大并保持居中 */} -
- - {/* 半透明遮罩层 */} -
- -
+
+
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) ? renderEmailLoginForm() : renderOAuthOptions()} diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js index 025161ac..e2d9a9ad 100644 --- a/web/src/components/auth/PasswordResetConfirm.js +++ b/web/src/components/auth/PasswordResetConfirm.js @@ -4,7 +4,6 @@ import { useSearchParams, Link } from 'react-router-dom'; import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; -import Background from '/example.png'; const { Text, Title } = Typography; @@ -79,24 +78,13 @@ const PasswordResetConfirm = () => { } return ( -
- {/* 背景图片容器 - 放大并保持居中 */} -
- - {/* 半透明遮罩层 */} -
- -
+
+
Logo - {systemName} + {systemName}
diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js index 4ff7882f..29c3d477 100644 --- a/web/src/components/auth/PasswordResetForm.js +++ b/web/src/components/auth/PasswordResetForm.js @@ -5,7 +5,6 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; import { IconMail } from '@douyinfe/semi-icons'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import Background from '/example.png'; const { Text, Title } = Typography; @@ -79,24 +78,13 @@ const PasswordResetForm = () => { } return ( -
- {/* 背景图片容器 - 放大并保持居中 */} -
- - {/* 半透明遮罩层 */} -
- -
+
+
Logo - {systemName} + {systemName}
diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js index 322b4990..0d9c8982 100644 --- a/web/src/components/auth/RegisterForm.js +++ b/web/src/components/auth/RegisterForm.js @@ -33,7 +33,6 @@ import WeChatIcon from '../common/logo/WeChatIcon.js'; import TelegramLoginButton from 'react-telegram-login/src'; import { UserContext } from '../../context/User/index.js'; import { useTranslation } from 'react-i18next'; -import Background from '/example.png'; const RegisterForm = () => { const { t } = useTranslation(); @@ -272,7 +271,7 @@ const RegisterForm = () => {
Logo - {systemName} + {systemName}
@@ -379,7 +378,7 @@ const RegisterForm = () => {
Logo - {systemName} + {systemName}
@@ -542,17 +541,8 @@ const RegisterForm = () => { }; return ( -
-
- -
- -
+
+
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) ? renderEmailRegisterForm() : renderOAuthOptions()} diff --git a/web/src/components/settings/DashboardSetting.js b/web/src/components/settings/DashboardSetting.js new file mode 100644 index 00000000..b00a6476 --- /dev/null +++ b/web/src/components/settings/DashboardSetting.js @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Spin } from '@douyinfe/semi-ui'; +import { API, showError } from '../../helpers'; +import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js'; + +const DashboardSetting = () => { + let [inputs, setInputs] = useState({ + ApiInfo: '', + }); + + let [loading, setLoading] = useState(false); + + const getOptions = async () => { + const res = await API.get('/api/option/'); + const { success, message, data } = res.data; + if (success) { + let newInputs = {}; + data.forEach((item) => { + if (item.key in inputs) { + newInputs[item.key] = item.value; + } + }); + setInputs(newInputs); + } else { + showError(message); + } + }; + + async function onRefresh() { + try { + setLoading(true); + await getOptions(); + } catch (error) { + showError('刷新失败'); + console.error(error); + } finally { + setLoading(false); + } + } + + useEffect(() => { + onRefresh(); + }, []); + + return ( + <> + + {/* API信息管理 */} + + + + + + ); +}; + +export default DashboardSetting; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.js b/web/src/components/settings/PersonalSetting.js index 935143ab..50bca76e 100644 --- a/web/src/components/settings/PersonalSetting.js +++ b/web/src/components/settings/PersonalSetting.js @@ -104,6 +104,33 @@ const PersonalSetting = () => { }); const [modelsLoading, setModelsLoading] = useState(true); const [showWebhookDocs, setShowWebhookDocs] = useState(true); + const [isDarkMode, setIsDarkMode] = useState(false); + + // 检测暗色模式 + useEffect(() => { + const checkDarkMode = () => { + const isDark = document.documentElement.classList.contains('dark') || + window.matchMedia('(prefers-color-scheme: dark)').matches; + setIsDarkMode(isDark); + }; + + checkDarkMode(); + + // 监听主题变化 + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addListener(checkDarkMode); + + return () => { + observer.disconnect(); + mediaQuery.removeListener(checkDarkMode); + }; + }, []); useEffect(() => { let status = localStorage.getItem('status'); @@ -384,107 +411,81 @@ const PersonalSetting = () => { {/* 顶部用户信息区域 */} {/* 装饰性背景元素 */}
-
-
-
+
+
+
-
+
{getAvatarText()}
-
+
{getUsername()}
{isRoot() ? ( {t('超级管理员')} ) : isAdmin() ? ( {t('管理员')} ) : ( {t('普通用户')} )} ID: {userState?.user?.id}
-
- +
+
-
+
{t('当前余额')}
-
+
{renderQuota(userState?.user?.quota)}
@@ -492,33 +493,33 @@ const PersonalSetting = () => {
-
+
{t('历史消耗')}
-
+
{renderQuota(userState?.user?.used_quota)}
-
+
{t('请求次数')}
-
+
{userState.user?.request_count || 0}
-
+
{t('用户分组')}
-
+
{userState?.user?.group || t('默认')}
-
+
@@ -537,10 +538,10 @@ const PersonalSetting = () => { >
{/* 可用模型部分 */} -
+
-
- +
+
{t('模型列表')} @@ -629,7 +630,7 @@ const PersonalSetting = () => {
-
+
{(() => { // 根据当前选中的分类过滤模型 const categories = getModelCategories(t); @@ -736,9 +737,9 @@ const PersonalSetting = () => { shadows='hover' >
-
-
- +
+
+
{t('邮箱')}
@@ -771,8 +772,8 @@ const PersonalSetting = () => { >
-
- +
+
{t('微信')}
@@ -808,8 +809,8 @@ const PersonalSetting = () => { >
-
- +
+
{t('GitHub')}
@@ -844,8 +845,8 @@ const PersonalSetting = () => { >
-
- +
+
{t('OIDC')}
@@ -883,8 +884,8 @@ const PersonalSetting = () => { >
-
- +
+
{t('Telegram')}
@@ -926,8 +927,8 @@ const PersonalSetting = () => { >
-
- +
+
{t('LinuxDO')}
@@ -978,8 +979,8 @@ const PersonalSetting = () => { >
-
- +
+
@@ -1006,7 +1007,7 @@ const PersonalSetting = () => { type="primary" theme="solid" onClick={generateAccessToken} - className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 w-full sm:w-auto" + className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto" icon={} > {systemToken ? t('重新生成') : t('生成令牌')} @@ -1022,8 +1023,8 @@ const PersonalSetting = () => { >
-
- +
+
@@ -1038,7 +1039,7 @@ const PersonalSetting = () => { type="primary" theme="solid" onClick={() => setShowChangePasswordModal(true)} - className="!rounded-lg !bg-orange-500 hover:!bg-orange-600 w-full sm:w-auto" + className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto" icon={} > {t('修改密码')} @@ -1054,11 +1055,11 @@ const PersonalSetting = () => { >
-
- +
+
- + {t('删除账户')} @@ -1070,7 +1071,7 @@ const PersonalSetting = () => { type="danger" theme="solid" onClick={() => setShowAccountDeleteModal(true)} - className="!rounded-lg w-full sm:w-auto" + className="!rounded-lg w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600" icon={} > {t('删除账户')} @@ -1111,7 +1112,7 @@ const PersonalSetting = () => { >
- +
{t('邮件通知')}
{t('通过邮件接收通知')}
@@ -1120,7 +1121,7 @@ const PersonalSetting = () => {
- +
{t('Webhook通知')}
{t('通过HTTP请求接收通知')}
@@ -1167,11 +1168,11 @@ const PersonalSetting = () => {
-
+
setShowWebhookDocs(!showWebhookDocs)}>
- - + + {t('Webhook请求结构')}
@@ -1254,11 +1255,11 @@ const PersonalSetting = () => { itemKey='price' >
-
-
-
- -
+
+
+
+ +
@@ -1292,7 +1293,7 @@ const PersonalSetting = () => { type='primary' onClick={saveNotificationSettings} size="large" - className="!rounded-lg !bg-purple-500 hover:!bg-purple-600" + className="!rounded-lg !bg-slate-600 hover:!bg-slate-700" icon={} > {t('保存设置')} @@ -1408,7 +1409,7 @@ const PersonalSetting = () => { theme="solid" size='large' onClick={bindWeChat} - className="!rounded-lg w-full !bg-green-500 hover:!bg-green-600" + className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700" icon={} > {t('绑定')} diff --git a/web/src/components/table/ChannelsTable.js b/web/src/components/table/ChannelsTable.js index 8302f5ce..9b38a883 100644 --- a/web/src/components/table/ChannelsTable.js +++ b/web/src/components/table/ChannelsTable.js @@ -6,15 +6,31 @@ import { showSuccess, timestamp2string, renderGroup, - renderQuotaWithAmount, - renderQuota + renderNumberWithPoint, + renderQuota, + getChannelIcon } from '../../helpers/index.js'; +import { + CheckCircle, + XCircle, + AlertCircle, + HelpCircle, + TestTube, + Zap, + Timer, + Clock, + AlertTriangle, + Coins, + Tags +} from 'lucide-react'; + import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js'; import { Button, Divider, Dropdown, + Empty, Input, InputNumber, Modal, @@ -25,13 +41,15 @@ import { Tag, Tooltip, Typography, - Checkbox, Card, - Select + Form } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; import EditChannel from '../../pages/Channel/EditChannel.js'; import { - IconList, IconTreeTriangleDown, IconFilter, IconPlus, @@ -64,7 +82,12 @@ const ChannelsTable = () => { type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; } return ( - + {type2label[type]?.label} ); @@ -74,7 +97,7 @@ const ChannelsTable = () => { return ( } + prefixIcon={} size='large' shape='circle' type='light' @@ -88,25 +111,25 @@ const ChannelsTable = () => { switch (status) { case 1: return ( - + }> {t('已启用')} ); case 2: return ( - + }> {t('已禁用')} ); case 3: return ( - + }> {t('自动禁用')} ); default: return ( - + }> {t('未知状态')} ); @@ -118,139 +141,48 @@ const ChannelsTable = () => { time = time.toFixed(2) + t(' 秒'); if (responseTime === 0) { return ( - + }> {t('未测试')} ); } else if (responseTime <= 1000) { return ( - + }> {time} ); } else if (responseTime <= 3000) { return ( - + }> {time} ); } else if (responseTime <= 5000) { return ( - + }> {time} ); } else { return ( - + }> {time} ); } }; - // Define column keys for selection - const COLUMN_KEYS = { - ID: 'id', - NAME: 'name', - GROUP: 'group', - TYPE: 'type', - STATUS: 'status', - RESPONSE_TIME: 'response_time', - BALANCE: 'balance', - PRIORITY: 'priority', - WEIGHT: 'weight', - OPERATE: 'operate', - }; - - // State for column visibility - const [visibleColumns, setVisibleColumns] = useState({}); - const [showColumnSelector, setShowColumnSelector] = useState(false); - - // Load saved column preferences from localStorage - useEffect(() => { - const savedColumns = localStorage.getItem('channels-table-columns'); - if (savedColumns) { - try { - const parsed = JSON.parse(savedColumns); - // Make sure all columns are accounted for - const defaults = getDefaultColumnVisibility(); - const merged = { ...defaults, ...parsed }; - setVisibleColumns(merged); - } catch (e) { - console.error('Failed to parse saved column preferences', e); - initDefaultColumns(); - } - } else { - initDefaultColumns(); - } - }, []); - - // Update table when column visibility changes - useEffect(() => { - if (Object.keys(visibleColumns).length > 0) { - // Save to localStorage - localStorage.setItem( - 'channels-table-columns', - JSON.stringify(visibleColumns), - ); - } - }, [visibleColumns]); - - // Get default column visibility - const getDefaultColumnVisibility = () => { - return { - [COLUMN_KEYS.ID]: true, - [COLUMN_KEYS.NAME]: true, - [COLUMN_KEYS.GROUP]: true, - [COLUMN_KEYS.TYPE]: true, - [COLUMN_KEYS.STATUS]: true, - [COLUMN_KEYS.RESPONSE_TIME]: true, - [COLUMN_KEYS.BALANCE]: true, - [COLUMN_KEYS.PRIORITY]: true, - [COLUMN_KEYS.WEIGHT]: true, - [COLUMN_KEYS.OPERATE]: true, - }; - }; - - // Initialize default column visibility - const initDefaultColumns = () => { - const defaults = getDefaultColumnVisibility(); - setVisibleColumns(defaults); - }; - - // Handle column visibility change - const handleColumnVisibilityChange = (columnKey, checked) => { - const updatedColumns = { ...visibleColumns, [columnKey]: checked }; - setVisibleColumns(updatedColumns); - }; - - // Handle "Select All" checkbox - const handleSelectAll = (checked) => { - const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]); - const updatedColumns = {}; - - allKeys.forEach((key) => { - updatedColumns[key] = checked; - }); - - setVisibleColumns(updatedColumns); - }; - - // Define all columns with keys - const allColumns = [ + // Define all columns + const columns = [ { - key: COLUMN_KEYS.ID, title: t('ID'), dataIndex: 'id', }, { - key: COLUMN_KEYS.NAME, title: t('名称'), dataIndex: 'name', }, { - key: COLUMN_KEYS.GROUP, title: t('分组'), dataIndex: 'group', render: (text, record, index) => ( @@ -269,7 +201,6 @@ const ChannelsTable = () => { ), }, { - key: COLUMN_KEYS.TYPE, title: t('类型'), dataIndex: 'type', render: (text, record, index) => { @@ -281,7 +212,6 @@ const ChannelsTable = () => { }, }, { - key: COLUMN_KEYS.STATUS, title: t('状态'), dataIndex: 'status', render: (text, record, index) => { @@ -307,7 +237,6 @@ const ChannelsTable = () => { }, }, { - key: COLUMN_KEYS.RESPONSE_TIME, title: t('响应时间'), dataIndex: 'response_time', render: (text, record, index) => ( @@ -315,7 +244,6 @@ const ChannelsTable = () => { ), }, { - key: COLUMN_KEYS.BALANCE, title: t('已用/剩余'), dataIndex: 'expired_time', render: (text, record, index) => { @@ -324,7 +252,7 @@ const ChannelsTable = () => {
- + }> {renderQuota(record.used_quota)} @@ -334,6 +262,7 @@ const ChannelsTable = () => { type='ghost' size='large' shape='circle' + prefixIcon={} onClick={() => updateChannelBalance(record)} > {renderQuotaWithAmount(record.balance)} @@ -345,7 +274,7 @@ const ChannelsTable = () => { } else { return ( - + }> {renderQuota(record.used_quota)} @@ -354,7 +283,6 @@ const ChannelsTable = () => { }, }, { - key: COLUMN_KEYS.PRIORITY, title: t('优先级'), dataIndex: 'priority', render: (text, record, index) => { @@ -406,7 +334,6 @@ const ChannelsTable = () => { }, }, { - key: COLUMN_KEYS.WEIGHT, title: t('权重'), dataIndex: 'weight', render: (text, record, index) => { @@ -458,7 +385,6 @@ const ChannelsTable = () => { }, }, { - key: COLUMN_KEYS.OPERATE, title: '', dataIndex: 'operate', fixed: 'right', @@ -631,96 +557,10 @@ const ChannelsTable = () => { }, ]; - // Filter columns based on visibility settings - const getVisibleColumns = () => { - return allColumns.filter((column) => visibleColumns[column.key]); - }; - - // Column selector modal - const renderColumnSelector = () => { - return ( - setShowColumnSelector(false)} - footer={ -
- - - -
- } - size="middle" - centered={true} - > -
- v === true)} - indeterminate={ - Object.values(visibleColumns).some((v) => v === true) && - !Object.values(visibleColumns).every((v) => v === true) - } - onChange={(e) => handleSelectAll(e.target.checked)} - > - {t('全选')} - -
-
- {allColumns.map((column) => { - // Skip columns without title - if (!column.title) { - return null; - } - - return ( -
- - handleColumnVisibilityChange(column.key, e.target.checked) - } - > - {column.title} - -
- ); - })} -
-
- ); - }; - const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); const [idSort, setIdSort] = useState(false); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searchGroup, setSearchGroup] = useState(''); - const [searchModel, setSearchModel] = useState(''); const [searching, setSearching] = useState(false); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [channelCount, setChannelCount] = useState(pageSize); @@ -745,6 +585,16 @@ const ChannelsTable = () => { const [testQueue, setTestQueue] = useState([]); const [isProcessingQueue, setIsProcessingQueue] = useState(false); + // Form API 引用 + const [formApi, setFormApi] = useState(null); + + // Form 初始值 + const formInitValues = { + searchKeyword: '', + searchGroup: '', + searchModel: '', + }; + const removeRecord = (record) => { let newDataSource = [...channels]; if (record.id != null) { @@ -896,15 +746,11 @@ const ChannelsTable = () => { }; const refresh = async () => { + const { searchKeyword, searchGroup, searchModel } = getFormValues(); if (searchKeyword === '' && searchGroup === '' && searchModel === '') { await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); } else { - await searchChannels( - searchKeyword, - searchGroup, - searchModel, - enableTagMode, - ); + await searchChannels(enableTagMode); } }; @@ -1010,29 +856,40 @@ const ChannelsTable = () => { } }; - const searchChannels = async ( - searchKeyword, - searchGroup, - searchModel, - enableTagMode, - ) => { - if (searchKeyword === '' && searchGroup === '' && searchModel === '') { - await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); - // setActivePage(1); - return; - } + // 获取表单值的辅助函数,确保所有值都是字符串 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchGroup: formValues.searchGroup || '', + searchModel: formValues.searchModel || '', + }; + }; + + const searchChannels = async (enableTagMode) => { + const { searchKeyword, searchGroup, searchModel } = getFormValues(); + setSearching(true); - const res = await API.get( - `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`, - ); - const { success, message, data } = res.data; - if (success) { - setChannelFormat(data, enableTagMode); - setActivePage(1); - } else { - showError(message); + try { + if (searchKeyword === '' && searchGroup === '' && searchModel === '') { + await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); + // setActivePage(1); + return; + } + + const res = await API.get( + `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`, + ); + const { success, message, data } = res.data; + if (success) { + setChannelFormat(data, enableTagMode); + setActivePage(1); + } else { + showError(message); + } + } finally { + setSearching(false); } - setSearching(false); }; const updateChannelProperty = (channelId, updateFn) => { @@ -1540,71 +1397,83 @@ const ChannelsTable = () => { > {t('刷新')} - -
-
- } - placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')} - value={searchKeyword} - loading={searching} - onChange={(v) => { - setSearchKeyword(v.trim()); - }} - className="!rounded-full" - showClear - /> -
-
- } - placeholder={t('模型关键字')} - value={searchModel} - loading={searching} - onChange={(v) => { - setSearchModel(v.trim()); - }} - className="!rounded-full" - showClear - /> -
-
- { - setLogType(parseInt(value)); - loadLogs(0, pageSize, parseInt(value)); - }} - > - {t('全部')} - {t('充值')} - {t('消费')} - {t('管理')} - {t('系统')} - {t('错误')} - - - {/* 其他搜索字段 */} - } - placeholder={t('令牌名称')} - value={token_name} - onChange={(value) => handleInputChange(value, 'token_name')} - className='!rounded-full' - showClear - /> - - } - placeholder={t('模型名称')} - value={model_name} - onChange={(value) => handleInputChange(value, 'model_name')} - className='!rounded-full' - showClear - /> - - } - placeholder={t('分组')} - value={group} - onChange={(value) => handleInputChange(value, 'group')} - className='!rounded-full' - showClear - /> - - {isAdminUser && ( - <> - } - placeholder={t('渠道 ID')} - value={channel} - onChange={(value) => handleInputChange(value, 'channel')} - className='!rounded-full' + {/* 操作按钮区域 */} +
+ {/* 日志类型选择器 */} +
+ - } - placeholder={t('用户名称')} - value={username} - onChange={(value) => handleInputChange(value, 'username')} - className='!rounded-full' - showClear - /> - - )} -
+ pure + onChange={() => { + // 延迟执行搜索,让表单值先更新 + setTimeout(() => { + refresh(); + }, 0); + }} + > + {t('全部')} + {t('充值')} + {t('消费')} + {t('管理')} + {t('系统')} + {t('错误')} + +
- {/* 操作按钮区域 */} -
-
-
- - +
+ + + +
-
+
} shadows='always' @@ -1276,6 +1366,14 @@ const LogsTable = () => { scroll={{ x: 'max-content' }} className='rounded-xl overflow-hidden' size='middle' + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } pagination={{ formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { diff --git a/web/src/components/table/MjLogsTable.js b/web/src/components/table/MjLogsTable.js index 48513eb1..08376641 100644 --- a/web/src/components/table/MjLogsTable.js +++ b/web/src/components/table/MjLogsTable.js @@ -1,35 +1,65 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { + Palette, + ZoomIn, + Shuffle, + Move, + FileText, + Blend, + Upload, + Minimize2, + RotateCcw, + PaintBucket, + Focus, + Move3D, + Monitor, + UserCheck, + HelpCircle, + CheckCircle, + Clock, + Copy, + FileX, + Pause, + XCircle, + Loader, + AlertCircle, + Hash +} from 'lucide-react'; import { API, copy, isAdmin, showError, showSuccess, - timestamp2string, + timestamp2string } from '../../helpers'; import { Button, Card, Checkbox, - DatePicker, Divider, + Empty, + Form, ImagePreview, - Input, Layout, Modal, Progress, Skeleton, Table, Tag, - Typography, + Typography } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; import { ITEMS_PER_PAGE } from '../../constants'; import { IconEyeOpened, IconSearch, - IconSetting, + IconSetting } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -154,103 +184,103 @@ const LogsTable = () => { switch (type) { case 'IMAGINE': return ( - + }> {t('绘图')} ); case 'UPSCALE': return ( - + }> {t('放大')} ); case 'VARIATION': return ( - + }> {t('变换')} ); case 'HIGH_VARIATION': return ( - + }> {t('强变换')} ); case 'LOW_VARIATION': return ( - + }> {t('弱变换')} ); case 'PAN': return ( - + }> {t('平移')} ); case 'DESCRIBE': return ( - + }> {t('图生文')} ); case 'BLEND': return ( - + }> {t('图混合')} ); case 'UPLOAD': return ( - + }> 上传文件 ); case 'SHORTEN': return ( - + }> {t('缩词')} ); case 'REROLL': return ( - + }> {t('重绘')} ); case 'INPAINT': return ( - + }> {t('局部重绘-提交')} ); case 'ZOOM': return ( - + }> {t('变焦')} ); case 'CUSTOM_ZOOM': return ( - + }> {t('自定义变焦-提交')} ); case 'MODAL': return ( - + }> {t('窗口处理')} ); case 'SWAP_FACE': return ( - + }> {t('换脸')} ); default: return ( - + }> {t('未知')} ); @@ -261,31 +291,31 @@ const LogsTable = () => { switch (code) { case 1: return ( - + }> {t('已提交')} ); case 21: return ( - + }> {t('等待中')} ); case 22: return ( - + }> {t('重复提交')} ); case 0: return ( - + }> {t('未提交')} ); default: return ( - + }> {t('未知')} ); @@ -296,43 +326,43 @@ const LogsTable = () => { switch (type) { case 'SUCCESS': return ( - + }> {t('成功')} ); case 'NOT_START': return ( - + }> {t('未启动')} ); case 'SUBMITTED': return ( - + }> {t('队列中')} ); case 'IN_PROGRESS': return ( - + }> {t('执行中')} ); case 'FAILURE': return ( - + }> {t('失败')} ); case 'MODAL': return ( - + }> {t('窗口等待')} ); default: return ( - + }> {t('未知')} ); @@ -362,7 +392,7 @@ const LogsTable = () => { const color = durationSec > 60 ? 'red' : 'green'; return ( - + }> {durationSec} {t('秒')} ); @@ -398,6 +428,7 @@ const LogsTable = () => { color={colors[parseInt(text) % colors.length]} size='large' shape='circle' + prefixIcon={} onClick={() => { copyText(text); }} @@ -462,7 +493,7 @@ const LogsTable = () => { percent={text ? parseInt(text.replace('%', '')) : 0} showInfo={true} aria-label='drawing progress' - style={{ minWidth: '200px' }} + style={{ minWidth: '160px' }} /> }
@@ -483,6 +514,7 @@ const LogsTable = () => { setModalImageUrl(text); setIsModalOpenurl(true); }} + className="!rounded-full" > {t('查看图片')} @@ -570,7 +602,6 @@ const LogsTable = () => { const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); - const [logType, setLogType] = useState(0); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [showBanner, setShowBanner] = useState(false); @@ -578,22 +609,44 @@ const LogsTable = () => { // 定义模态框图片URL的状态和更新函数 const [modalImageUrl, setModalImageUrl] = useState(''); let now = new Date(); - // 初始化start_timestamp为前一天 - const [inputs, setInputs] = useState({ + + // Form 初始值 + const formInitValues = { channel_id: '', mj_id: '', - start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000), - end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), - }); - const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs; + dateRange: [ + timestamp2string(now.getTime() / 1000 - 2592000), + timestamp2string(now.getTime() / 1000 + 3600) + ], + }; + + // Form API 引用 + const [formApi, setFormApi] = useState(null); const [stat, setStat] = useState({ quota: 0, token: 0, }); - const handleInputChange = (value, name) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); + // 获取表单值的辅助函数 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + + // 处理时间范围 + let start_timestamp = timestamp2string(now.getTime() / 1000 - 2592000); + let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600); + + if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) { + start_timestamp = formValues.dateRange[0]; + end_timestamp = formValues.dateRange[1]; + } + + return { + channel_id: formValues.channel_id || '', + mj_id: formValues.mj_id || '', + start_timestamp, + end_timestamp, + }; }; const setLogsFormat = (logs) => { @@ -611,6 +664,7 @@ const LogsTable = () => { setLoading(true); let url = ''; + const { channel_id, mj_id, start_timestamp, end_timestamp } = getFormValues(); let localStartTimestamp = Date.parse(start_timestamp); let localEndTimestamp = Date.parse(end_timestamp); if (isAdminUser) { @@ -673,7 +727,7 @@ const LogsTable = () => { const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE; setPageSize(localPageSize); loadLogs(0, localPageSize).then(); - }, [logType]); + }, []); useEffect(() => { const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled'); @@ -788,70 +842,93 @@ const LogsTable = () => { {/* 搜索表单区域 */} -
-
- {/* 时间选择器 */} -
- { - if (Array.isArray(value) && value.length === 2) { - handleInputChange(value[0], 'start_timestamp'); - handleInputChange(value[1], 'end_timestamp'); - } - }} - /> -
+
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
- {/* 任务 ID */} - } - placeholder={t('任务 ID')} - value={mj_id} - onChange={(value) => handleInputChange(value, 'mj_id')} - className="!rounded-full" - showClear - /> - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - value={channel_id} - onChange={(value) => handleInputChange(value, 'channel_id')} + placeholder={t('任务 ID')} className="!rounded-full" showClear + pure /> - )} -
- {/* 操作按钮区域 */} -
-
-
- - + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + className="!rounded-full" + showClear + pure + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + + +
-
+
} shadows='always' @@ -865,6 +942,14 @@ const LogsTable = () => { scroll={{ x: 'max-content' }} className="rounded-xl overflow-hidden" size="middle" + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } pagination={{ formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { diff --git a/web/src/components/table/ModelPricing.js b/web/src/components/table/ModelPricing.js index c0be5195..b81274c7 100644 --- a/web/src/components/table/ModelPricing.js +++ b/web/src/components/table/ModelPricing.js @@ -17,14 +17,19 @@ import { Tabs, TabPane, Dropdown, + Empty } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; import { IconVerify, IconHelpCircle, IconSearch, IconCopy, IconInfoCircle, - IconLayers, + IconLayers } from '@douyinfe/semi-icons'; import { UserContext } from '../../context/User/index.js'; import { AlertCircle } from 'lucide-react'; @@ -489,6 +494,14 @@ const ModelPricing = () => { loading={loading} rowSelection={rowSelection} className="custom-table" + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } pagination={{ defaultPageSize: 10, pageSize: pageSize, diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js index 62ccb7ac..c314bae2 100644 --- a/web/src/components/table/RedemptionsTable.js +++ b/web/src/components/table/RedemptionsTable.js @@ -8,20 +8,33 @@ import { renderQuota } from '../../helpers'; +import { + CheckCircle, + XCircle, + Minus, + HelpCircle, + Coins +} from 'lucide-react'; + import { ITEMS_PER_PAGE } from '../../constants'; import { Button, Card, Divider, Dropdown, - Input, + Empty, + Form, Modal, Popover, Space, Table, Tag, - Typography, + Typography } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; import { IconPlus, IconCopy, @@ -31,7 +44,7 @@ import { IconDelete, IconStop, IconPlay, - IconMore, + IconMore } from '@douyinfe/semi-icons'; import EditRedemption from '../../pages/Redemption/EditRedemption'; import { useTranslation } from 'react-i18next'; @@ -49,25 +62,25 @@ const RedemptionsTable = () => { switch (status) { case 1: return ( - + }> {t('未使用')} ); case 2: return ( - + }> {t('已禁用')} ); case 3: return ( - + }> {t('已使用')} ); default: return ( - + }> {t('未知状态')} ); @@ -95,7 +108,13 @@ const RedemptionsTable = () => { title: t('额度'), dataIndex: 'quota', render: (text, record, index) => { - return
{renderQuota(parseInt(text))}
; + return ( +
+ }> + {renderQuota(parseInt(text))} + +
+ ); }, }, { @@ -223,7 +242,6 @@ const RedemptionsTable = () => { const [redemptions, setRedemptions] = useState([]); const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); const [searching, setSearching] = useState(false); const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); const [selectedKeys, setSelectedKeys] = useState([]); @@ -233,6 +251,22 @@ const RedemptionsTable = () => { }); const [showEdit, setShowEdit] = useState(false); + // Form 初始值 + const formInitValues = { + searchKeyword: '', + }; + + // Form API 引用 + const [formApi, setFormApi] = useState(null); + + // 获取表单值的辅助函数 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + }; + }; + const closeEdit = () => { setShowEdit(false); setTimeout(() => { @@ -340,8 +374,14 @@ const RedemptionsTable = () => { setLoading(false); }; - const searchRedemptions = async (keyword, page, pageSize) => { - if (searchKeyword === '') { + const searchRedemptions = async (keyword = null, page, pageSize) => { + // 如果没有传递keyword参数,从表单获取值 + if (keyword === null) { + const formValues = getFormValues(); + keyword = formValues.searchKeyword; + } + + if (keyword === '') { await loadRedemptions(page, pageSize); return; } @@ -361,10 +401,6 @@ const RedemptionsTable = () => { setSearching(false); }; - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - const sortRedemption = (key) => { if (redemptions.length === 0) return; setLoading(true); @@ -381,6 +417,7 @@ const RedemptionsTable = () => { const handlePageChange = (page) => { setActivePage(page); + const { searchKeyword } = getFormValues(); if (searchKeyword === '') { loadRedemptions(page, pageSize).then(); } else { @@ -457,28 +494,59 @@ const RedemptionsTable = () => {
-
-
- } - placeholder={t('关键字(id或者名称)')} - value={searchKeyword} - onChange={handleKeywordChange} - className="!rounded-full" - showClear - /> +
setFormApi(api)} + onSubmit={() => { + setActivePage(1); + searchRedemptions(null, 1, pageSize); + }} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('关键字(id或者名称)')} + className="!rounded-full" + showClear + pure + /> +
+
+ + +
- -
+
); @@ -517,6 +585,7 @@ const RedemptionsTable = () => { onPageSizeChange: (size) => { setPageSize(size); setActivePage(1); + const { searchKeyword } = getFormValues(); if (searchKeyword === '') { loadRedemptions(1, size).then(); } else { @@ -528,6 +597,14 @@ const RedemptionsTable = () => { loading={loading} rowSelection={rowSelection} onRow={handleRow} + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } className="rounded-xl overflow-hidden" size="middle" > diff --git a/web/src/components/table/TaskLogsTable.js b/web/src/components/table/TaskLogsTable.js index 4e329d29..4f802515 100644 --- a/web/src/components/table/TaskLogsTable.js +++ b/web/src/components/table/TaskLogsTable.js @@ -1,34 +1,51 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { + Music, + FileText, + HelpCircle, + CheckCircle, + Pause, + Clock, + Play, + XCircle, + Loader, + List, + Hash +} from 'lucide-react'; import { API, copy, isAdmin, showError, showSuccess, - timestamp2string, + timestamp2string } from '../../helpers'; import { Button, Card, Checkbox, - DatePicker, Divider, - Input, + Empty, + Form, Layout, Modal, Progress, Skeleton, Table, Tag, - Typography, + Typography } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; import { ITEMS_PER_PAGE } from '../../constants'; import { IconEyeOpened, IconSearch, - IconSetting, + IconSetting } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -97,7 +114,7 @@ function renderDuration(submit_time, finishTime) { // 返回带有样式的颜色标签 return ( - + }> {durationSec} 秒 ); @@ -188,19 +205,19 @@ const LogsTable = () => { switch (type) { case 'MUSIC': return ( - + }> {t('生成音乐')} ); case 'LYRICS': return ( - + }> {t('生成歌词')} ); default: return ( - + }> {t('未知')} ); @@ -211,13 +228,13 @@ const LogsTable = () => { switch (type) { case 'suno': return ( - + }> Suno ); default: return ( - + }> {t('未知')} ); @@ -228,55 +245,55 @@ const LogsTable = () => { switch (type) { case 'SUCCESS': return ( - + }> {t('成功')} ); case 'NOT_START': return ( - + }> {t('未启动')} ); case 'SUBMITTED': return ( - + }> {t('队列中')} ); case 'IN_PROGRESS': return ( - + }> {t('执行中')} ); case 'FAILURE': return ( - + }> {t('失败')} ); case 'QUEUED': return ( - + }> {t('排队中')} ); case 'UNKNOWN': return ( - + }> {t('未知')} ); case '': return ( - + }> {t('正在提交')} ); default: return ( - + }> {t('未知')} ); @@ -321,6 +338,7 @@ const LogsTable = () => { color={colors[parseInt(text) % colors.length]} size='large' shape='circle' + prefixIcon={} onClick={() => { copyText(text); }} @@ -395,7 +413,7 @@ const LogsTable = () => { percent={text ? parseInt(text.replace('%', '')) : 0} showInfo={true} aria-label='task progress' - style={{ minWidth: '200px' }} + style={{ minWidth: '160px' }} /> ) } @@ -437,21 +455,43 @@ const LogsTable = () => { const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); - const [logType] = useState(0); let now = new Date(); // 初始化start_timestamp为前一天 let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const [inputs, setInputs] = useState({ + + // Form 初始值 + const formInitValues = { channel_id: '', task_id: '', - start_timestamp: timestamp2string(zeroNow.getTime() / 1000), - end_timestamp: '', - }); - const { channel_id, task_id, start_timestamp, end_timestamp } = inputs; + dateRange: [ + timestamp2string(zeroNow.getTime() / 1000), + timestamp2string(now.getTime() / 1000 + 3600) + ], + }; - const handleInputChange = (value, name) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); + // Form API 引用 + const [formApi, setFormApi] = useState(null); + + // 获取表单值的辅助函数 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + + // 处理时间范围 + let start_timestamp = timestamp2string(zeroNow.getTime() / 1000); + let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600); + + if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) { + start_timestamp = formValues.dateRange[0]; + end_timestamp = formValues.dateRange[1]; + } + + return { + channel_id: formValues.channel_id || '', + task_id: formValues.task_id || '', + start_timestamp, + end_timestamp, + }; }; const setLogsFormat = (logs) => { @@ -469,6 +509,7 @@ const LogsTable = () => { setLoading(true); let url = ''; + const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues(); let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000); let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000); if (isAdminUser) { @@ -528,7 +569,7 @@ const LogsTable = () => { const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; setPageSize(localPageSize); loadLogs(0, localPageSize).then(); - }, [logType]); + }, []); // 列选择器模态框 const renderColumnSelector = () => { @@ -628,70 +669,93 @@ const LogsTable = () => { {/* 搜索表单区域 */} -
-
- {/* 时间选择器 */} -
- { - if (Array.isArray(value) && value.length === 2) { - handleInputChange(value[0], 'start_timestamp'); - handleInputChange(value[1], 'end_timestamp'); - } - }} - /> -
+
setFormApi(api)} + onSubmit={refresh} + allowEmpty={true} + autoComplete="off" + layout="vertical" + trigger="change" + stopValidateWithError={false} + > +
+
+ {/* 时间选择器 */} +
+ +
- {/* 任务 ID */} - } - placeholder={t('任务 ID')} - value={task_id} - onChange={(value) => handleInputChange(value, 'task_id')} - className="!rounded-full" - showClear - /> - - {/* 渠道 ID - 仅管理员可见 */} - {isAdminUser && ( - } - placeholder={t('渠道 ID')} - value={channel_id} - onChange={(value) => handleInputChange(value, 'channel_id')} + placeholder={t('任务 ID')} className="!rounded-full" showClear + pure /> - )} -
- {/* 操作按钮区域 */} -
-
-
- - + {/* 渠道 ID - 仅管理员可见 */} + {isAdminUser && ( + } + placeholder={t('渠道 ID')} + className="!rounded-full" + showClear + pure + /> + )} +
+ + {/* 操作按钮区域 */} +
+
+
+ + + +
-
+
} shadows='always' @@ -705,6 +769,14 @@ const LogsTable = () => { scroll={{ x: 'max-content' }} className="rounded-xl overflow-hidden" size="middle" + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } pagination={{ formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js index 7d4f5af0..4fc26c6c 100644 --- a/web/src/components/table/TokensTable.js +++ b/web/src/components/table/TokensTable.js @@ -6,7 +6,8 @@ import { showSuccess, timestamp2string, renderGroup, - renderQuota + renderQuota, + getQuotaPerUnit } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; @@ -14,13 +15,29 @@ import { Button, Card, Dropdown, + Empty, + Form, Modal, Space, SplitButtonGroup, Table, - Tag, - Input, + Tag } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; + +import { + CheckCircle, + Shield, + XCircle, + Clock, + Gauge, + HelpCircle, + Infinity, + Coins +} from 'lucide-react'; import { IconPlus, @@ -32,7 +49,7 @@ import { IconDelete, IconStop, IconPlay, - IconMore, + IconMore } from '@douyinfe/semi-icons'; import EditToken from '../../pages/Token/EditToken'; import { useTranslation } from 'react-i18next'; @@ -49,38 +66,38 @@ const TokensTable = () => { case 1: if (model_limits_enabled) { return ( - + }> {t('已启用:限制模型')} ); } else { return ( - + }> {t('已启用')} ); } case 2: return ( - + }> {t('已禁用')} ); case 3: return ( - + }> {t('已过期')} ); case 4: return ( - + }> {t('已耗尽')} ); default: return ( - + }> {t('未知状态')} ); @@ -111,21 +128,45 @@ const TokensTable = () => { title: t('已用额度'), dataIndex: 'used_quota', render: (text, record, index) => { - return
{renderQuota(parseInt(text))}
; + return ( +
+ }> + {renderQuota(parseInt(text))} + +
+ ); }, }, { title: t('剩余额度'), dataIndex: 'remain_quota', render: (text, record, index) => { + const getQuotaColor = (quotaValue) => { + const quotaPerUnit = getQuotaPerUnit(); + const dollarAmount = quotaValue / quotaPerUnit; + + if (dollarAmount <= 0) { + return 'red'; + } else if (dollarAmount <= 100) { + return 'yellow'; + } else { + return 'green'; + } + }; + return (
{record.unlimited_quota ? ( - + }> {t('无限制')} ) : ( - + } + > {renderQuota(parseInt(text))} )} @@ -335,14 +376,29 @@ const TokensTable = () => { const [tokenCount, setTokenCount] = useState(pageSize); const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searchToken, setSearchToken] = useState(''); const [searching, setSearching] = useState(false); - const [chats, setChats] = useState([]); const [editingToken, setEditingToken] = useState({ id: undefined, }); + // Form 初始值 + const formInitValues = { + searchKeyword: '', + searchToken: '', + }; + + // Form API 引用 + const [formApi, setFormApi] = useState(null); + + // 获取表单值的辅助函数 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchToken: formValues.searchToken || '', + }; + }; + const closeEdit = () => { setShowEdit(false); setTimeout(() => { @@ -416,8 +472,6 @@ const TokensTable = () => { window.open(url, '_blank'); }; - - useEffect(() => { loadTokens(0) .then() @@ -472,6 +526,7 @@ const TokensTable = () => { }; const searchTokens = async () => { + const { searchKeyword, searchToken } = getFormValues(); if (searchKeyword === '' && searchToken === '') { await loadTokens(0); setActivePage(1); @@ -491,14 +546,6 @@ const TokensTable = () => { setSearching(false); }; - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - - const handleSearchTokenChange = async (value) => { - setSearchToken(value.trim()); - }; - const sortToken = (key) => { if (tokens.length === 0) return; setLoading(true); @@ -580,36 +627,65 @@ const TokensTable = () => {
-
-
- } - placeholder={t('搜索关键字')} - value={searchKeyword} - onChange={handleKeywordChange} - className="!rounded-full" - showClear - /> +
setFormApi(api)} + onSubmit={searchTokens} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('搜索关键字')} + className="!rounded-full" + showClear + pure + /> +
+
+ } + placeholder={t('密钥')} + className="!rounded-full" + showClear + pure + /> +
+
+ + +
-
- } - placeholder={t('密钥')} - value={searchToken} - onChange={handleSearchTokenChange} - className="!rounded-full" - showClear - /> -
- -
+
); @@ -654,6 +730,14 @@ const TokensTable = () => { loading={loading} rowSelection={rowSelection} onRow={handleRow} + empty={ + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } className="rounded-xl overflow-hidden" size="middle" > diff --git a/web/src/components/table/UsersTable.js b/web/src/components/table/UsersTable.js index 8c713a1a..a027af59 100644 --- a/web/src/components/table/UsersTable.js +++ b/web/src/components/table/UsersTable.js @@ -1,18 +1,37 @@ import React, { useEffect, useState } from 'react'; import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers'; + +import { + User, + Shield, + Crown, + HelpCircle, + CheckCircle, + XCircle, + Minus, + Coins, + Activity, + Users, + DollarSign, + UserPlus +} from 'lucide-react'; import { Button, Card, Divider, Dropdown, - Input, + Empty, + Form, Modal, - Select, Space, Table, Tag, - Typography, + Typography } from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; import { IconPlus, IconSearch, @@ -23,7 +42,7 @@ import { IconMore, IconUserAdd, IconArrowUp, - IconArrowDown, + IconArrowDown } from '@douyinfe/semi-icons'; import { ITEMS_PER_PAGE } from '../../constants'; import AddUser from '../../pages/User/AddUser'; @@ -39,25 +58,25 @@ const UsersTable = () => { switch (role) { case 1: return ( - + }> {t('普通用户')} ); case 10: return ( - + }> {t('管理员')} ); case 100: return ( - + }> {t('超级管理员')} ); default: return ( - + }> {t('未知身份')} ); @@ -67,16 +86,16 @@ const UsersTable = () => { const renderStatus = (status) => { switch (status) { case 1: - return {t('已激活')}; + return }>{t('已激活')}; case 2: return ( - + }> {t('已封禁')} ); default: return ( - + }> {t('未知状态')} ); @@ -106,13 +125,13 @@ const UsersTable = () => { return (
- + }> {t('剩余')}: {renderQuota(record.quota)} - + }> {t('已用')}: {renderQuota(record.used_quota)} - + }> {t('调用')}: {renderNumber(record.request_count)} @@ -127,13 +146,13 @@ const UsersTable = () => { return (
- + }> {t('邀请')}: {renderNumber(record.aff_count)} - + }> {t('收益')}: {renderQuota(record.aff_history_quota)} - + }> {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`} @@ -155,7 +174,7 @@ const UsersTable = () => { return (
{record.DeletedAt !== null ? ( - {t('已注销')} + }>{t('已注销')} ) : ( renderStatus(text) )} @@ -285,9 +304,7 @@ const UsersTable = () => { const [loading, setLoading] = useState(true); const [activePage, setActivePage] = useState(1); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [searchKeyword, setSearchKeyword] = useState(''); const [searching, setSearching] = useState(false); - const [searchGroup, setSearchGroup] = useState(''); const [groupOptions, setGroupOptions] = useState([]); const [userCount, setUserCount] = useState(ITEMS_PER_PAGE); const [showAddUser, setShowAddUser] = useState(false); @@ -296,6 +313,24 @@ const UsersTable = () => { id: undefined, }); + // Form 初始值 + const formInitValues = { + searchKeyword: '', + searchGroup: '', + }; + + // Form API 引用 + const [formApi, setFormApi] = useState(null); + + // 获取表单值的辅助函数 + const getFormValues = () => { + const formValues = formApi ? formApi.getValues() : {}; + return { + searchKeyword: formValues.searchKeyword || '', + searchGroup: formValues.searchGroup || '', + }; + }; + const removeRecord = (key) => { let newDataSource = [...users]; if (key != null) { @@ -363,9 +398,16 @@ const UsersTable = () => { const searchUsers = async ( startIdx, pageSize, - searchKeyword, - searchGroup, + searchKeyword = null, + searchGroup = null, ) => { + // 如果没有传递参数,从表单获取值 + if (searchKeyword === null || searchGroup === null) { + const formValues = getFormValues(); + searchKeyword = formValues.searchKeyword; + searchGroup = formValues.searchGroup; + } + if (searchKeyword === '' && searchGroup === '') { // if keyword is blank, load files instead. await loadUsers(startIdx, pageSize); @@ -387,12 +429,9 @@ const UsersTable = () => { setSearching(false); }; - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - const handlePageChange = (page) => { setActivePage(page); + const { searchKeyword, searchGroup } = getFormValues(); if (searchKeyword === '' && searchGroup === '') { loadUsers(page, pageSize).then(); } else { @@ -413,10 +452,11 @@ const UsersTable = () => { const refresh = async () => { setActivePage(1); - if (searchKeyword === '') { - await loadUsers(activePage, pageSize); + const { searchKeyword, searchGroup } = getFormValues(); + if (searchKeyword === '' && searchGroup === '') { + await loadUsers(1, pageSize); } else { - await searchUsers(activePage, pageSize, searchKeyword, searchGroup); + await searchUsers(1, pageSize, searchKeyword, searchGroup); } }; @@ -488,41 +528,76 @@ const UsersTable = () => {
-
-
- } - placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} - value={searchKeyword} - onChange={handleKeywordChange} - className="!rounded-full" - showClear - /> +
setFormApi(api)} + onSubmit={() => { + setActivePage(1); + searchUsers(1, pageSize); + }} + allowEmpty={true} + autoComplete="off" + layout="horizontal" + trigger="change" + stopValidateWithError={false} + className="w-full md:w-auto order-1 md:order-2" + > +
+
+ } + placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} + className="!rounded-full" + showClear + pure + /> +
+
+ { + // 分组变化时自动搜索 + setTimeout(() => { + setActivePage(1); + searchUsers(1, pageSize); + }, 100); + }} + className="!rounded-full w-full" + showClear + pure + /> +
+
+ + +
-
- { /> handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))} > {t('填入模板')} handleInputChange('model_mapping', JSON.stringify({}, null, 2))} > {t('清空重定向')} handleInputChange('model_mapping', '')} > {t('不更改')} diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 0d9c5911..6c1b87b3 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -1,6 +1,7 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { initVChartSemiTheme } from '@visactor/vchart-semi-theme'; import { useNavigate } from 'react-router-dom'; +import { Wallet, Activity, Zap, Gauge, PieChart } from 'lucide-react'; import { Card, @@ -9,6 +10,10 @@ import { IconButton, Modal, Avatar, + Tabs, + TabPane, + Empty, + Tag } from '@douyinfe/semi-ui'; import { IconRefresh, @@ -21,7 +26,9 @@ import { IconPulse, IconStopwatchStroked, IconTypograph, + IconPieChart2Stroked } from '@douyinfe/semi-icons'; +import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations'; import { VChart } from '@visactor/react-vchart'; import { API, @@ -34,47 +41,165 @@ import { modelColorMap, renderNumber, renderQuota, - modelToColor + modelToColor, + copy, + showSuccess } from '../../helpers'; import { UserContext } from '../../context/User/index.js'; +import { StatusContext } from '../../context/Status/index.js'; import { useTranslation } from 'react-i18next'; const Detail = (props) => { + // ========== Hooks - Context ========== + const [userState, userDispatch] = useContext(UserContext); + const [statusState, statusDispatch] = useContext(StatusContext); + + // ========== Hooks - Navigation & Translation ========== const { t } = useTranslation(); const navigate = useNavigate(); + + // ========== Hooks - Refs ========== const formRef = useRef(); + const initialized = useRef(false); + const apiScrollRef = useRef(null); + + // ========== Constants & Shared Configurations ========== + const CHART_CONFIG = { mode: 'desktop-browser' }; + + const CARD_PROPS = { + shadows: 'always', + bordered: false, + headerLine: true + }; + + const FORM_FIELD_PROPS = { + className: "w-full mb-2 !rounded-lg", + size: 'large' + }; + + const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full"; + const FLEX_CENTER_GAP2 = "flex items-center gap-2"; + + // ========== Constants ========== let now = new Date(); - const [userState, userDispatch] = useContext(UserContext); + const isAdminUser = isAdmin(); + + // ========== Helper Functions ========== + const getDefaultTime = useCallback(() => { + return localStorage.getItem('data_export_default_time') || 'hour'; + }, []); + + const getTimeInterval = useCallback((timeType, isSeconds = false) => { + const intervals = { + hour: isSeconds ? 3600 : 60, + day: isSeconds ? 86400 : 1440, + week: isSeconds ? 604800 : 10080 + }; + return intervals[timeType] || intervals.hour; + }, []); + + const getInitialTimestamp = useCallback(() => { + const defaultTime = getDefaultTime(); + const now = new Date().getTime() / 1000; + + switch (defaultTime) { + case 'hour': + return timestamp2string(now - 86400); + case 'week': + return timestamp2string(now - 86400 * 30); + default: + return timestamp2string(now - 86400 * 7); + } + }, [getDefaultTime]); + + const updateMapValue = useCallback((map, key, value) => { + if (!map.has(key)) { + map.set(key, 0); + } + map.set(key, map.get(key) + value); + }, []); + + const initializeMaps = useCallback((key, ...maps) => { + maps.forEach(map => { + if (!map.has(key)) { + map.set(key, 0); + } + }); + }, []); + + const updateChartSpec = useCallback((setterFunc, newData, subtitle, newColors, dataId) => { + setterFunc(prev => ({ + ...prev, + data: [{ id: dataId, values: newData }], + title: { + ...prev.title, + subtext: subtitle, + }, + color: { + specified: newColors, + }, + })); + }, []); + + const createSectionTitle = useCallback((Icon, text) => ( +
+ + {text} +
+ ), []); + + const createFormField = useCallback((Component, props) => ( + + ), []); + + // ========== Time Options ========== + const timeOptions = useMemo(() => [ + { label: t('小时'), value: 'hour' }, + { label: t('天'), value: 'day' }, + { label: t('周'), value: 'week' }, + ], [t]); + + // ========== Hooks - State ========== const [inputs, setInputs] = useState({ username: '', token_name: '', model_name: '', - start_timestamp: - localStorage.getItem('data_export_default_time') === 'hour' - ? timestamp2string(now.getTime() / 1000 - 86400) - : localStorage.getItem('data_export_default_time') === 'week' - ? timestamp2string(now.getTime() / 1000 - 86400 * 30) - : timestamp2string(now.getTime() / 1000 - 86400 * 7), + start_timestamp: getInitialTimestamp(), end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), channel: '', data_export_default_time: '', }); - const { username, model_name, start_timestamp, end_timestamp, channel } = - inputs; - const isAdminUser = isAdmin(); - const initialized = useRef(false); + + const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime()); + const [loading, setLoading] = useState(false); const [quotaData, setQuotaData] = useState([]); const [consumeQuota, setConsumeQuota] = useState(0); const [consumeTokens, setConsumeTokens] = useState(0); const [times, setTimes] = useState(0); - const [dataExportDefaultTime, setDataExportDefaultTime] = useState( - localStorage.getItem('data_export_default_time') || 'hour', - ); const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]); const [lineData, setLineData] = useState([]); + const [apiInfoData, setApiInfoData] = useState([]); + const [modelColors, setModelColors] = useState({}); + const [activeChartTab, setActiveChartTab] = useState('1'); + const [showApiScrollHint, setShowApiScrollHint] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false); + const [trendData, setTrendData] = useState({ + balance: [], + usedQuota: [], + requestCount: [], + times: [], + consumeQuota: [], + tokens: [], + rpm: [], + tpm: [] + }); + + // ========== Props Destructuring ========== + const { username, model_name, start_timestamp, end_timestamp, channel } = inputs; + + // ========== Chart Specs State ========== const [spec_pie, setSpecPie] = useState({ type: 'pie', data: [ @@ -131,6 +256,7 @@ const Detail = (props) => { specified: modelColorMap, }, }); + const [spec_line, setSpecLine] = useState({ type: 'bar', data: [ @@ -205,23 +331,35 @@ const Detail = (props) => { }, }); - // 添加一个新的状态来存储模型-颜色映射 - const [modelColors, setModelColors] = useState({}); + // ========== Hooks - Memoized Values ========== + const performanceMetrics = useMemo(() => { + const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000; + const avgRPM = (times / timeDiff).toFixed(3); + const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3); - // 添加趋势数据状态 - const [trendData, setTrendData] = useState({ - balance: [], - usedQuota: [], - requestCount: [], - times: [], - consumeQuota: [], - tokens: [], - rpm: [], - tpm: [] - }); + return { avgRPM, avgTPM, timeDiff }; + }, [times, consumeTokens, end_timestamp, start_timestamp]); - // 迷你趋势图配置 - const getTrendSpec = (data, color) => ({ + const getGreeting = useMemo(() => { + const hours = new Date().getHours(); + let greeting = ''; + + if (hours >= 5 && hours < 12) { + greeting = t('早上好'); + } else if (hours >= 12 && hours < 14) { + greeting = t('中午好'); + } else if (hours >= 14 && hours < 18) { + greeting = t('下午好'); + } else { + greeting = t('晚上好'); + } + + const username = userState?.user?.username || ''; + return `👋${greeting},${username}`; + }, [t, userState?.user?.username]); + + // ========== Hooks - Callbacks ========== + const getTrendSpec = useCallback((data, color) => ({ type: 'line', data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }], xField: 'x', @@ -255,33 +393,124 @@ const Detail = (props) => { background: { fill: 'transparent' } - }); + }), []); - // 显示搜索Modal - const showSearchModal = () => { - setSearchModalVisible(true); - }; + const groupedStatsData = useMemo(() => [ + { + title: createSectionTitle(Wallet, t('账户数据')), + color: 'bg-blue-50', + items: [ + { + title: t('当前余额'), + value: renderQuota(userState?.user?.quota), + icon: , + avatarColor: 'blue', + onClick: () => navigate('/console/topup'), + trendData: [], + trendColor: '#3b82f6' + }, + { + title: t('历史消耗'), + value: renderQuota(userState?.user?.used_quota), + icon: , + avatarColor: 'purple', + trendData: [], + trendColor: '#8b5cf6' + } + ] + }, + { + title: createSectionTitle(Activity, t('使用统计')), + color: 'bg-green-50', + items: [ + { + title: t('请求次数'), + value: userState.user?.request_count, + icon: , + avatarColor: 'green', + trendData: [], + trendColor: '#10b981' + }, + { + title: t('统计次数'), + value: times, + icon: , + avatarColor: 'cyan', + trendData: trendData.times, + trendColor: '#06b6d4' + } + ] + }, + { + title: createSectionTitle(Zap, t('资源消耗')), + color: 'bg-yellow-50', + items: [ + { + title: t('统计额度'), + value: renderQuota(consumeQuota), + icon: , + avatarColor: 'yellow', + trendData: trendData.consumeQuota, + trendColor: '#f59e0b' + }, + { + title: t('统计Tokens'), + value: isNaN(consumeTokens) ? 0 : consumeTokens, + icon: , + avatarColor: 'pink', + trendData: trendData.tokens, + trendColor: '#ec4899' + } + ] + }, + { + title: createSectionTitle(Gauge, t('性能指标')), + color: 'bg-indigo-50', + items: [ + { + title: t('平均RPM'), + value: performanceMetrics.avgRPM, + icon: , + avatarColor: 'indigo', + trendData: trendData.rpm, + trendColor: '#6366f1' + }, + { + title: t('平均TPM'), + value: performanceMetrics.avgTPM, + icon: , + avatarColor: 'orange', + trendData: trendData.tpm, + trendColor: '#f97316' + } + ] + } + ], [ + createSectionTitle, t, userState?.user?.quota, userState?.user?.used_quota, userState?.user?.request_count, + times, consumeQuota, consumeTokens, trendData, performanceMetrics, navigate + ]); - // 关闭搜索Modal - const handleCloseModal = () => { - setSearchModalVisible(false); - }; + const handleCopyUrl = useCallback(async (url) => { + if (await copy(url)) { + showSuccess(t('复制成功')); + } + }, [t]); - // 搜索Modal确认按钮 - const handleSearchConfirm = () => { - refresh(); - setSearchModalVisible(false); - }; + const handleSpeedTest = useCallback((apiUrl) => { + const encodedUrl = encodeURIComponent(apiUrl); + const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`; + window.open(speedTestUrl, '_blank'); + }, []); - const handleInputChange = (value, name) => { + const handleInputChange = useCallback((value, name) => { if (name === 'data_export_default_time') { setDataExportDefaultTime(value); return; } setInputs((inputs) => ({ ...inputs, [name]: value })); - }; + }, []); - const loadQuotaData = async () => { + const loadQuotaData = useCallback(async () => { setLoading(true); try { let url = ''; @@ -304,7 +533,6 @@ const Detail = (props) => { created_at: now.getTime() / 1000, }); } - // sort created_at data.sort((a, b) => a.created_at - b.created_at); updateChartData(data); } else { @@ -313,72 +541,97 @@ const Detail = (props) => { } finally { setLoading(false); } - }; + }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]); - const refresh = async () => { + const refresh = useCallback(async () => { await loadQuotaData(); - }; + }, [loadQuotaData]); - const initChart = async () => { + const handleSearchConfirm = useCallback(() => { + refresh(); + setSearchModalVisible(false); + }, [refresh]); + + const initChart = useCallback(async () => { await loadQuotaData(); + }, [loadQuotaData]); + + const showSearchModal = useCallback(() => { + setSearchModalVisible(true); + }, []); + + const handleCloseModal = useCallback(() => { + setSearchModalVisible(false); + }, []); + + // ========== Regular Functions ========== + const checkApiScrollable = () => { + if (apiScrollRef.current) { + const element = apiScrollRef.current; + const isScrollable = element.scrollHeight > element.clientHeight; + const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5; + setShowApiScrollHint(isScrollable && !isAtBottom); + } }; - const updateChartData = (data) => { - let newPieData = []; - let newLineData = []; - let totalQuota = 0; - let totalTimes = 0; - let uniqueModels = new Set(); - let totalTokens = 0; + const handleApiScroll = () => { + checkApiScrollable(); + }; - // 趋势数据处理 - let timePoints = []; - let timeQuotaMap = new Map(); - let timeTokensMap = new Map(); - let timeCountMap = new Map(); + const getUserData = async () => { + let res = await API.get(`/api/user/self`); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + } else { + showError(message); + } + }; + + // ========== Data Processing Functions ========== + const processRawData = useCallback((data) => { + const result = { + totalQuota: 0, + totalTimes: 0, + totalTokens: 0, + uniqueModels: new Set(), + timePoints: [], + timeQuotaMap: new Map(), + timeTokensMap: new Map(), + timeCountMap: new Map() + }; - // 收集所有唯一的模型名称和时间点 data.forEach((item) => { - uniqueModels.add(item.model_name); - totalTokens += item.token_used; - totalQuota += item.quota; - totalTimes += item.count; + result.uniqueModels.add(item.model_name); + result.totalTokens += item.token_used; + result.totalQuota += item.quota; + result.totalTimes += item.count; - // 记录时间点 const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime); - if (!timePoints.includes(timeKey)) { - timePoints.push(timeKey); + if (!result.timePoints.includes(timeKey)) { + result.timePoints.push(timeKey); } - // 按时间点累加数据 - if (!timeQuotaMap.has(timeKey)) { - timeQuotaMap.set(timeKey, 0); - timeTokensMap.set(timeKey, 0); - timeCountMap.set(timeKey, 0); - } - timeQuotaMap.set(timeKey, timeQuotaMap.get(timeKey) + item.quota); - timeTokensMap.set(timeKey, timeTokensMap.get(timeKey) + item.token_used); - timeCountMap.set(timeKey, timeCountMap.get(timeKey) + item.count); + initializeMaps(timeKey, result.timeQuotaMap, result.timeTokensMap, result.timeCountMap); + updateMapValue(result.timeQuotaMap, timeKey, item.quota); + updateMapValue(result.timeTokensMap, timeKey, item.token_used); + updateMapValue(result.timeCountMap, timeKey, item.count); }); - // 确保时间点有序 - timePoints.sort(); + result.timePoints.sort(); + return result; + }, [dataExportDefaultTime, initializeMaps, updateMapValue]); - // 生成趋势数据 + const calculateTrendData = useCallback((timePoints, timeQuotaMap, timeTokensMap, timeCountMap) => { const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0); const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0); const countTrend = timePoints.map(time => timeCountMap.get(time) || 0); - // 计算RPM和TPM趋势 const rpmTrend = []; const tpmTrend = []; if (timePoints.length >= 2) { - const interval = dataExportDefaultTime === 'hour' - ? 60 // 分钟/小时 - : dataExportDefaultTime === 'day' - ? 1440 // 分钟/天 - : 10080; // 分钟/周 + const interval = getTimeInterval(dataExportDefaultTime); for (let i = 0; i < timePoints.length; i++) { rpmTrend.push(timeCountMap.get(timePoints[i]) / interval); @@ -386,23 +639,19 @@ const Detail = (props) => { } } - // 更新趋势数据状态 - setTrendData({ - // 账户数据不在API返回中,保持空数组 + return { balance: [], usedQuota: [], - // 使用统计 - requestCount: [], // 没有总请求次数趋势数据 + requestCount: [], times: countTrend, - // 资源消耗 consumeQuota: quotaTrend, tokens: tokensTrend, - // 性能指标 rpm: rpmTrend, tpm: tpmTrend - }); + }; + }, [dataExportDefaultTime, getTimeInterval]); - // 处理颜色映射 + const generateModelColors = useCallback((uniqueModels) => { const newModelColors = {}; Array.from(uniqueModels).forEach((modelName) => { newModelColors[modelName] = @@ -410,10 +659,12 @@ const Detail = (props) => { modelColors[modelName] || modelToColor(modelName); }); - setModelColors(newModelColors); + return newModelColors; + }, [modelColors]); + + const aggregateDataByTimeAndModel = useCallback((data) => { + const aggregatedData = new Map(); - // 按时间和模型聚合数据 - let aggregatedData = new Map(); data.forEach((item) => { const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime); const modelKey = item.model_name; @@ -433,41 +684,52 @@ const Detail = (props) => { existing.count += item.count; }); - // 处理饼图数据 - let modelTotals = new Map(); - for (let [_, value] of aggregatedData) { - if (!modelTotals.has(value.model)) { - modelTotals.set(value.model, 0); - } - modelTotals.set(value.model, modelTotals.get(value.model) + value.count); - } + return aggregatedData; + }, [dataExportDefaultTime]); - newPieData = Array.from(modelTotals).map(([model, count]) => ({ - type: model, - value: count, - })); - - // 生成时间点序列 + const generateChartTimePoints = useCallback((aggregatedData, data) => { let chartTimePoints = Array.from( new Set([...aggregatedData.values()].map((d) => d.time)), ); + if (chartTimePoints.length < 7) { const lastTime = Math.max(...data.map((item) => item.created_at)); - const interval = - dataExportDefaultTime === 'hour' - ? 3600 - : dataExportDefaultTime === 'day' - ? 86400 - : 604800; + const interval = getTimeInterval(dataExportDefaultTime, true); chartTimePoints = Array.from({ length: 7 }, (_, i) => timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime), ); } - // 生成柱状图数据 + return chartTimePoints; + }, [dataExportDefaultTime, getTimeInterval]); + + const updateChartData = useCallback((data) => { + const processedData = processRawData(data); + const { totalQuota, totalTimes, totalTokens, uniqueModels, timePoints, timeQuotaMap, timeTokensMap, timeCountMap } = processedData; + + const trendDataResult = calculateTrendData(timePoints, timeQuotaMap, timeTokensMap, timeCountMap); + setTrendData(trendDataResult); + + const newModelColors = generateModelColors(uniqueModels); + setModelColors(newModelColors); + + const aggregatedData = aggregateDataByTimeAndModel(data); + + const modelTotals = new Map(); + for (let [_, value] of aggregatedData) { + updateMapValue(modelTotals, value.model, value.count); + } + + const newPieData = Array.from(modelTotals).map(([model, count]) => ({ + type: model, + value: count, + })).sort((a, b) => b.value - a.value); + + const chartTimePoints = generateChartTimePoints(aggregatedData, data); + let newLineData = []; + chartTimePoints.forEach((time) => { - // 为每个时间点收集所有模型的数据 let timeData = Array.from(uniqueModels).map((model) => { const key = `${time}-${model}`; const aggregated = aggregatedData.get(key); @@ -479,68 +741,41 @@ const Detail = (props) => { }; }); - // 计算该时间点的总计 const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0); - - // 按照 rawQuota 从大到小排序 timeData.sort((a, b) => b.rawQuota - a.rawQuota); - - // 为每个数据点添加该时间的总计 - timeData = timeData.map((item) => ({ - ...item, - TimeSum: timeSum, - })); - - // 将排序后的数据添加到 newLineData + timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum })); newLineData.push(...timeData); }); - // 排序 - newPieData.sort((a, b) => b.value - a.value); newLineData.sort((a, b) => a.Time.localeCompare(b.Time)); - // 更新图表配置和数据 - setSpecPie((prev) => ({ - ...prev, - data: [{ id: 'id0', values: newPieData }], - title: { - ...prev.title, - subtext: `${t('总计')}:${renderNumber(totalTimes)}`, - }, - color: { - specified: newModelColors, - }, - })); + updateChartSpec( + setSpecPie, + newPieData, + `${t('总计')}:${renderNumber(totalTimes)}`, + newModelColors, + 'id0' + ); - setSpecLine((prev) => ({ - ...prev, - data: [{ id: 'barData', values: newLineData }], - title: { - ...prev.title, - subtext: `${t('总计')}:${renderQuota(totalQuota, 2)}`, - }, - color: { - specified: newModelColors, - }, - })); + updateChartSpec( + setSpecLine, + newLineData, + `${t('总计')}:${renderQuota(totalQuota, 2)}`, + newModelColors, + 'barData' + ); setPieData(newPieData); setLineData(newLineData); setConsumeQuota(totalQuota); setTimes(totalTimes); setConsumeTokens(totalTokens); - }; - - const getUserData = async () => { - let res = await API.get(`/api/user/self`); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - } else { - showError(message); - } - }; + }, [ + processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel, + generateChartTimePoints, updateChartSpec, updateMapValue, t + ]); + // ========== Hooks - Effects ========== useEffect(() => { getUserData(); if (!initialized.current) { @@ -552,140 +787,34 @@ const Detail = (props) => { } }, []); - // 数据卡片信息 - const groupedStatsData = [ - { - title: t('账户数据'), - color: 'bg-blue-50', - items: [ - { - title: t('当前余额'), - value: renderQuota(userState?.user?.quota), - icon: , - avatarColor: 'blue', - onClick: () => navigate('/console/topup'), - trendData: [], // 当前余额没有趋势数据 - trendColor: '#3b82f6' - }, - { - title: t('历史消耗'), - value: renderQuota(userState?.user?.used_quota), - icon: , - avatarColor: 'purple', - trendData: [], // 历史消耗没有趋势数据 - trendColor: '#8b5cf6' - } - ] - }, - { - title: t('使用统计'), - color: 'bg-green-50', - items: [ - { - title: t('请求次数'), - value: userState.user?.request_count, - icon: , - avatarColor: 'green', - trendData: [], // 请求次数没有趋势数据 - trendColor: '#10b981' - }, - { - title: t('统计次数'), - value: times, - icon: , - avatarColor: 'cyan', - trendData: trendData.times, - trendColor: '#06b6d4' - } - ] - }, - { - title: t('资源消耗'), - color: 'bg-yellow-50', - items: [ - { - title: t('统计额度'), - value: renderQuota(consumeQuota), - icon: , - avatarColor: 'yellow', - trendData: trendData.consumeQuota, - trendColor: '#f59e0b' - }, - { - title: t('统计Tokens'), - value: isNaN(consumeTokens) ? 0 : consumeTokens, - icon: , - avatarColor: 'pink', - trendData: trendData.tokens, - trendColor: '#ec4899' - } - ] - }, - { - title: t('性能指标'), - color: 'bg-indigo-50', - items: [ - { - title: t('平均RPM'), - value: ( - times / - ((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000) - ).toFixed(3), - icon: , - avatarColor: 'indigo', - trendData: trendData.rpm, - trendColor: '#6366f1' - }, - { - title: t('平均TPM'), - value: (() => { - const tpm = consumeTokens / - ((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000); - return isNaN(tpm) ? '0' : tpm.toFixed(3); - })(), - icon: , - avatarColor: 'orange', - trendData: trendData.tpm, - trendColor: '#f97316' - } - ] + useEffect(() => { + if (statusState?.status?.api_info) { + setApiInfoData(statusState.status.api_info); } - ]; + }, [statusState?.status?.api_info]); - // 获取问候语 - const getGreeting = () => { - const hours = new Date().getHours(); - let greeting = ''; - - if (hours >= 5 && hours < 12) { - greeting = t('早上好'); - } else if (hours >= 12 && hours < 14) { - greeting = t('中午好'); - } else if (hours >= 14 && hours < 18) { - greeting = t('下午好'); - } else { - greeting = t('晚上好'); - } - - const username = userState?.user?.username || ''; - return `👋${greeting},${username}`; - }; + useEffect(() => { + const timer = setTimeout(() => { + checkApiScrollable(); + }, 100); + return () => clearTimeout(timer); + }, []); return (
-

{getGreeting()}

+

{getGreeting}

} onClick={showSearchModal} - className="bg-green-500 text-white hover:bg-green-600 !rounded-full" + className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`} /> } onClick={refresh} loading={loading} - className="bg-blue-500 text-white hover:bg-blue-600 !rounded-full" + className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`} />
@@ -701,55 +830,44 @@ const Detail = (props) => { centered > - handleInputChange(value, 'start_timestamp')} - /> - handleInputChange(value, 'end_timestamp')} - /> - handleInputChange(value, 'data_export_default_time')} - /> - {isAdminUser && ( - handleInputChange(value, 'username')} - /> - )} + {createFormField(Form.DatePicker, { + field: 'start_timestamp', + label: t('起始时间'), + initValue: start_timestamp, + value: start_timestamp, + type: 'dateTime', + name: 'start_timestamp', + onChange: (value) => handleInputChange(value, 'start_timestamp') + })} + + {createFormField(Form.DatePicker, { + field: 'end_timestamp', + label: t('结束时间'), + initValue: end_timestamp, + value: end_timestamp, + type: 'dateTime', + name: 'end_timestamp', + onChange: (value) => handleInputChange(value, 'end_timestamp') + })} + + {createFormField(Form.Select, { + field: 'data_export_default_time', + label: t('时间粒度'), + initValue: dataExportDefaultTime, + placeholder: t('时间粒度'), + name: 'data_export_default_time', + optionList: timeOptions, + onChange: (value) => handleInputChange(value, 'data_export_default_time') + })} + + {isAdminUser && createFormField(Form.Input, { + field: 'username', + label: t('用户名称'), + value: username, + placeholder: t('可选值'), + name: 'username', + onChange: (value) => handleInputChange(value, 'username') + })} @@ -759,23 +877,9 @@ const Detail = (props) => { {groupedStatsData.map((group, idx) => ( {group.title}
} - headerStyle={{ - background: idx === 0 - ? 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)' - : idx === 1 - ? 'linear-gradient(135deg, #10b981 0%, #34d399 100%)' - : idx === 2 - ? 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)' - : 'linear-gradient(135deg, #ec4899 0%, #f472b6 100%)', - borderTopLeftRadius: '16px', - borderTopRightRadius: '16px', - padding: '12px 16px', - }} + title={group.title} >
{group.items.map((item, itemIdx) => ( @@ -801,7 +905,7 @@ const Detail = (props) => {
)} @@ -813,29 +917,127 @@ const Detail = (props) => {
-
- -
+
+
+ +
+ + {t('模型数据分析')} +
+ + + + {t('消耗分布')} + + } itemKey="1" /> + + + {t('调用次数分布')} + + } itemKey="2" /> + +
+ } + >
- + {activeChartTab === '1' ? ( + + ) : ( + + )}
-
- -
-
- + + + {!statusState?.status?.self_use_mode_enabled && ( + + + {t('API信息')} +
+ } + > +
+
+ {apiInfoData.length > 0 ? ( + apiInfoData.map((api) => ( +
+
+ + {api.route.substring(0, 2)} + +
+
+
+ } + size="small" + color="white" + shape='circle' + onClick={() => handleSpeedTest(api.url)} + className="cursor-pointer hover:opacity-80 text-xs" + > + {t('测速')} + + {api.route} +
+
handleCopyUrl(api.url)} + > + {api.url} +
+
+ {api.description} +
+
+
+ )) + ) : ( +
+ } + darkModeImage={} + title={t('暂无API信息配置')} + description={t('请联系管理员在系统设置中配置API信息')} + style={{ padding: '12px' }} + /> +
+ )} +
+
+
+ + )} +
diff --git a/web/src/pages/Home/index.js b/web/src/pages/Home/index.js index 36fb8213..bb647a34 100644 --- a/web/src/pages/Home/index.js +++ b/web/src/pages/Home/index.js @@ -4,8 +4,7 @@ import { API, showError, isMobile } from '../../helpers'; import { StatusContext } from '../../context/Status'; import { marked } from 'marked'; import { useTranslation } from 'react-i18next'; -import { IconGithubLogo } from '@douyinfe/semi-icons'; -import exampleImage from '/example.png'; +import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons'; import { Link } from 'react-router-dom'; import NoticeModal from '../../components/layout/NoticeModal'; import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons'; @@ -20,6 +19,7 @@ const Home = () => { const [noticeVisible, setNoticeVisible] = useState(false); const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + const docsLink = statusState?.status?.docs_link || ''; useEffect(() => { const checkNoticeAndShow = async () => { @@ -85,132 +85,123 @@ const Home = () => { {homePageContentLoaded && homePageContent === '' ? (
{/* Banner 部分 */} -
-
- {/* 左侧内容区 */} -
-
-

+
+
+ {/* 居中内容区 */} +
+
+

{statusState?.status?.system_name || 'New API'}

- {statusState?.status?.version && ( - - {statusState.status.version} - - )}
-

+

{t('新一代大模型网关与AI资产管理系统,一键接入主流大模型,轻松管理您的AI资产')}

{/* 操作按钮 */} -
+
- - {isDemoSiteMode && ( + {isDemoSiteMode && statusState?.status?.version ? ( + ) : ( + docsLink && ( + + ) )}
{/* 框架兼容性图标 */} -
-
- +
+
+ {t('支持众多的大模型供应商')}
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
- 30+ +
+ 30+
- - {/* 右侧图片区域 - 在小屏幕上隐藏或调整位置 */} -
-
-
-
-
- - application demo -
@@ -223,7 +214,7 @@ const Home = () => { /> ) : (
)} @@ -234,3 +225,4 @@ const Home = () => { }; export default Home; + diff --git a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js new file mode 100644 index 00000000..f97a0302 --- /dev/null +++ b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js @@ -0,0 +1,399 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, + Space, + Table, + Form, + Typography, + Empty, + Divider, + Avatar, + Modal, + Tag +} from '@douyinfe/semi-ui'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import { + Plus, + Edit, + Trash2, + Save, + Settings +} from 'lucide-react'; +import { API, showError, showSuccess } from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +const SettingsAPIInfo = ({ options, refresh }) => { + const { t } = useTranslation(); + + const [apiInfoList, setApiInfoList] = useState([]); + const [showApiModal, setShowApiModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletingApi, setDeletingApi] = useState(null); + const [editingApi, setEditingApi] = useState(null); + const [modalLoading, setModalLoading] = useState(false); + const [loading, setLoading] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [apiForm, setApiForm] = useState({ + url: '', + description: '', + route: '', + color: 'blue' + }); + + const colorOptions = [ + { value: 'blue', label: 'blue' }, + { value: 'green', label: 'green' }, + { value: 'cyan', label: 'cyan' }, + { value: 'purple', label: 'purple' }, + { value: 'pink', label: 'pink' }, + { value: 'red', label: 'red' }, + { value: 'orange', label: 'orange' }, + { value: 'amber', label: 'amber' }, + { value: 'yellow', label: 'yellow' }, + { value: 'lime', label: 'lime' }, + { value: 'light-green', label: 'light-green' }, + { value: 'teal', label: 'teal' }, + { value: 'light-blue', label: 'light-blue' }, + { value: 'indigo', label: 'indigo' }, + { value: 'violet', label: 'violet' }, + { value: 'grey', label: 'grey' } + ]; + + const updateOption = async (key, value) => { + const res = await API.put('/api/option/', { + key, + value, + }); + const { success, message } = res.data; + if (success) { + showSuccess('API信息已更新'); + if (refresh) refresh(); + } else { + showError(message); + } + }; + + const submitApiInfo = async () => { + try { + setLoading(true); + const apiInfoJson = JSON.stringify(apiInfoList); + await updateOption('ApiInfo', apiInfoJson); + setHasChanges(false); + } catch (error) { + console.error('API信息更新失败', error); + showError('API信息更新失败'); + } finally { + setLoading(false); + } + }; + + const handleAddApi = () => { + setEditingApi(null); + setApiForm({ + url: '', + description: '', + route: '', + color: 'blue' + }); + setShowApiModal(true); + }; + + const handleEditApi = (api) => { + setEditingApi(api); + setApiForm({ + url: api.url, + description: api.description, + route: api.route, + color: api.color + }); + setShowApiModal(true); + }; + + const handleDeleteApi = (api) => { + setDeletingApi(api); + setShowDeleteModal(true); + }; + + const confirmDeleteApi = () => { + if (deletingApi) { + const newList = apiInfoList.filter(api => api.id !== deletingApi.id); + setApiInfoList(newList); + setHasChanges(true); + showSuccess('API信息已删除,请及时点击“保存配置”进行保存'); + } + setShowDeleteModal(false); + setDeletingApi(null); + }; + + const handleSaveApi = async () => { + if (!apiForm.url || !apiForm.route || !apiForm.description) { + showError('请填写完整的API信息'); + return; + } + + try { + setModalLoading(true); + + let newList; + if (editingApi) { + newList = apiInfoList.map(api => + api.id === editingApi.id + ? { ...api, ...apiForm } + : api + ); + } else { + const newId = Math.max(...apiInfoList.map(api => api.id), 0) + 1; + const newApi = { + id: newId, + ...apiForm + }; + newList = [...apiInfoList, newApi]; + } + + setApiInfoList(newList); + setHasChanges(true); + setShowApiModal(false); + showSuccess(editingApi ? 'API信息已更新,请及时点击“保存配置”进行保存' : 'API信息已添加,请及时点击“保存配置”进行保存'); + } catch (error) { + showError('操作失败: ' + error.message); + } finally { + setModalLoading(false); + } + }; + + const parseApiInfo = (apiInfoStr) => { + if (!apiInfoStr) { + setApiInfoList([]); + return; + } + + try { + const parsed = JSON.parse(apiInfoStr); + setApiInfoList(Array.isArray(parsed) ? parsed : []); + } catch (error) { + console.error('解析API信息失败:', error); + setApiInfoList([]); + } + }; + + useEffect(() => { + if (options.ApiInfo !== undefined) { + parseApiInfo(options.ApiInfo); + } + }, [options.ApiInfo]); + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + }, + { + title: t('API地址'), + dataIndex: 'url', + render: (text, record) => ( + + {text} + + ), + }, + { + title: t('线路描述'), + dataIndex: 'route', + render: (text, record) => ( + + {text} + + ), + }, + { + title: t('说明'), + dataIndex: 'description', + ellipsis: true, + render: (text, record) => ( + + {text || '-'} + + ), + }, + { + title: t('颜色'), + dataIndex: 'color', + render: (color) => ( + + ), + }, + { + title: t('操作'), + fixed: 'right', + render: (_, record) => ( + + + + + ), + }, + ]; + + const renderHeader = () => ( +
+
+
+ + {t('API信息管理,可以配置多个API地址用于状态展示和负载均衡')} +
+
+ + + +
+
+ + +
+
+
+ ); + + return ( + <> + + } + darkModeImage={} + description={t('暂无API信息')} + style={{ padding: 30 }} + /> + } + className="rounded-xl overflow-hidden" + /> + + + setShowApiModal(false)} + okText={t('保存')} + cancelText={t('取消')} + className="rounded-xl" + confirmLoading={modalLoading} + > +
+ setApiForm({ ...apiForm, url: value })} + /> + setApiForm({ ...apiForm, route: value })} + /> + setApiForm({ ...apiForm, description: value })} + /> + setApiForm({ ...apiForm, color: value })} + render={(option) => ( +
+ + {option.label} +
+ )} + /> + +
+ + { + setShowDeleteModal(false); + setDeletingApi(null); + }} + okText={t('确认删除')} + cancelText={t('取消')} + type="warning" + className="rounded-xl" + okButtonProps={{ + type: 'danger', + theme: 'solid' + }} + > + {t('确定要删除此API信息吗?')} + + + ); +}; + +export default SettingsAPIInfo; \ No newline at end of file diff --git a/web/src/pages/Setting/index.js b/web/src/pages/Setting/index.js index 056fc207..dc48c8dc 100644 --- a/web/src/pages/Setting/index.js +++ b/web/src/pages/Setting/index.js @@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next'; import SystemSetting from '../../components/settings/SystemSetting.js'; import { isRoot } from '../../helpers'; import OtherSetting from '../../components/settings/OtherSetting'; -import PersonalSetting from '../../components/settings/PersonalSetting.js'; import OperationSetting from '../../components/settings/OperationSetting.js'; import RateLimitSetting from '../../components/settings/RateLimitSetting.js'; import ModelSetting from '../../components/settings/ModelSetting.js'; +import DashboardSetting from '../../components/settings/DashboardSetting.js'; const Setting = () => { const { t } = useTranslation(); @@ -44,6 +44,11 @@ const Setting = () => { content: , itemKey: 'other', }); + panes.push({ + tab: t('仪表盘配置'), + content: , + itemKey: 'dashboard', + }); } const onChangeTab = (key) => { setTabActiveKey(key); diff --git a/web/src/pages/Setup/index.js b/web/src/pages/Setup/index.js index a3572f8d..b85e208c 100644 --- a/web/src/pages/Setup/index.js +++ b/web/src/pages/Setup/index.js @@ -133,7 +133,7 @@ const Setup = () => { }; return ( -
+
diff --git a/web/src/pages/Token/EditToken.js b/web/src/pages/Token/EditToken.js index 946164da..71f611bd 100644 --- a/web/src/pages/Token/EditToken.js +++ b/web/src/pages/Token/EditToken.js @@ -219,9 +219,15 @@ const EditToken = (props) => { let successCount = 0; // 记录成功创建的令牌数量 for (let i = 0; i < tokenCount; i++) { let localInputs = { ...inputs }; - if (i !== 0) { - // 如果用户想要创建多个令牌,则给每个令牌一个序号后缀 - localInputs.name = `${inputs.name}-${generateRandomSuffix()}`; + + // 检查用户是否填写了令牌名称 + const baseName = inputs.name.trim() === '' ? 'default' : inputs.name; + + if (i !== 0 || inputs.name.trim() === '') { + // 如果创建多个令牌(i !== 0)或者用户没有填写名称,则添加随机后缀 + localInputs.name = `${baseName}-${generateRandomSuffix()}`; + } else { + localInputs.name = baseName; } localInputs.remain_quota = parseInt(localInputs.remain_quota); diff --git a/web/src/pages/TopUp/index.js b/web/src/pages/TopUp/index.js index bff83791..69d940b7 100644 --- a/web/src/pages/TopUp/index.js +++ b/web/src/pages/TopUp/index.js @@ -55,6 +55,7 @@ const TopUp = () => { const [amountLoading, setAmountLoading] = useState(false); const [paymentLoading, setPaymentLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false); + const [isDarkMode, setIsDarkMode] = useState(false); // 邀请相关状态 const [affLink, setAffLink] = useState(''); @@ -256,6 +257,32 @@ const TopUp = () => { showSuccess(t('邀请链接已复制到剪切板')); }; + // 检测暗色模式 + useEffect(() => { + const checkDarkMode = () => { + const isDark = document.documentElement.classList.contains('dark') || + window.matchMedia('(prefers-color-scheme: dark)').matches; + setIsDarkMode(isDark); + }; + + checkDarkMode(); + + // 监听主题变化 + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addListener(checkDarkMode); + + return () => { + observer.disconnect(); + mediaQuery.removeListener(checkDarkMode); + }; + }, []); + useEffect(() => { if (userState?.user?.id) { setUserDataLoading(false); @@ -398,48 +425,45 @@ const TopUp = () => {
-
-
-
+
+
+
-
+
{userDataLoading ? ( ) : ( -
+
{t('尊敬的')} {getUsername()}
)}
-
- +
+
-
+
{t('当前余额')}
{userDataLoading ? ( ) : ( -
+
{renderQuota(userState?.user?.quota || userQuota)}
)} @@ -448,37 +472,37 @@ const TopUp = () => {
-
+
{t('历史消耗')}
{userDataLoading ? ( ) : ( -
+
{renderQuota(userState?.user?.used_quota || 0)}
)}
-
+
{t('用户分组')}
{userDataLoading ? ( ) : ( -
+
{userState?.user?.group || t('默认')}
)}
-
+
{t('用户角色')}
{userDataLoading ? ( ) : ( -
+
{getUserRole()}
)} @@ -489,32 +513,187 @@ const TopUp = () => { {userDataLoading ? ( ) : ( -
+
ID: {userState?.user?.id || '---'}
)}
-
+
- {/* 邀请信息部分 */} -
+ {/* 左侧:在线充值和兑换余额 */} +
+ {/* 在线充值部分 */} +
+
+
+ +
+
+ {t('在线充值')} +
{t('支持多种支付方式')}
+
+
+ +
+
+
+ {t('充值数量')} + {amountLoading ? ( + + ) : ( + {t('实付金额:') + ' ' + renderAmount()} + )} +
+ { + if (value && value >= 1) { + setTopUpCount(value); + await getAmount(value); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value); + if (!value || value < 1) { + setTopUpCount(1); + getAmount(1); + } + }} + size="large" + className="!rounded-lg w-full" + prefix={} + formatter={(value) => value ? `${value}` : ''} + parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0} + /> +
+ +
+ + +
+ + {!enableOnlineTopUp && ( + + {t('在线充值功能未开启')} +
+ } + description={ +
+ {t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')} +
+ } + /> + )} +
+
+ + {/* 兑换余额部分 */} +
+
+
+ +
+
+ {t('兑换余额')} +
{t('使用兑换码充值余额')}
+
+
+ +
+
+ {t('兑换码')} + setRedemptionCode(value)} + size="large" + className="!rounded-lg" + prefix={} + /> +
+ +
+ {topUpLink && ( + + )} + +
+
+
+
+ + {/* 右侧:邀请信息部分 */} +
-
- +
+
@@ -524,7 +703,7 @@ const TopUp = () => { theme="solid" onClick={() => setOpenTransfer(true)} size="small" - className="!rounded-lg !bg-blue-500 hover:!bg-blue-600" + className="!rounded-lg !bg-slate-600 hover:!bg-slate-700" icon={} > {t('划转')} @@ -536,7 +715,7 @@ const TopUp = () => {
-
+
{
{renderQuota(userState?.user?.aff_quota)}
-
{
-
-
-
- -
-
- {t('兑换余额')} -
{t('使用兑换码充值余额')}
-
-
- -
-
- {t('兑换码')} - setRedemptionCode(value)} - size="large" - className="!rounded-lg" - prefix={} - /> -
- -
- {topUpLink && ( - - )} - -
-
-
- -
-
-
- -
-
- {t('在线充值')} -
{t('支持多种支付方式')}
-
-
- -
-
-
- {t('充值数量')} - {amountLoading ? ( - - ) : ( - {t('实付金额:') + ' ' + renderAmount()} - )} -
- { - if (value && value >= 1) { - setTopUpCount(value); - await getAmount(value); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value); - if (!value || value < 1) { - setTopUpCount(1); - getAmount(1); - } - }} - size="large" - className="!rounded-lg w-full" - prefix={} - formatter={(value) => value ? `${value}` : ''} - parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0} - /> -
- -
- - -
- - {!enableOnlineTopUp && ( - - {t('在线充值功能未开启')} -
- } - description={ -
- {t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')} -
- } - /> - )} -
-