Merge branch 'alpha' into fix-balance-unit-sync

This commit is contained in:
Calcium-Ion
2025-06-09 20:48:50 +08:00
committed by GitHub
53 changed files with 3367 additions and 1780 deletions

View File

@@ -92,12 +92,12 @@ func RedisDel(key string) error {
return RDB.Del(ctx, key).Err() return RDB.Del(ctx, key).Err()
} }
func RedisHDelObj(key string) error { func RedisDelKey(key string) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis HDEL: key=%s", key)) SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
} }
ctx := context.Background() 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 { func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {

View File

@@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
} else { } else {
testRequest.MaxTokens = 10 testRequest.MaxTokens = 10
} }
content, _ := json.Marshal("hi")
testMessage := dto.Message{ testMessage := dto.Message{
Role: "user", Role: "user",
Content: content, Content: "hi",
} }
testRequest.Model = model testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage) testRequest.Messages = append(testRequest.Messages, testMessage)

View File

@@ -623,3 +623,44 @@ func BatchSetChannelTag(c *gin.Context) {
}) })
return 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
}

View File

@@ -74,6 +74,7 @@ func GetStatus(c *gin.Context) {
"oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup, "setup": constant.Setup,
"api_info": setting.GetApiInfo(),
}, },
}) })
return return

View File

@@ -119,7 +119,15 @@ func UpdateOption(c *gin.Context) {
}) })
return 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) err = model.UpdateOption(option.Key, option.Value)
if err != nil { if err != nil {

View File

@@ -1,6 +1,9 @@
package dto package dto
import "encoding/json" import (
"encoding/json"
"one-api/common"
)
type ClaudeMetadata struct { type ClaudeMetadata struct {
UserId string `json:"user_id"` UserId string `json:"user_id"`
@@ -20,11 +23,11 @@ type ClaudeMediaMessage struct {
Delta string `json:"delta,omitempty"` Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"` CacheControl json.RawMessage `json:"cache_control,omitempty"`
// tool_calls // tool_calls
Id string `json:"id,omitempty"` Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"` Input any `json:"input,omitempty"`
Content json.RawMessage `json:"content,omitempty"` Content any `json:"content,omitempty"`
ToolUseId string `json:"tool_use_id,omitempty"` ToolUseId string `json:"tool_use_id,omitempty"`
} }
func (c *ClaudeMediaMessage) SetText(s string) { func (c *ClaudeMediaMessage) SetText(s string) {
@@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string {
} }
func (c *ClaudeMediaMessage) IsStringContent() bool { func (c *ClaudeMediaMessage) IsStringContent() bool {
var content string if c.Content == nil {
return json.Unmarshal(c.Content, &content) == nil return false
}
_, ok := c.Content.(string)
if ok {
return true
}
return false
} }
func (c *ClaudeMediaMessage) GetStringContent() string { func (c *ClaudeMediaMessage) GetStringContent() string {
var content string if c.Content == nil {
if err := json.Unmarshal(c.Content, &content); err == nil { return ""
return content
} }
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 "" return ""
} }
@@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string {
} }
func (c *ClaudeMediaMessage) SetContent(content any) { func (c *ClaudeMediaMessage) SetContent(content any) {
jsonContent, _ := json.Marshal(content) c.Content = content
c.Content = jsonContent
} }
func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
var mediaContent []ClaudeMediaMessage mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content)
if err := json.Unmarshal(c.Content, &mediaContent); err == nil { return mediaContent
return mediaContent
}
return make([]ClaudeMediaMessage, 0)
} }
type ClaudeMessageSource struct { type ClaudeMessageSource struct {
@@ -82,14 +105,36 @@ type ClaudeMessage struct {
} }
func (c *ClaudeMessage) IsStringContent() bool { func (c *ClaudeMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string) _, ok := c.Content.(string)
return ok return ok
} }
func (c *ClaudeMessage) GetStringContent() string { func (c *ClaudeMessage) GetStringContent() string {
if c.IsStringContent() { if c.Content == nil {
return c.Content.(string) 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 "" return ""
} }
@@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) {
} }
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
// map content to []ClaudeMediaMessage return common.Any2Type[[]ClaudeMediaMessage](c.Content)
// 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
} }
type Tool struct { type Tool struct {
@@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) {
} }
func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
// map content to []ClaudeMediaMessage mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System)
// parse to json return mediaContent
jsonContent, _ := json.Marshal(c.System)
var contentList []ClaudeMediaMessage
if err := json.Unmarshal(jsonContent, &contentList); err == nil {
return contentList
}
return make([]ClaudeMediaMessage, 0)
} }
type ClaudeError struct { type ClaudeError struct {

View File

@@ -19,43 +19,43 @@ type FormatJsonSchema struct {
} }
type GeneralOpenAIRequest struct { type GeneralOpenAIRequest struct {
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Messages []Message `json:"messages,omitempty"` Messages []Message `json:"messages,omitempty"`
Prompt any `json:"prompt,omitempty"` Prompt any `json:"prompt,omitempty"`
Prefix any `json:"prefix,omitempty"` Prefix any `json:"prefix,omitempty"`
Suffix any `json:"suffix,omitempty"` Suffix any `json:"suffix,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"` StreamOptions *StreamOptions `json:"stream_options,omitempty"`
MaxTokens uint `json:"max_tokens,omitempty"` MaxTokens uint `json:"max_tokens,omitempty"`
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"`
Temperature *float64 `json:"temperature,omitempty"` Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"` TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"` TopK int `json:"top_k,omitempty"`
Stop any `json:"stop,omitempty"` Stop any `json:"stop,omitempty"`
N int `json:"n,omitempty"` N int `json:"n,omitempty"`
Input any `json:"input,omitempty"` Input any `json:"input,omitempty"`
Instruction string `json:"instruction,omitempty"` Instruction string `json:"instruction,omitempty"`
Size string `json:"size,omitempty"` Size string `json:"size,omitempty"`
Functions any `json:"functions,omitempty"` Functions json.RawMessage `json:"functions,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"` PresencePenalty float64 `json:"presence_penalty,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"` ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
EncodingFormat any `json:"encoding_format,omitempty"` EncodingFormat json.RawMessage `json:"encoding_format,omitempty"`
Seed float64 `json:"seed,omitempty"` Seed float64 `json:"seed,omitempty"`
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
Tools []ToolCallRequest `json:"tools,omitempty"` Tools []ToolCallRequest `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"` ToolChoice any `json:"tool_choice,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
LogProbs bool `json:"logprobs,omitempty"` LogProbs bool `json:"logprobs,omitempty"`
TopLogProbs int `json:"top_logprobs,omitempty"` TopLogProbs int `json:"top_logprobs,omitempty"`
Dimensions int `json:"dimensions,omitempty"` Dimensions int `json:"dimensions,omitempty"`
Modalities any `json:"modalities,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"`
Audio any `json:"audio,omitempty"` Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali EnableThinking any `json:"enable_thinking,omitempty"` // ali
ExtraBody any `json:"extra_body,omitempty"` ExtraBody json.RawMessage `json:"extra_body,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params // OpenRouter Params
Reasoning json.RawMessage `json:"reasoning,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"`
} }
@@ -107,16 +107,16 @@ func (r *GeneralOpenAIRequest) ParseInput() []string {
} }
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content json.RawMessage `json:"content"` Content any `json:"content"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Prefix *bool `json:"prefix,omitempty"` Prefix *bool `json:"prefix,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"` Reasoning string `json:"reasoning,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"` ToolCallId string `json:"tool_call_id,omitempty"`
parsedContent []MediaContent parsedContent []MediaContent
parsedStringContent *string //parsedStringContent *string
} }
type MediaContent struct { type MediaContent struct {
@@ -132,21 +132,50 @@ type MediaContent struct {
func (m *MediaContent) GetImageMedia() *MessageImageUrl { func (m *MediaContent) GetImageMedia() *MessageImageUrl {
if m.ImageUrl != nil { 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 return nil
} }
func (m *MediaContent) GetInputAudio() *MessageInputAudio { func (m *MediaContent) GetInputAudio() *MessageInputAudio {
if m.InputAudio != nil { 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 return nil
} }
func (m *MediaContent) GetFile() *MessageFile { func (m *MediaContent) GetFile() *MessageFile {
if m.File != nil { 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 return nil
} }
@@ -212,6 +241,186 @@ func (m *Message) SetToolCalls(toolCalls any) {
} }
func (m *Message) StringContent() string { 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 { if m.parsedStringContent != nil {
return *m.parsedStringContent return *m.parsedStringContent
} }
@@ -382,7 +591,7 @@ func (m *Message) ParseContent() []MediaContent {
m.parsedContent = contentList m.parsedContent = contentList
} }
return contentList return contentList
} }*/
type WebSearchOptions struct { type WebSearchOptions struct {
SearchContextSize string `json:"search_context_size,omitempty"` SearchContextSize string `json:"search_context_size,omitempty"`

View File

@@ -7,7 +7,7 @@ all: build-frontend start-backend
build-frontend: build-frontend:
@echo "Building 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: start-backend:
@echo "Starting backend dev server..." @echo "Starting backend dev server..."

View File

@@ -122,6 +122,7 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["ApiInfo"] = ""
// 自动添加所有注册的模型配置 // 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs() modelConfigs := config.GlobalConfig.ExportAllConfigs()

View File

@@ -19,7 +19,7 @@ func cacheSetToken(token Token) error {
func cacheDeleteToken(key string) error { func cacheDeleteToken(key string) error {
key = common.GenerateHMAC(key) key = common.GenerateHMAC(key)
err := common.RedisHDelObj(fmt.Sprintf("token:%s", key)) err := common.RedisDelKey(fmt.Sprintf("token:%s", key))
if err != nil { if err != nil {
return err return err
} }

View File

@@ -3,11 +3,12 @@ package model
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"one-api/common" "one-api/common"
"one-api/constant" "one-api/constant"
"time" "time"
"github.com/gin-gonic/gin"
"github.com/bytedance/gopkg/util/gopool" "github.com/bytedance/gopkg/util/gopool"
) )
@@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error {
if !common.RedisEnabled { if !common.RedisEnabled {
return nil return nil
} }
return common.RedisHDelObj(getUserCacheKey(userId)) return common.RedisDelKey(getUserCacheKey(userId))
} }
// updateUserCache updates all user cache fields using hash // updateUserCache updates all user cache fields using hash

View File

@@ -96,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *
} }
func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse { func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
content, _ := json.Marshal(response.Output.Text)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Output.Text,
}, },
FinishReason: response.Output.FinishReason, FinishReason: response.Output.FinishReason,
} }

View File

@@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest {
} }
func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse {
content, _ := json.Marshal(response.Result)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Result,
}, },
FinishReason: "stop", FinishReason: "stop",
} }

View File

@@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla
prompt := "" prompt := ""
for _, message := range textRequest.Messages { for _, message := range textRequest.Messages {
if message.Role == "user" { 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" { } 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" { } else if message.Role == "system" {
if prompt == "" { if prompt == "" {
prompt = message.StringContent() prompt = message.StringContent()
@@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
} }
if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() { if lastMessage.IsStringContent() && message.IsStringContent() {
content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
fmtMessage.Content = content
// delete last message // delete last message
formatMessages = formatMessages[:len(formatMessages)-1] formatMessages = formatMessages[:len(formatMessages)-1]
} }
} }
if fmtMessage.Content == nil { if fmtMessage.Content == nil {
content, _ := json.Marshal("...") fmtMessage.SetStringContent("...")
fmtMessage.Content = content
} }
formatMessages = append(formatMessages, fmtMessage) formatMessages = append(formatMessages, fmtMessage)
lastMessage = fmtMessage lastMessage = fmtMessage
@@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
thinkingContent := "" thinkingContent := ""
if reqMode == RequestModeCompletion { if reqMode == RequestModeCompletion {
content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " "))
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: strings.TrimPrefix(claudeResponse.Completion, " "),
Name: nil, Name: nil,
}, },
FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason),

View File

@@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt
openaiResp.Model = modelName openaiResp.Model = modelName
openaiResp.Usage = usage openaiResp.Usage = usage
content, _ := json.Marshal(cohereResp.Text)
openaiResp.Choices = []dto.OpenAITextResponseChoice{ openaiResp.Choices = []dto.OpenAITextResponseChoice{
{ {
Index: 0, Index: 0,
Message: dto.Message{Content: content, Role: "assistant"}, Message: dto.Message{Content: cohereResp.Text, Role: "assistant"},
FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason),
}, },
} }

View File

@@ -10,7 +10,7 @@ type CozeError struct {
type CozeEnterMessage struct { type CozeEnterMessage struct {
Role string `json:"role"` Role string `json:"role"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Content json.RawMessage `json:"content,omitempty"` Content any `json:"content,omitempty"`
MetaData json.RawMessage `json:"meta_data,omitempty"` MetaData json.RawMessage `json:"meta_data,omitempty"`
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
} }

View File

@@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf
Created: common.GetTimestamp(), Created: common.GetTimestamp(),
Usage: difyResponse.MetaData.Usage, Usage: difyResponse.MetaData.Usage,
} }
content, _ := json.Marshal(difyResponse.Answer)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: difyResponse.Answer,
}, },
FinishReason: "stop", FinishReason: "stop",
} }

View File

@@ -175,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
// common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools)) // common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools))
// json_data, _ := json.Marshal(geminiRequest.Tools) // json_data, _ := json.Marshal(geminiRequest.Tools)
// common.SysLog("tools_json: " + string(json_data)) // 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") { 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(), Created: common.GetTimestamp(),
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
} }
content, _ := json.Marshal("")
isToolCall := false isToolCall := false
for _, candidate := range response.Candidates { for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index), Index: int(candidate.Index),
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: "",
}, },
FinishReason: constant.FinishReasonStop, FinishReason: constant.FinishReasonStop,
} }

View File

@@ -47,7 +47,7 @@ func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAI
} }
mediaMessages := message.ParseContent() 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{} mediaMessages = []dto.MediaContent{}
} }
for j, mediaMessage := range mediaMessages { for j, mediaMessage := range mediaMessages {

View File

@@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse {
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
} }
for i, candidate := range response.Candidates { for i, candidate := range response.Candidates {
content, _ := json.Marshal(candidate.Content)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: i, Index: i,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: candidate.Content,
}, },
FinishReason: "stop", FinishReason: "stop",
} }

View File

@@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon
}, },
} }
if len(response.Choices) > 0 { if len(response.Choices) > 0 {
content, _ := json.Marshal(response.Choices[0].Messages.Content)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Choices[0].Messages.Content,
}, },
FinishReason: response.Choices[0].FinishReason, FinishReason: response.Choices[0].FinishReason,
} }

View File

@@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse
}, },
} }
} }
content, _ := json.Marshal(response.Payload.Choices.Text[0].Content)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Payload.Choices.Text[0].Content,
}, },
FinishReason: constant.FinishReasonStop, FinishReason: constant.FinishReasonStop,
} }

View File

@@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse {
Usage: response.Data.Usage, Usage: response.Data.Usage,
} }
for i, choice := range response.Data.Choices { for i, choice := range response.Data.Choices {
content, _ := json.Marshal(strings.Trim(choice.Content, "\""))
openaiChoice := dto.OpenAITextResponseChoice{ openaiChoice := dto.OpenAITextResponseChoice{
Index: i, Index: i,
Message: dto.Message{ Message: dto.Message{
Role: choice.Role, Role: choice.Role,
Content: content, Content: strings.Trim(choice.Content, "\""),
}, },
FinishReason: "", FinishReason: "",
} }

View File

@@ -105,6 +105,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels) channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
} }
tokenRoute := apiRouter.Group("/token") tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth()) tokenRoute.Use(middleware.UserAuth())

View File

@@ -261,12 +261,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream
//} //}
tokenNum += 1000 tokenNum += 1000
case "tool_use": case "tool_use":
tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) if mediaMessage.Input != nil {
inputJSON, _ := json.Marshal(mediaMessage.Input) tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) inputJSON, _ := json.Marshal(mediaMessage.Input)
tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
}
case "tool_result": case "tool_result":
contentJSON, _ := json.Marshal(mediaMessage.Content) if mediaMessage.Content != nil {
tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) 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 { for _, message := range messages {
tokenNum += tokensPerMessage tokenNum += tokensPerMessage
tokenNum += getTokenNum(tokenEncoder, message.Role) tokenNum += getTokenNum(tokenEncoder, message.Role)
if len(message.Content) > 0 { if message.Content != nil {
if message.Name != nil { if message.Name != nil {
tokenNum += tokensPerName tokenNum += tokensPerName
tokenNum += getTokenNum(tokenEncoder, *message.Name) tokenNum += getTokenNum(tokenEncoder, *message.Name)

124
setting/api_info.go Normal file
View File

@@ -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{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(description), dangerous) {
return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(route), dangerous) {
return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
}
}
}
return nil
}
// GetApiInfo 获取API信息列表
func GetApiInfo() []map[string]interface{} {
// 从OptionMap中获取API信息如果不存在则返回空数组
common.OptionMapRWMutex.RLock()
apiInfoStr, exists := common.OptionMap["ApiInfo"]
common.OptionMapRWMutex.RUnlock()
if !exists || apiInfoStr == "" {
// 如果没有配置,返回空数组
return []map[string]interface{}{}
}
// 解析存储的API信息
var apiInfo []map[string]interface{}
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
// 如果解析失败,返回空数组
return []map[string]interface{}{}
}
return apiInfo
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -32,7 +32,6 @@ import OIDCIcon from '../common/logo/OIDCIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js'; import WeChatIcon from '../common/logo/WeChatIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const LoginForm = () => { const LoginForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
@@ -266,7 +265,7 @@ const LoginForm = () => {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2"> <div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div> </div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden"> <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -500,19 +499,8 @@ const LoginForm = () => {
}; };
return ( return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden"> <div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景图片容器 - 放大并保持居中 */} <div className="w-full max-w-sm">
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) {showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailLoginForm() ? renderEmailLoginForm()
: renderOAuthOptions()} : renderOAuthOptions()}

View File

@@ -4,7 +4,6 @@ import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -79,24 +78,13 @@ const PasswordResetConfirm = () => {
} }
return ( return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden"> <div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景图片容器 - 放大并保持居中 */} <div className="w-full max-w-sm">
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2"> <div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div> </div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden"> <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">

View File

@@ -5,7 +5,6 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons'; import { IconMail } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -79,24 +78,13 @@ const PasswordResetForm = () => {
} }
return ( return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden"> <div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
{/* 背景图片容器 - 放大并保持居中 */} <div className="w-full max-w-sm">
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2"> <div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div> </div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden"> <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">

View File

@@ -33,7 +33,6 @@ import WeChatIcon from '../common/logo/WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src'; import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User/index.js'; import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const RegisterForm = () => { const RegisterForm = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -272,7 +271,7 @@ const RegisterForm = () => {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2"> <div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div> </div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden"> <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -379,7 +378,7 @@ const RegisterForm = () => {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2"> <div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" /> <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title> <Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div> </div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden"> <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -542,17 +541,8 @@ const RegisterForm = () => {
}; };
return ( return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden"> <div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div <div className="w-full max-w-sm">
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) {showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailRegisterForm() ? renderEmailRegisterForm()
: renderOAuthOptions()} : renderOAuthOptions()}

View File

@@ -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 (
<>
<Spin spinning={loading} size='large'>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default DashboardSetting;

View File

@@ -104,6 +104,33 @@ const PersonalSetting = () => {
}); });
const [modelsLoading, setModelsLoading] = useState(true); const [modelsLoading, setModelsLoading] = useState(true);
const [showWebhookDocs, setShowWebhookDocs] = 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(() => { useEffect(() => {
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
@@ -384,107 +411,81 @@ const PersonalSetting = () => {
<Card className="!rounded-2xl shadow-lg border-0"> <Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部用户信息区域 */} {/* 顶部用户信息区域 */}
<Card <Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden" className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
style={{ style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)', background: isDarkMode
? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
position: 'relative' position: 'relative'
}} }}
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
{/* 装饰性背景元素 */} {/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div> <div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 dark:bg-slate-500 opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div> <div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 dark:bg-slate-400 opacity-8 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div> <div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 dark:bg-slate-500 opacity-6 rounded-full"></div>
</div> </div>
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}> <div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
<div className="flex justify-between items-start mb-4 sm:mb-6"> <div className="flex justify-between items-start mb-4 sm:mb-6">
<div className="flex items-center flex-1 min-w-0"> <div className="flex items-center flex-1 min-w-0">
<Avatar <Avatar
size='large' size='large'
color={stringToColor(getUsername())} className="mr-3 sm:mr-4 shadow-md flex-shrink-0 bg-slate-500 dark:bg-slate-400"
border={{ motion: true }}
contentMotion={true}
className="mr-3 sm:mr-4 shadow-lg flex-shrink-0"
> >
{getAvatarText()} {getAvatarText()}
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}> <div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
{getUsername()} {getUsername()}
</div> </div>
<div className="mt-1 flex flex-wrap gap-1 sm:gap-2"> <div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
{isRoot() ? ( {isRoot() ? (
<Tag <Tag
color='red'
size='small' size='small'
style={{ className="!rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600"
backgroundColor: 'rgba(255, 255, 255, 0.95)', style={{ fontWeight: '500' }}
color: '#dc2626',
fontWeight: '600'
}}
className="!rounded-full"
> >
{t('超级管理员')} {t('超级管理员')}
</Tag> </Tag>
) : isAdmin() ? ( ) : isAdmin() ? (
<Tag <Tag
color='orange'
size='small' size='small'
style={{ className="!rounded-full bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600"
backgroundColor: 'rgba(255, 255, 255, 0.95)', style={{ fontWeight: '500' }}
color: '#ea580c',
fontWeight: '600'
}}
className="!rounded-full"
> >
{t('管理员')} {t('管理员')}
</Tag> </Tag>
) : ( ) : (
<Tag <Tag
color='blue'
size='small' size='small'
style={{ className="!rounded-full bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-600"
backgroundColor: 'rgba(255, 255, 255, 0.95)', style={{ fontWeight: '500' }}
color: '#2563eb',
fontWeight: '600'
}}
className="!rounded-full"
> >
{t('普通用户')} {t('普通用户')}
</Tag> </Tag>
)} )}
<Tag <Tag
color='green'
size='small' size='small'
className="!rounded-full" className="!rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-600"
style={{ style={{ fontWeight: '500' }}
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#16a34a',
fontWeight: '600'
}}
> >
ID: {userState?.user?.id} ID: {userState?.user?.id}
</Tag> </Tag>
</div> </div>
</div> </div>
</div> </div>
<div <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2" <IconUser size="default" className="text-white" />
style={{
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
}}
>
<IconUser size="default" style={{ color: 'white' }} />
</div> </div>
</div> </div>
<div className="mb-4 sm:mb-6"> <div className="mb-4 sm:mb-6">
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}> <div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
{t('当前余额')} {t('当前余额')}
</div> </div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}> <div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
{renderQuota(userState?.user?.quota)} {renderQuota(userState?.user?.quota)}
</div> </div>
</div> </div>
@@ -492,33 +493,33 @@ const PersonalSetting = () => {
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0"> <div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}> <div className="text-xs text-gray-400 dark:text-gray-500">
{t('历史消耗')} {t('历史消耗')}
</div> </div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}> <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{renderQuota(userState?.user?.used_quota)} {renderQuota(userState?.user?.used_quota)}
</div> </div>
</div> </div>
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}> <div className="text-xs text-gray-400 dark:text-gray-500">
{t('请求次数')} {t('请求次数')}
</div> </div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}> <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState.user?.request_count || 0} {userState.user?.request_count || 0}
</div> </div>
</div> </div>
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}> <div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户分组')} {t('用户分组')}
</div> </div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}> <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState?.user?.group || t('默认')} {userState?.user?.group || t('默认')}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div> <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
</div> </div>
</Card> </Card>
@@ -537,10 +538,10 @@ const PersonalSetting = () => {
> >
<div className="gap-6 py-4"> <div className="gap-6 py-4">
{/* 可用模型部分 */} {/* 可用模型部分 */}
<div className="bg-gray-50 rounded-xl"> <div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<Settings size={20} className="text-purple-500" /> <Settings size={20} className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div> <div>
<Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title> <Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
@@ -629,7 +630,7 @@ const PersonalSetting = () => {
</Tabs> </Tabs>
</div> </div>
<div className="bg-white rounded-lg p-3"> <div className="bg-white dark:bg-gray-700 rounded-lg p-3">
{(() => { {(() => {
// 根据当前选中的分类过滤模型 // 根据当前选中的分类过滤模型
const categories = getModelCategories(t); const categories = getModelCategories(t);
@@ -736,9 +737,9 @@ const PersonalSetting = () => {
shadows='hover' shadows='hover'
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconMail size="default" className="text-red-500" /> <IconMail size="default" className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('邮箱')}</div> <div className="font-medium text-gray-900">{t('邮箱')}</div>
@@ -771,8 +772,8 @@ const PersonalSetting = () => {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiWechat size={20} className="text-green-500" /> <SiWechat size={20} className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('微信')}</div> <div className="font-medium text-gray-900">{t('微信')}</div>
@@ -808,8 +809,8 @@ const PersonalSetting = () => {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconGithubLogo size="default" className="text-gray-700" /> <IconGithubLogo size="default" className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('GitHub')}</div> <div className="font-medium text-gray-900">{t('GitHub')}</div>
@@ -844,8 +845,8 @@ const PersonalSetting = () => {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconShield size="default" className="text-indigo-500" /> <IconShield size="default" className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('OIDC')}</div> <div className="font-medium text-gray-900">{t('OIDC')}</div>
@@ -883,8 +884,8 @@ const PersonalSetting = () => {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiTelegram size={20} className="text-blue-500" /> <SiTelegram size={20} className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('Telegram')}</div> <div className="font-medium text-gray-900">{t('Telegram')}</div>
@@ -926,8 +927,8 @@ const PersonalSetting = () => {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3"> <div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiLinux size={20} className="text-orange-500" /> <SiLinux size={20} className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('LinuxDO')}</div> <div className="font-medium text-gray-900">{t('LinuxDO')}</div>
@@ -978,8 +979,8 @@ const PersonalSetting = () => {
> >
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto"> <div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4 flex-shrink-0"> <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-blue-500" /> <IconKey size="large" className="text-slate-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Typography.Title heading={6} className="mb-1"> <Typography.Title heading={6} className="mb-1">
@@ -1006,7 +1007,7 @@ const PersonalSetting = () => {
type="primary" type="primary"
theme="solid" theme="solid"
onClick={generateAccessToken} 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={<IconKey />} icon={<IconKey />}
> >
{systemToken ? t('重新生成') : t('生成令牌')} {systemToken ? t('重新生成') : t('生成令牌')}
@@ -1022,8 +1023,8 @@ const PersonalSetting = () => {
> >
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto"> <div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4 flex-shrink-0"> <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-orange-500" /> <IconLock size="large" className="text-slate-600" />
</div> </div>
<div> <div>
<Typography.Title heading={6} className="mb-1"> <Typography.Title heading={6} className="mb-1">
@@ -1038,7 +1039,7 @@ const PersonalSetting = () => {
type="primary" type="primary"
theme="solid" theme="solid"
onClick={() => setShowChangePasswordModal(true)} 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={<IconLock />} icon={<IconLock />}
> >
{t('修改密码')} {t('修改密码')}
@@ -1054,11 +1055,11 @@ const PersonalSetting = () => {
> >
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto"> <div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mr-4 flex-shrink-0"> <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-red-500" /> <IconDelete size="large" className="text-slate-600" />
</div> </div>
<div> <div>
<Typography.Title heading={6} className="mb-1 text-red-600"> <Typography.Title heading={6} className="mb-1 text-slate-700">
{t('删除账户')} {t('删除账户')}
</Typography.Title> </Typography.Title>
<Typography.Text type="tertiary" className="text-sm"> <Typography.Text type="tertiary" className="text-sm">
@@ -1070,7 +1071,7 @@ const PersonalSetting = () => {
type="danger" type="danger"
theme="solid" theme="solid"
onClick={() => setShowAccountDeleteModal(true)} 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={<IconDelete />} icon={<IconDelete />}
> >
{t('删除账户')} {t('删除账户')}
@@ -1111,7 +1112,7 @@ const PersonalSetting = () => {
> >
<Radio value='email' className="!p-4 !rounded-lg"> <Radio value='email' className="!p-4 !rounded-lg">
<div className="flex items-center"> <div className="flex items-center">
<IconMail className="mr-2 text-blue-500" /> <IconMail className="mr-2 text-slate-600" />
<div> <div>
<div className="font-medium">{t('邮件通知')}</div> <div className="font-medium">{t('邮件通知')}</div>
<div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div> <div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
@@ -1120,7 +1121,7 @@ const PersonalSetting = () => {
</Radio> </Radio>
<Radio value='webhook' className="!p-4 !rounded-lg"> <Radio value='webhook' className="!p-4 !rounded-lg">
<div className="flex items-center"> <div className="flex items-center">
<Webhook size={16} className="mr-2 text-green-500" /> <Webhook size={16} className="mr-2 text-slate-600" />
<div> <div>
<div className="font-medium">{t('Webhook通知')}</div> <div className="font-medium">{t('Webhook通知')}</div>
<div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div> <div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
@@ -1167,11 +1168,11 @@ const PersonalSetting = () => {
</div> </div>
</div> </div>
<div className="bg-yellow-50 rounded-xl"> <div className="bg-slate-50 rounded-xl">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}> <div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
<div className="flex items-center"> <div className="flex items-center">
<Globe size={16} className="mr-2 text-yellow-600" /> <Globe size={16} className="mr-2 text-slate-600" />
<Typography.Text strong className="text-yellow-800"> <Typography.Text strong className="text-slate-700">
{t('Webhook请求结构')} {t('Webhook请求结构')}
</Typography.Text> </Typography.Text>
</div> </div>
@@ -1254,11 +1255,11 @@ const PersonalSetting = () => {
itemKey='price' itemKey='price'
> >
<div className="py-4"> <div className="py-4">
<div className="bg-white rounded-xl"> <div className="bg-white rounded-xl">
<div className="flex items-start"> <div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mt-1"> <div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
<Shield size={20} className="text-orange-500" /> <Shield size={20} className="text-slate-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -1292,7 +1293,7 @@ const PersonalSetting = () => {
type='primary' type='primary'
onClick={saveNotificationSettings} onClick={saveNotificationSettings}
size="large" size="large"
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600" className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
icon={<IconSetting />} icon={<IconSetting />}
> >
{t('保存设置')} {t('保存设置')}
@@ -1408,7 +1409,7 @@ const PersonalSetting = () => {
theme="solid" theme="solid"
size='large' size='large'
onClick={bindWeChat} 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={<SiWechat size={16} />} icon={<SiWechat size={16} />}
> >
{t('绑定')} {t('绑定')}

View File

@@ -6,15 +6,31 @@ import {
showSuccess, showSuccess,
timestamp2string, timestamp2string,
renderGroup, renderGroup,
renderQuotaWithAmount, renderNumberWithPoint,
renderQuota renderQuota,
getChannelIcon
} from '../../helpers/index.js'; } 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 { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import { import {
Button, Button,
Divider, Divider,
Dropdown, Dropdown,
Empty,
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
@@ -25,13 +41,15 @@ import {
Tag, Tag,
Tooltip, Tooltip,
Typography, Typography,
Checkbox,
Card, Card,
Select Form
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import EditChannel from '../../pages/Channel/EditChannel.js'; import EditChannel from '../../pages/Channel/EditChannel.js';
import { import {
IconList,
IconTreeTriangleDown, IconTreeTriangleDown,
IconFilter, IconFilter,
IconPlus, IconPlus,
@@ -64,7 +82,12 @@ const ChannelsTable = () => {
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' }; type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
} }
return ( return (
<Tag size='large' color={type2label[type]?.color} shape='circle'> <Tag
size='large'
color={type2label[type]?.color}
shape='circle'
prefixIcon={getChannelIcon(type)}
>
{type2label[type]?.label} {type2label[type]?.label}
</Tag> </Tag>
); );
@@ -74,7 +97,7 @@ const ChannelsTable = () => {
return ( return (
<Tag <Tag
color='light-blue' color='light-blue'
prefixIcon={<IconList />} prefixIcon={<Tags size={14} />}
size='large' size='large'
shape='circle' shape='circle'
type='light' type='light'
@@ -88,25 +111,25 @@ const ChannelsTable = () => {
switch (status) { switch (status) {
case 1: case 1:
return ( return (
<Tag size='large' color='green' shape='circle'> <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')} {t('已启用')}
</Tag> </Tag>
); );
case 2: case 2:
return ( return (
<Tag size='large' color='yellow' shape='circle'> <Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')} {t('已禁用')}
</Tag> </Tag>
); );
case 3: case 3:
return ( return (
<Tag size='large' color='yellow' shape='circle'> <Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')} {t('自动禁用')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag size='large' color='grey' shape='circle'> <Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')} {t('未知状态')}
</Tag> </Tag>
); );
@@ -118,139 +141,48 @@ const ChannelsTable = () => {
time = time.toFixed(2) + t(' 秒'); time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) { if (responseTime === 0) {
return ( return (
<Tag size='large' color='grey' shape='circle'> <Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
{t('未测试')} {t('未测试')}
</Tag> </Tag>
); );
} else if (responseTime <= 1000) { } else if (responseTime <= 1000) {
return ( return (
<Tag size='large' color='green' shape='circle'> <Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
{time} {time}
</Tag> </Tag>
); );
} else if (responseTime <= 3000) { } else if (responseTime <= 3000) {
return ( return (
<Tag size='large' color='lime' shape='circle'> <Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
{time} {time}
</Tag> </Tag>
); );
} else if (responseTime <= 5000) { } else if (responseTime <= 5000) {
return ( return (
<Tag size='large' color='yellow' shape='circle'> <Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{time} {time}
</Tag> </Tag>
); );
} else { } else {
return ( return (
<Tag size='large' color='red' shape='circle'> <Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
{time} {time}
</Tag> </Tag>
); );
} }
}; };
// Define column keys for selection // Define all columns
const COLUMN_KEYS = { const columns = [
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 = [
{ {
key: COLUMN_KEYS.ID,
title: t('ID'), title: t('ID'),
dataIndex: 'id', dataIndex: 'id',
}, },
{ {
key: COLUMN_KEYS.NAME,
title: t('名称'), title: t('名称'),
dataIndex: 'name', dataIndex: 'name',
}, },
{ {
key: COLUMN_KEYS.GROUP,
title: t('分组'), title: t('分组'),
dataIndex: 'group', dataIndex: 'group',
render: (text, record, index) => ( render: (text, record, index) => (
@@ -269,7 +201,6 @@ const ChannelsTable = () => {
), ),
}, },
{ {
key: COLUMN_KEYS.TYPE,
title: t('类型'), title: t('类型'),
dataIndex: 'type', dataIndex: 'type',
render: (text, record, index) => { render: (text, record, index) => {
@@ -281,7 +212,6 @@ const ChannelsTable = () => {
}, },
}, },
{ {
key: COLUMN_KEYS.STATUS,
title: t('状态'), title: t('状态'),
dataIndex: 'status', dataIndex: 'status',
render: (text, record, index) => { render: (text, record, index) => {
@@ -307,7 +237,6 @@ const ChannelsTable = () => {
}, },
}, },
{ {
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'), title: t('响应时间'),
dataIndex: 'response_time', dataIndex: 'response_time',
render: (text, record, index) => ( render: (text, record, index) => (
@@ -315,7 +244,6 @@ const ChannelsTable = () => {
), ),
}, },
{ {
key: COLUMN_KEYS.BALANCE,
title: t('已用/剩余'), title: t('已用/剩余'),
dataIndex: 'expired_time', dataIndex: 'expired_time',
render: (text, record, index) => { render: (text, record, index) => {
@@ -324,7 +252,7 @@ const ChannelsTable = () => {
<div> <div>
<Space spacing={1}> <Space spacing={1}>
<Tooltip content={t('已用额度')}> <Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'> <Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(record.used_quota)} {renderQuota(record.used_quota)}
</Tag> </Tag>
</Tooltip> </Tooltip>
@@ -334,6 +262,7 @@ const ChannelsTable = () => {
type='ghost' type='ghost'
size='large' size='large'
shape='circle' shape='circle'
prefixIcon={<Coins size={14} />}
onClick={() => updateChannelBalance(record)} onClick={() => updateChannelBalance(record)}
> >
{renderQuotaWithAmount(record.balance)} {renderQuotaWithAmount(record.balance)}
@@ -345,7 +274,7 @@ const ChannelsTable = () => {
} else { } else {
return ( return (
<Tooltip content={t('已用额度')}> <Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'> <Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(record.used_quota)} {renderQuota(record.used_quota)}
</Tag> </Tag>
</Tooltip> </Tooltip>
@@ -354,7 +283,6 @@ const ChannelsTable = () => {
}, },
}, },
{ {
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'), title: t('优先级'),
dataIndex: 'priority', dataIndex: 'priority',
render: (text, record, index) => { render: (text, record, index) => {
@@ -406,7 +334,6 @@ const ChannelsTable = () => {
}, },
}, },
{ {
key: COLUMN_KEYS.WEIGHT,
title: t('权重'), title: t('权重'),
dataIndex: 'weight', dataIndex: 'weight',
render: (text, record, index) => { render: (text, record, index) => {
@@ -458,7 +385,6 @@ const ChannelsTable = () => {
}, },
}, },
{ {
key: COLUMN_KEYS.OPERATE,
title: '', title: '',
dataIndex: 'operate', dataIndex: 'operate',
fixed: 'right', 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 (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('确定')}
</Button>
</div>
}
size="middle"
centered={true}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip columns without title
if (!column.title) {
return null;
}
return (
<div
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
const [channels, setChannels] = useState([]); const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [idSort, setIdSort] = useState(false); const [idSort, setIdSort] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchGroup, setSearchGroup] = useState('');
const [searchModel, setSearchModel] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [channelCount, setChannelCount] = useState(pageSize); const [channelCount, setChannelCount] = useState(pageSize);
@@ -745,6 +585,16 @@ const ChannelsTable = () => {
const [testQueue, setTestQueue] = useState([]); const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false); const [isProcessingQueue, setIsProcessingQueue] = useState(false);
// Form API 引用
const [formApi, setFormApi] = useState(null);
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchGroup: '',
searchModel: '',
};
const removeRecord = (record) => { const removeRecord = (record) => {
let newDataSource = [...channels]; let newDataSource = [...channels];
if (record.id != null) { if (record.id != null) {
@@ -896,15 +746,11 @@ const ChannelsTable = () => {
}; };
const refresh = async () => { const refresh = async () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') { if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
} else { } else {
await searchChannels( await searchChannels(enableTagMode);
searchKeyword,
searchGroup,
searchModel,
enableTagMode,
);
} }
}; };
@@ -1010,29 +856,40 @@ const ChannelsTable = () => {
} }
}; };
const searchChannels = async ( // 获取表单值的辅助函数,确保所有值都是字符串
searchKeyword, const getFormValues = () => {
searchGroup, const formValues = formApi ? formApi.getValues() : {};
searchModel, return {
enableTagMode, searchKeyword: formValues.searchKeyword || '',
) => { searchGroup: formValues.searchGroup || '',
if (searchKeyword === '' && searchGroup === '' && searchModel === '') { searchModel: formValues.searchModel || '',
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode); };
// setActivePage(1); };
return;
} const searchChannels = async (enableTagMode) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setSearching(true); setSearching(true);
const res = await API.get( try {
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`, if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
); await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
const { success, message, data } = res.data; // setActivePage(1);
if (success) { return;
setChannelFormat(data, enableTagMode); }
setActivePage(1);
} else { const res = await API.get(
showError(message); `/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) => { const updateChannelProperty = (channelId, updateFn) => {
@@ -1540,71 +1397,83 @@ const ChannelsTable = () => {
> >
{t('刷新')} {t('刷新')}
</Button> </Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full w-full md:w-auto"
>
{t('列设置')}
</Button>
</div> </div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2"> <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64"> <Form
<Input initValues={formInitValues}
prefix={<IconSearch />} getFormApi={(api) => setFormApi(api)}
placeholder={t('搜索渠道的 ID名称密钥和API地址 ...')} onSubmit={() => searchChannels(enableTagMode)}
value={searchKeyword} allowEmpty={true}
loading={searching} autoComplete="off"
onChange={(v) => { layout="horizontal"
setSearchKeyword(v.trim()); trigger="change"
}} stopValidateWithError={false}
className="!rounded-full" className="flex flex-col md:flex-row items-center gap-4 w-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Input
prefix={<IconFilter />}
placeholder={t('模型关键字')}
value={searchModel}
loading={searching}
onChange={(v) => {
setSearchModel(v.trim());
}}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
value={searchGroup}
onChange={(v) => {
setSearchGroup(v);
searchChannels(searchKeyword, v, searchModel, enableTagMode);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
> >
{t('查询')} <div className="relative w-full md:w-64">
</Button> <Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID名称密钥和API地址 ...')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Input
field="searchModel"
prefix={<IconFilter />}
placeholder={t('模型关键字')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className="!rounded-full w-full"
showClear
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
searchChannels(enableTagMode);
}, 0);
}}
/>
</div>
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full w-full md:w-auto"
>
{t('重置')}
</Button>
</Form>
</div> </div>
</div> </div>
</div> </div>
@@ -1612,7 +1481,6 @@ const ChannelsTable = () => {
return ( return (
<> <>
{renderColumnSelector()}
<EditTagModal <EditTagModal
visible={showEditTag} visible={showEditTag}
tag={editingTag} tag={editingTag}
@@ -1633,7 +1501,7 @@ const ChannelsTable = () => {
bordered={false} bordered={false}
> >
<Table <Table
columns={getVisibleColumns()} columns={columns}
dataSource={pageData} dataSource={pageData}
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{
@@ -1663,6 +1531,14 @@ const ChannelsTable = () => {
} }
: null : null
} }
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
loading={loading} loading={loading}

View File

@@ -1,5 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
CreditCard,
ShoppingCart,
Settings,
Server,
AlertTriangle,
HelpCircle,
Zap,
Play,
Clock,
Hash,
Key
} from 'lucide-react';
import { import {
API, API,
copy, copy,
@@ -20,16 +33,16 @@ import {
renderQuota, renderQuota,
stringToColor, stringToColor,
getLogOther, getLogOther,
renderModelTag, renderModelTag
} from '../../helpers'; } from '../../helpers';
import { import {
Avatar, Avatar,
Button, Button,
Descriptions, Descriptions,
Empty,
Modal, Modal,
Popover, Popover,
Select,
Space, Space,
Spin, Spin,
Table, Table,
@@ -39,24 +52,18 @@ import {
Card, Card,
Typography, Typography,
Divider, Divider,
Input, Form
DatePicker,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph'; import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons'; import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
const MODE_OPTIONS = [
{ key: 'all', text: 'all', value: 'all' },
{ key: 'self', text: 'current user', value: 'self' },
];
const colors = [ const colors = [
'amber', 'amber',
'blue', 'blue',
@@ -238,11 +245,6 @@ const LogsTable = () => {
onClick: (event) => { onClick: (event) => {
copyText(event, record.model_name).then((r) => { }); copyText(event, record.model_name).then((r) => { });
}, },
suffixIcon: (
<IconForward
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
/>
),
})} })}
</Popover> </Popover>
</Space> </Space>
@@ -737,39 +739,67 @@ const LogsTable = () => {
const [logType, setLogType] = useState(0); const [logType, setLogType] = useState(0);
const isAdminUser = isAdmin(); const isAdminUser = isAdmin();
let now = new Date(); let now = new Date();
// 初始化start_timestamp为今天0点
const [inputs, setInputs] = useState({ // Form 初始值
const formInitValues = {
username: '', username: '',
token_name: '', token_name: '',
model_name: '', model_name: '',
start_timestamp: timestamp2string(getTodayStartTimestamp()),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
channel: '', channel: '',
group: '', group: '',
}); dateRange: [
const { timestamp2string(getTodayStartTimestamp()),
username, timestamp2string(now.getTime() / 1000 + 3600)
token_name, ],
model_name, logType: '0',
start_timestamp, };
end_timestamp,
channel,
group,
} = inputs;
const [stat, setStat] = useState({ const [stat, setStat] = useState({
quota: 0, quota: 0,
token: 0, token: 0,
}); });
const handleInputChange = (value, name) => { // Form API 引用
setInputs((inputs) => ({ ...inputs, [name]: value })); const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数,确保所有值都是字符串
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
// 处理时间范围
let start_timestamp = timestamp2string(getTodayStartTimestamp());
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 {
username: formValues.username || '',
token_name: formValues.token_name || '',
model_name: formValues.model_name || '',
start_timestamp,
end_timestamp,
channel: formValues.channel || '',
group: formValues.group || '',
logType: formValues.logType ? parseInt(formValues.logType) : 0,
};
}; };
const getLogSelfStat = async () => { const getLogSelfStat = async () => {
const {
token_name,
model_name,
start_timestamp,
end_timestamp,
group,
logType: formLogType,
} = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`; let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
url = encodeURI(url); url = encodeURI(url);
let res = await API.get(url); let res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
@@ -781,9 +811,20 @@ const LogsTable = () => {
}; };
const getLogStat = async () => { const getLogStat = async () => {
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
logType: formLogType,
} = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`; let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
url = encodeURI(url); url = encodeURI(url);
let res = await API.get(url); let res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
@@ -1016,16 +1057,30 @@ const LogsTable = () => {
setLogs(logs); setLogs(logs);
}; };
const loadLogs = async (startIdx, pageSize, logType = 0) => { const loadLogs = async (startIdx, pageSize, customLogType = null) => {
setLoading(true); setLoading(true);
let url = ''; let url = '';
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
logType: formLogType,
} = getFormValues();
// 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
const currentLogType = customLogType !== null ? customLogType : formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) { if (isAdminUser) {
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`; url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
} else { } else {
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`; url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
} }
url = encodeURI(url); url = encodeURI(url);
const res = await API.get(url); const res = await API.get(url);
@@ -1045,7 +1100,7 @@ const LogsTable = () => {
const handlePageChange = (page) => { const handlePageChange = (page) => {
setActivePage(page); setActivePage(page);
loadLogs(page, pageSize, logType).then((r) => { }); loadLogs(page, pageSize).then((r) => { }); // 不传入logType让其从表单获取最新值
}; };
const handlePageSizeChange = async (size) => { const handlePageSizeChange = async (size) => {
@@ -1062,7 +1117,7 @@ const LogsTable = () => {
const refresh = async () => { const refresh = async () => {
setActivePage(1); setActivePage(1);
handleEyeClick(); handleEyeClick();
await loadLogs(activePage, pageSize, logType); await loadLogs(1, pageSize); // 不传入logType让其从表单获取最新值
}; };
const copyText = async (e, text) => { const copyText = async (e, text) => {
@@ -1083,9 +1138,15 @@ const LogsTable = () => {
.catch((reason) => { .catch((reason) => {
showError(reason); showError(reason);
}); });
handleEyeClick();
}, []); }, []);
// 当 formApi 可用时,初始化统计
useEffect(() => {
if (formApi) {
handleEyeClick();
}
}, [formApi]);
const expandRowRender = (record, index) => { const expandRowRender = (record, index) => {
return <Descriptions data={expandData[record.key]} />; return <Descriptions data={expandData[record.key]} />;
}; };
@@ -1149,115 +1210,144 @@ const LogsTable = () => {
<Divider margin='12px' /> <Divider margin='12px' />
{/* 搜索表单区域 */} {/* 搜索表单区域 */}
<div className='flex flex-col gap-4'> <Form
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'> initValues={formInitValues}
{/* 时间选择器 */} getFormApi={(api) => setFormApi(api)}
<div className='col-span-1 lg:col-span-2'> onSubmit={refresh}
<DatePicker allowEmpty={true}
className='w-full' autoComplete="off"
value={[start_timestamp, end_timestamp]} layout="vertical"
type='dateTimeRange' trigger="change"
onChange={(value) => { stopValidateWithError={false}
if (Array.isArray(value) && value.length === 2) { >
handleInputChange(value[0], 'start_timestamp'); <div className='flex flex-col gap-4'>
handleInputChange(value[1], 'end_timestamp'); <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
} {/* 时间选择器 */}
}} <div className='col-span-1 lg:col-span-2'>
<Form.DatePicker
field='dateRange'
className='w-full'
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 其他搜索字段 */}
<Form.Input
field='token_name'
prefix={<IconSearch />}
placeholder={t('令牌名称')}
className='!rounded-full'
showClear
pure
/> />
<Form.Input
field='model_name'
prefix={<IconSearch />}
placeholder={t('模型名称')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='group'
prefix={<IconSearch />}
placeholder={t('分组')}
className='!rounded-full'
showClear
pure
/>
{isAdminUser && (
<>
<Form.Input
field='channel'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='username'
prefix={<IconSearch />}
placeholder={t('用户名称')}
className='!rounded-full'
showClear
pure
/>
</>
)}
</div> </div>
{/* 日志类型选择器 */} {/* 操作按钮区域 */}
<Select <div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
value={logType.toString()} {/* 日志类型选择器 */}
placeholder={t('日志类型')} <div className='w-full sm:w-auto'>
className='!rounded-full' <Form.Select
onChange={(value) => { field='logType'
setLogType(parseInt(value)); placeholder={t('日志类型')}
loadLogs(0, pageSize, parseInt(value)); className='!rounded-full w-full sm:w-auto min-w-[120px]'
}}
>
<Select.Option value='0'>{t('全部')}</Select.Option>
<Select.Option value='1'>{t('充值')}</Select.Option>
<Select.Option value='2'>{t('消费')}</Select.Option>
<Select.Option value='3'>{t('管理')}</Select.Option>
<Select.Option value='4'>{t('系统')}</Select.Option>
<Select.Option value='5'>{t('错误')}</Select.Option>
</Select>
{/* 其他搜索字段 */}
<Input
prefix={<IconSearch />}
placeholder={t('令牌名称')}
value={token_name}
onChange={(value) => handleInputChange(value, 'token_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('模型名称')}
value={model_name}
onChange={(value) => handleInputChange(value, 'model_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('分组')}
value={group}
onChange={(value) => handleInputChange(value, 'group')}
className='!rounded-full'
showClear
/>
{isAdminUser && (
<>
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel}
onChange={(value) => handleInputChange(value, 'channel')}
className='!rounded-full'
showClear showClear
/> pure
<Input onChange={() => {
prefix={<IconSearch />} // 延迟执行搜索,让表单值先更新
placeholder={t('用户名称')} setTimeout(() => {
value={username} refresh();
onChange={(value) => handleInputChange(value, 'username')} }, 0);
className='!rounded-full' }}
showClear >
/> <Form.Select.Option value='0'>{t('全部')}</Form.Select.Option>
</> <Form.Select.Option value='1'>{t('充值')}</Form.Select.Option>
)} <Form.Select.Option value='2'>{t('消费')}</Form.Select.Option>
</div> <Form.Select.Option value='3'>{t('管理')}</Form.Select.Option>
<Form.Select.Option value='4'>{t('系统')}</Form.Select.Option>
<Form.Select.Option value='5'>{t('错误')}</Form.Select.Option>
</Form.Select>
</div>
{/* 操作按钮区域 */} <div className='flex gap-2 w-full sm:w-auto justify-end'>
<div className='flex justify-between items-center pt-2'> <Button
<div></div> type='primary'
<div className='flex gap-2'> htmlType='submit'
<Button loading={loading}
type='primary' className='!rounded-full'
onClick={refresh} >
loading={loading} {t('查询')}
className='!rounded-full' </Button>
> <Button
{t('查询')} theme='light'
</Button> onClick={() => {
<Button if (formApi) {
theme='light' formApi.reset();
type='tertiary' setLogType(0);
icon={<IconSetting />} // 重置后立即查询使用setTimeout确保表单重置完成
onClick={() => setShowColumnSelector(true)} setTimeout(() => {
className='!rounded-full' refresh();
> }, 100);
{t('列设置')} }
</Button> }}
className='!rounded-full'
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className='!rounded-full'
>
{t('列设置')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </Form>
</div> </div>
} }
shadows='always' shadows='always'
@@ -1276,6 +1366,14 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
className='rounded-xl overflow-hidden' className='rounded-xl overflow-hidden'
size='middle' size='middle'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{ pagination={{
formatPageText: (page) => formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', { t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -1,35 +1,65 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { import {
API, API,
copy, copy,
isAdmin, isAdmin,
showError, showError,
showSuccess, showSuccess,
timestamp2string, timestamp2string
} from '../../helpers'; } from '../../helpers';
import { import {
Button, Button,
Card, Card,
Checkbox, Checkbox,
DatePicker,
Divider, Divider,
Empty,
Form,
ImagePreview, ImagePreview,
Input,
Layout, Layout,
Modal, Modal,
Progress, Progress,
Skeleton, Skeleton,
Table, Table,
Tag, Tag,
Typography, Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { import {
IconEyeOpened, IconEyeOpened,
IconSearch, IconSearch,
IconSetting, IconSetting
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
@@ -154,103 +184,103 @@ const LogsTable = () => {
switch (type) { switch (type) {
case 'IMAGINE': case 'IMAGINE':
return ( return (
<Tag color='blue' size='large' shape='circle'> <Tag color='blue' size='large' shape='circle' prefixIcon={<Palette size={14} />}>
{t('绘图')} {t('绘图')}
</Tag> </Tag>
); );
case 'UPSCALE': case 'UPSCALE':
return ( return (
<Tag color='orange' size='large' shape='circle'> <Tag color='orange' size='large' shape='circle' prefixIcon={<ZoomIn size={14} />}>
{t('放大')} {t('放大')}
</Tag> </Tag>
); );
case 'VARIATION': case 'VARIATION':
return ( return (
<Tag color='purple' size='large' shape='circle'> <Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('变换')} {t('变换')}
</Tag> </Tag>
); );
case 'HIGH_VARIATION': case 'HIGH_VARIATION':
return ( return (
<Tag color='purple' size='large' shape='circle'> <Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('强变换')} {t('强变换')}
</Tag> </Tag>
); );
case 'LOW_VARIATION': case 'LOW_VARIATION':
return ( return (
<Tag color='purple' size='large' shape='circle'> <Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('弱变换')} {t('弱变换')}
</Tag> </Tag>
); );
case 'PAN': case 'PAN':
return ( return (
<Tag color='cyan' size='large' shape='circle'> <Tag color='cyan' size='large' shape='circle' prefixIcon={<Move size={14} />}>
{t('平移')} {t('平移')}
</Tag> </Tag>
); );
case 'DESCRIBE': case 'DESCRIBE':
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
{t('图生文')} {t('图生文')}
</Tag> </Tag>
); );
case 'BLEND': case 'BLEND':
return ( return (
<Tag color='lime' size='large' shape='circle'> <Tag color='lime' size='large' shape='circle' prefixIcon={<Blend size={14} />}>
{t('图混合')} {t('图混合')}
</Tag> </Tag>
); );
case 'UPLOAD': case 'UPLOAD':
return ( return (
<Tag color='blue' size='large' shape='circle'> <Tag color='blue' size='large' shape='circle' prefixIcon={<Upload size={14} />}>
上传文件 上传文件
</Tag> </Tag>
); );
case 'SHORTEN': case 'SHORTEN':
return ( return (
<Tag color='pink' size='large' shape='circle'> <Tag color='pink' size='large' shape='circle' prefixIcon={<Minimize2 size={14} />}>
{t('缩词')} {t('缩词')}
</Tag> </Tag>
); );
case 'REROLL': case 'REROLL':
return ( return (
<Tag color='indigo' size='large' shape='circle'> <Tag color='indigo' size='large' shape='circle' prefixIcon={<RotateCcw size={14} />}>
{t('重绘')} {t('重绘')}
</Tag> </Tag>
); );
case 'INPAINT': case 'INPAINT':
return ( return (
<Tag color='violet' size='large' shape='circle'> <Tag color='violet' size='large' shape='circle' prefixIcon={<PaintBucket size={14} />}>
{t('局部重绘-提交')} {t('局部重绘-提交')}
</Tag> </Tag>
); );
case 'ZOOM': case 'ZOOM':
return ( return (
<Tag color='teal' size='large' shape='circle'> <Tag color='teal' size='large' shape='circle' prefixIcon={<Focus size={14} />}>
{t('变焦')} {t('变焦')}
</Tag> </Tag>
); );
case 'CUSTOM_ZOOM': case 'CUSTOM_ZOOM':
return ( return (
<Tag color='teal' size='large' shape='circle'> <Tag color='teal' size='large' shape='circle' prefixIcon={<Move3D size={14} />}>
{t('自定义变焦-提交')} {t('自定义变焦-提交')}
</Tag> </Tag>
); );
case 'MODAL': case 'MODAL':
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<Monitor size={14} />}>
{t('窗口处理')} {t('窗口处理')}
</Tag> </Tag>
); );
case 'SWAP_FACE': case 'SWAP_FACE':
return ( return (
<Tag color='light-green' size='large' shape='circle'> <Tag color='light-green' size='large' shape='circle' prefixIcon={<UserCheck size={14} />}>
{t('换脸')} {t('换脸')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
@@ -261,31 +291,31 @@ const LogsTable = () => {
switch (code) { switch (code) {
case 1: case 1:
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已提交')} {t('已提交')}
</Tag> </Tag>
); );
case 21: case 21:
return ( return (
<Tag color='lime' size='large' shape='circle'> <Tag color='lime' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('等待中')} {t('等待中')}
</Tag> </Tag>
); );
case 22: case 22:
return ( return (
<Tag color='orange' size='large' shape='circle'> <Tag color='orange' size='large' shape='circle' prefixIcon={<Copy size={14} />}>
{t('重复提交')} {t('重复提交')}
</Tag> </Tag>
); );
case 0: case 0:
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<FileX size={14} />}>
{t('未提交')} {t('未提交')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
@@ -296,43 +326,43 @@ const LogsTable = () => {
switch (type) { switch (type) {
case 'SUCCESS': case 'SUCCESS':
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')} {t('成功')}
</Tag> </Tag>
); );
case 'NOT_START': case 'NOT_START':
return ( return (
<Tag color='grey' size='large' shape='circle'> <Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')} {t('未启动')}
</Tag> </Tag>
); );
case 'SUBMITTED': case 'SUBMITTED':
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')} {t('队列中')}
</Tag> </Tag>
); );
case 'IN_PROGRESS': case 'IN_PROGRESS':
return ( return (
<Tag color='blue' size='large' shape='circle'> <Tag color='blue' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
{t('执行中')} {t('执行中')}
</Tag> </Tag>
); );
case 'FAILURE': case 'FAILURE':
return ( return (
<Tag color='red' size='large' shape='circle'> <Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')} {t('失败')}
</Tag> </Tag>
); );
case 'MODAL': case 'MODAL':
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('窗口等待')} {t('窗口等待')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
@@ -362,7 +392,7 @@ const LogsTable = () => {
const color = durationSec > 60 ? 'red' : 'green'; const color = durationSec > 60 ? 'red' : 'green';
return ( return (
<Tag color={color} size='large' shape='circle'> <Tag color={color} size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec} {t('秒')} {durationSec} {t('秒')}
</Tag> </Tag>
); );
@@ -398,6 +428,7 @@ const LogsTable = () => {
color={colors[parseInt(text) % colors.length]} color={colors[parseInt(text) % colors.length]}
size='large' size='large'
shape='circle' shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => { onClick={() => {
copyText(text); copyText(text);
}} }}
@@ -462,7 +493,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0} percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true} showInfo={true}
aria-label='drawing progress' aria-label='drawing progress'
style={{ minWidth: '200px' }} style={{ minWidth: '160px' }}
/> />
} }
</div> </div>
@@ -483,6 +514,7 @@ const LogsTable = () => {
setModalImageUrl(text); setModalImageUrl(text);
setIsModalOpenurl(true); setIsModalOpenurl(true);
}} }}
className="!rounded-full"
> >
{t('查看图片')} {t('查看图片')}
</Button> </Button>
@@ -570,7 +602,6 @@ const LogsTable = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false); const [showBanner, setShowBanner] = useState(false);
@@ -578,22 +609,44 @@ const LogsTable = () => {
// 定义模态框图片URL的状态和更新函数 // 定义模态框图片URL的状态和更新函数
const [modalImageUrl, setModalImageUrl] = useState(''); const [modalImageUrl, setModalImageUrl] = useState('');
let now = new Date(); let now = new Date();
// 初始化start_timestamp为前一天
const [inputs, setInputs] = useState({ // Form 初始值
const formInitValues = {
channel_id: '', channel_id: '',
mj_id: '', mj_id: '',
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000), dateRange: [
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), timestamp2string(now.getTime() / 1000 - 2592000),
}); timestamp2string(now.getTime() / 1000 + 3600)
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs; ],
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
const [stat, setStat] = useState({ const [stat, setStat] = useState({
quota: 0, quota: 0,
token: 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) => { const setLogsFormat = (logs) => {
@@ -611,6 +664,7 @@ const LogsTable = () => {
setLoading(true); setLoading(true);
let url = ''; let url = '';
const { channel_id, mj_id, start_timestamp, end_timestamp } = getFormValues();
let localStartTimestamp = Date.parse(start_timestamp); let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp); let localEndTimestamp = Date.parse(end_timestamp);
if (isAdminUser) { if (isAdminUser) {
@@ -673,7 +727,7 @@ const LogsTable = () => {
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE; const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize); setPageSize(localPageSize);
loadLogs(0, localPageSize).then(); loadLogs(0, localPageSize).then();
}, [logType]); }, []);
useEffect(() => { useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled'); const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
@@ -788,70 +842,93 @@ const LogsTable = () => {
<Divider margin="12px" /> <Divider margin="12px" />
{/* 搜索表单区域 */} {/* 搜索表单区域 */}
<div className="flex flex-col gap-4"> <Form
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> initValues={formInitValues}
{/* 时间选择器 */} getFormApi={(api) => setFormApi(api)}
<div className="col-span-1 lg:col-span-2"> onSubmit={refresh}
<DatePicker allowEmpty={true}
className="w-full" autoComplete="off"
value={[start_timestamp, end_timestamp]} layout="vertical"
type='dateTimeRange' trigger="change"
onChange={(value) => { stopValidateWithError={false}
if (Array.isArray(value) && value.length === 2) { >
handleInputChange(value[0], 'start_timestamp'); <div className="flex flex-col gap-4">
handleInputChange(value[1], 'end_timestamp'); <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
} {/* 时间选择器 */}
}} <div className="col-span-1 lg:col-span-2">
/> <Form.DatePicker
</div> field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 任务 ID */} {/* 任务 ID */}
<Input <Form.Input
prefix={<IconSearch />} field='mj_id'
placeholder={t('任务 ID')}
value={mj_id}
onChange={(value) => handleInputChange(value, 'mj_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('渠道 ID')} placeholder={t('任务 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
className="!rounded-full" className="!rounded-full"
showClear showClear
pure
/> />
)}
</div>
{/* 操作按钮区域 */} {/* 渠道 ID - 仅管理员可见 */}
<div className="flex justify-between items-center pt-2"> {isAdminUser && (
<div></div> <Form.Input
<div className="flex gap-2"> field='channel_id'
<Button prefix={<IconSearch />}
type='primary' placeholder={t('渠道 ID')}
onClick={refresh} className="!rounded-full"
loading={loading} showClear
className="!rounded-full" pure
> />
{t('查询')} )}
</Button> </div>
<Button
theme='light' {/* 操作按钮区域 */}
type='tertiary' <div className="flex justify-between items-center">
icon={<IconSetting />} <div></div>
onClick={() => setShowColumnSelector(true)} <div className="flex gap-2">
className="!rounded-full" <Button
> type='primary'
{t('列设置')} htmlType='submit'
</Button> loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </Form>
</div> </div>
} }
shadows='always' shadows='always'
@@ -865,6 +942,14 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{ pagination={{
formatPageText: (page) => formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', { t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -17,14 +17,19 @@ import {
Tabs, Tabs,
TabPane, TabPane,
Dropdown, Dropdown,
Empty
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { import {
IconVerify, IconVerify,
IconHelpCircle, IconHelpCircle,
IconSearch, IconSearch,
IconCopy, IconCopy,
IconInfoCircle, IconInfoCircle,
IconLayers, IconLayers
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { UserContext } from '../../context/User/index.js'; import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
@@ -489,6 +494,14 @@ const ModelPricing = () => {
loading={loading} loading={loading}
rowSelection={rowSelection} rowSelection={rowSelection}
className="custom-table" className="custom-table"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{ pagination={{
defaultPageSize: 10, defaultPageSize: 10,
pageSize: pageSize, pageSize: pageSize,

View File

@@ -8,20 +8,33 @@ import {
renderQuota renderQuota
} from '../../helpers'; } from '../../helpers';
import {
CheckCircle,
XCircle,
Minus,
HelpCircle,
Coins
} from 'lucide-react';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { import {
Button, Button,
Card, Card,
Divider, Divider,
Dropdown, Dropdown,
Input, Empty,
Form,
Modal, Modal,
Popover, Popover,
Space, Space,
Table, Table,
Tag, Tag,
Typography, Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { import {
IconPlus, IconPlus,
IconCopy, IconCopy,
@@ -31,7 +44,7 @@ import {
IconDelete, IconDelete,
IconStop, IconStop,
IconPlay, IconPlay,
IconMore, IconMore
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption'; import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -49,25 +62,25 @@ const RedemptionsTable = () => {
switch (status) { switch (status) {
case 1: case 1:
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('未使用')} {t('未使用')}
</Tag> </Tag>
); );
case 2: case 2:
return ( return (
<Tag color='red' size='large' shape='circle'> <Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')} {t('已禁用')}
</Tag> </Tag>
); );
case 3: case 3:
return ( return (
<Tag color='grey' size='large' shape='circle'> <Tag color='grey' size='large' shape='circle' prefixIcon={<Minus size={14} />}>
{t('已使用')} {t('已使用')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='black' size='large' shape='circle'> <Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')} {t('未知状态')}
</Tag> </Tag>
); );
@@ -95,7 +108,13 @@ const RedemptionsTable = () => {
title: t('额度'), title: t('额度'),
dataIndex: 'quota', dataIndex: 'quota',
render: (text, record, index) => { render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>; return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
}, },
}, },
{ {
@@ -223,7 +242,6 @@ const RedemptionsTable = () => {
const [redemptions, setRedemptions] = useState([]); const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
const [selectedKeys, setSelectedKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]);
@@ -233,6 +251,22 @@ const RedemptionsTable = () => {
}); });
const [showEdit, setShowEdit] = useState(false); 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 = () => { const closeEdit = () => {
setShowEdit(false); setShowEdit(false);
setTimeout(() => { setTimeout(() => {
@@ -340,8 +374,14 @@ const RedemptionsTable = () => {
setLoading(false); setLoading(false);
}; };
const searchRedemptions = async (keyword, page, pageSize) => { const searchRedemptions = async (keyword = null, page, pageSize) => {
if (searchKeyword === '') { // 如果没有传递keyword参数从表单获取值
if (keyword === null) {
const formValues = getFormValues();
keyword = formValues.searchKeyword;
}
if (keyword === '') {
await loadRedemptions(page, pageSize); await loadRedemptions(page, pageSize);
return; return;
} }
@@ -361,10 +401,6 @@ const RedemptionsTable = () => {
setSearching(false); setSearching(false);
}; };
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const sortRedemption = (key) => { const sortRedemption = (key) => {
if (redemptions.length === 0) return; if (redemptions.length === 0) return;
setLoading(true); setLoading(true);
@@ -381,6 +417,7 @@ const RedemptionsTable = () => {
const handlePageChange = (page) => { const handlePageChange = (page) => {
setActivePage(page); setActivePage(page);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') { if (searchKeyword === '') {
loadRedemptions(page, pageSize).then(); loadRedemptions(page, pageSize).then();
} else { } else {
@@ -457,28 +494,59 @@ const RedemptionsTable = () => {
</Button> </Button>
</div> </div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2"> <Form
<div className="relative w-full md:w-64"> initValues={formInitValues}
<Input getFormApi={(api) => setFormApi(api)}
prefix={<IconSearch />} onSubmit={() => {
placeholder={t('关键字(id或者名称)')} setActivePage(1);
value={searchKeyword} searchRedemptions(null, 1, pageSize);
onChange={handleKeywordChange} }}
className="!rounded-full" allowEmpty={true}
showClear autoComplete="off"
/> layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadRedemptions(1, pageSize);
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div> </div>
<Button </Form>
type="primary"
onClick={() => {
searchRedemptions(searchKeyword, 1, pageSize).then();
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div> </div>
</div> </div>
); );
@@ -517,6 +585,7 @@ const RedemptionsTable = () => {
onPageSizeChange: (size) => { onPageSizeChange: (size) => {
setPageSize(size); setPageSize(size);
setActivePage(1); setActivePage(1);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') { if (searchKeyword === '') {
loadRedemptions(1, size).then(); loadRedemptions(1, size).then();
} else { } else {
@@ -528,6 +597,14 @@ const RedemptionsTable = () => {
loading={loading} loading={loading}
rowSelection={rowSelection} rowSelection={rowSelection}
onRow={handleRow} onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
></Table> ></Table>

View File

@@ -1,34 +1,51 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import {
Music,
FileText,
HelpCircle,
CheckCircle,
Pause,
Clock,
Play,
XCircle,
Loader,
List,
Hash
} from 'lucide-react';
import { import {
API, API,
copy, copy,
isAdmin, isAdmin,
showError, showError,
showSuccess, showSuccess,
timestamp2string, timestamp2string
} from '../../helpers'; } from '../../helpers';
import { import {
Button, Button,
Card, Card,
Checkbox, Checkbox,
DatePicker,
Divider, Divider,
Input, Empty,
Form,
Layout, Layout,
Modal, Modal,
Progress, Progress,
Skeleton, Skeleton,
Table, Table,
Tag, Tag,
Typography, Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { import {
IconEyeOpened, IconEyeOpened,
IconSearch, IconSearch,
IconSetting, IconSetting
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
@@ -97,7 +114,7 @@ function renderDuration(submit_time, finishTime) {
// 返回带有样式的颜色标签 // 返回带有样式的颜色标签
return ( return (
<Tag color={color} size='large'> <Tag color={color} size='large' prefixIcon={<Clock size={14} />}>
{durationSec} {durationSec}
</Tag> </Tag>
); );
@@ -188,19 +205,19 @@ const LogsTable = () => {
switch (type) { switch (type) {
case 'MUSIC': case 'MUSIC':
return ( return (
<Tag color='grey' size='large' shape='circle'> <Tag color='grey' size='large' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')} {t('生成音乐')}
</Tag> </Tag>
); );
case 'LYRICS': case 'LYRICS':
return ( return (
<Tag color='pink' size='large' shape='circle'> <Tag color='pink' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')} {t('生成歌词')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
@@ -211,13 +228,13 @@ const LogsTable = () => {
switch (type) { switch (type) {
case 'suno': case 'suno':
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
Suno Suno
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
@@ -228,55 +245,55 @@ const LogsTable = () => {
switch (type) { switch (type) {
case 'SUCCESS': case 'SUCCESS':
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')} {t('成功')}
</Tag> </Tag>
); );
case 'NOT_START': case 'NOT_START':
return ( return (
<Tag color='grey' size='large' shape='circle'> <Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')} {t('未启动')}
</Tag> </Tag>
); );
case 'SUBMITTED': case 'SUBMITTED':
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')} {t('队列中')}
</Tag> </Tag>
); );
case 'IN_PROGRESS': case 'IN_PROGRESS':
return ( return (
<Tag color='blue' size='large' shape='circle'> <Tag color='blue' size='large' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')} {t('执行中')}
</Tag> </Tag>
); );
case 'FAILURE': case 'FAILURE':
return ( return (
<Tag color='red' size='large' shape='circle'> <Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')} {t('失败')}
</Tag> </Tag>
); );
case 'QUEUED': case 'QUEUED':
return ( return (
<Tag color='orange' size='large' shape='circle'> <Tag color='orange' size='large' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')} {t('排队中')}
</Tag> </Tag>
); );
case 'UNKNOWN': case 'UNKNOWN':
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
case '': case '':
return ( return (
<Tag color='grey' size='large' shape='circle'> <Tag color='grey' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')} {t('正在提交')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='white' size='large' shape='circle'> <Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')} {t('未知')}
</Tag> </Tag>
); );
@@ -321,6 +338,7 @@ const LogsTable = () => {
color={colors[parseInt(text) % colors.length]} color={colors[parseInt(text) % colors.length]}
size='large' size='large'
shape='circle' shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => { onClick={() => {
copyText(text); copyText(text);
}} }}
@@ -395,7 +413,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0} percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true} showInfo={true}
aria-label='task progress' aria-label='task progress'
style={{ minWidth: '200px' }} style={{ minWidth: '160px' }}
/> />
) )
} }
@@ -437,21 +455,43 @@ const LogsTable = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType] = useState(0);
let now = new Date(); let now = new Date();
// 初始化start_timestamp为前一天 // 初始化start_timestamp为前一天
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
channel_id: '', channel_id: '',
task_id: '', task_id: '',
start_timestamp: timestamp2string(zeroNow.getTime() / 1000), dateRange: [
end_timestamp: '', timestamp2string(zeroNow.getTime() / 1000),
}); timestamp2string(now.getTime() / 1000 + 3600)
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs; ],
};
const handleInputChange = (value, name) => { // Form API 引用
setInputs((inputs) => ({ ...inputs, [name]: value })); 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) => { const setLogsFormat = (logs) => {
@@ -469,6 +509,7 @@ const LogsTable = () => {
setLoading(true); setLoading(true);
let url = ''; let url = '';
const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues();
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000); let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000); let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
if (isAdminUser) { if (isAdminUser) {
@@ -528,7 +569,7 @@ const LogsTable = () => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE; const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize); setPageSize(localPageSize);
loadLogs(0, localPageSize).then(); loadLogs(0, localPageSize).then();
}, [logType]); }, []);
// 列选择器模态框 // 列选择器模态框
const renderColumnSelector = () => { const renderColumnSelector = () => {
@@ -628,70 +669,93 @@ const LogsTable = () => {
<Divider margin="12px" /> <Divider margin="12px" />
{/* 搜索表单区域 */} {/* 搜索表单区域 */}
<div className="flex flex-col gap-4"> <Form
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> initValues={formInitValues}
{/* 时间选择器 */} getFormApi={(api) => setFormApi(api)}
<div className="col-span-1 lg:col-span-2"> onSubmit={refresh}
<DatePicker allowEmpty={true}
className="w-full" autoComplete="off"
value={[start_timestamp, end_timestamp]} layout="vertical"
type='dateTimeRange' trigger="change"
onChange={(value) => { stopValidateWithError={false}
if (Array.isArray(value) && value.length === 2) { >
handleInputChange(value[0], 'start_timestamp'); <div className="flex flex-col gap-4">
handleInputChange(value[1], 'end_timestamp'); <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
} {/* 时间选择器 */}
}} <div className="col-span-1 lg:col-span-2">
/> <Form.DatePicker
</div> field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 任务 ID */} {/* 任务 ID */}
<Input <Form.Input
prefix={<IconSearch />} field='task_id'
placeholder={t('任务 ID')}
value={task_id}
onChange={(value) => handleInputChange(value, 'task_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('渠道 ID')} placeholder={t('任务 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
className="!rounded-full" className="!rounded-full"
showClear showClear
pure
/> />
)}
</div>
{/* 操作按钮区域 */} {/* 渠道 ID - 仅管理员可见 */}
<div className="flex justify-between items-center pt-2"> {isAdminUser && (
<div></div> <Form.Input
<div className="flex gap-2"> field='channel_id'
<Button prefix={<IconSearch />}
type='primary' placeholder={t('渠道 ID')}
onClick={refresh} className="!rounded-full"
loading={loading} showClear
className="!rounded-full" pure
> />
{t('查询')} )}
</Button> </div>
<Button
theme='light' {/* 操作按钮区域 */}
type='tertiary' <div className="flex justify-between items-center">
icon={<IconSetting />} <div></div>
onClick={() => setShowColumnSelector(true)} <div className="flex gap-2">
className="!rounded-full" <Button
> type='primary'
{t('列设置')} htmlType='submit'
</Button> loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </Form>
</div> </div>
} }
shadows='always' shadows='always'
@@ -705,6 +769,14 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{ pagination={{
formatPageText: (page) => formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', { t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -6,7 +6,8 @@ import {
showSuccess, showSuccess,
timestamp2string, timestamp2string,
renderGroup, renderGroup,
renderQuota renderQuota,
getQuotaPerUnit
} from '../../helpers'; } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
@@ -14,13 +15,29 @@ import {
Button, Button,
Card, Card,
Dropdown, Dropdown,
Empty,
Form,
Modal, Modal,
Space, Space,
SplitButtonGroup, SplitButtonGroup,
Table, Table,
Tag, Tag
Input,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
CheckCircle,
Shield,
XCircle,
Clock,
Gauge,
HelpCircle,
Infinity,
Coins
} from 'lucide-react';
import { import {
IconPlus, IconPlus,
@@ -32,7 +49,7 @@ import {
IconDelete, IconDelete,
IconStop, IconStop,
IconPlay, IconPlay,
IconMore, IconMore
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import EditToken from '../../pages/Token/EditToken'; import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -49,38 +66,38 @@ const TokensTable = () => {
case 1: case 1:
if (model_limits_enabled) { if (model_limits_enabled) {
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
{t('已启用:限制模型')} {t('已启用:限制模型')}
</Tag> </Tag>
); );
} else { } else {
return ( return (
<Tag color='green' size='large' shape='circle'> <Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')} {t('已启用')}
</Tag> </Tag>
); );
} }
case 2: case 2:
return ( return (
<Tag color='red' size='large' shape='circle'> <Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')} {t('已禁用')}
</Tag> </Tag>
); );
case 3: case 3:
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('已过期')} {t('已过期')}
</Tag> </Tag>
); );
case 4: case 4:
return ( return (
<Tag color='grey' size='large' shape='circle'> <Tag color='grey' size='large' shape='circle' prefixIcon={<Gauge size={14} />}>
{t('已耗尽')} {t('已耗尽')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='black' size='large' shape='circle'> <Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')} {t('未知状态')}
</Tag> </Tag>
); );
@@ -111,21 +128,45 @@ const TokensTable = () => {
title: t('已用额度'), title: t('已用额度'),
dataIndex: 'used_quota', dataIndex: 'used_quota',
render: (text, record, index) => { render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>; return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
}, },
}, },
{ {
title: t('剩余额度'), title: t('剩余额度'),
dataIndex: 'remain_quota', dataIndex: 'remain_quota',
render: (text, record, index) => { 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 ( return (
<div> <div>
{record.unlimited_quota ? ( {record.unlimited_quota ? (
<Tag size={'large'} color={'white'} shape='circle'> <Tag size={'large'} color={'white'} shape='circle' prefixIcon={<Infinity size={14} />}>
{t('无限制')} {t('无限制')}
</Tag> </Tag>
) : ( ) : (
<Tag size={'large'} color={'light-blue'} shape='circle'> <Tag
size={'large'}
color={getQuotaColor(parseInt(text))}
shape='circle'
prefixIcon={<Coins size={14} />}
>
{renderQuota(parseInt(text))} {renderQuota(parseInt(text))}
</Tag> </Tag>
)} )}
@@ -335,14 +376,29 @@ const TokensTable = () => {
const [tokenCount, setTokenCount] = useState(pageSize); const [tokenCount, setTokenCount] = useState(pageSize);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchToken, setSearchToken] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [chats, setChats] = useState([]);
const [editingToken, setEditingToken] = useState({ const [editingToken, setEditingToken] = useState({
id: undefined, 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 = () => { const closeEdit = () => {
setShowEdit(false); setShowEdit(false);
setTimeout(() => { setTimeout(() => {
@@ -416,8 +472,6 @@ const TokensTable = () => {
window.open(url, '_blank'); window.open(url, '_blank');
}; };
useEffect(() => { useEffect(() => {
loadTokens(0) loadTokens(0)
.then() .then()
@@ -472,6 +526,7 @@ const TokensTable = () => {
}; };
const searchTokens = async () => { const searchTokens = async () => {
const { searchKeyword, searchToken } = getFormValues();
if (searchKeyword === '' && searchToken === '') { if (searchKeyword === '' && searchToken === '') {
await loadTokens(0); await loadTokens(0);
setActivePage(1); setActivePage(1);
@@ -491,14 +546,6 @@ const TokensTable = () => {
setSearching(false); setSearching(false);
}; };
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handleSearchTokenChange = async (value) => {
setSearchToken(value.trim());
};
const sortToken = (key) => { const sortToken = (key) => {
if (tokens.length === 0) return; if (tokens.length === 0) return;
setLoading(true); setLoading(true);
@@ -580,36 +627,65 @@ const TokensTable = () => {
</Button> </Button>
</div> </div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2"> <Form
<div className="relative w-full md:w-56"> initValues={formInitValues}
<Input getFormApi={(api) => setFormApi(api)}
prefix={<IconSearch />} onSubmit={searchTokens}
placeholder={t('搜索关键字')} allowEmpty={true}
value={searchKeyword} autoComplete="off"
onChange={handleKeywordChange} layout="horizontal"
className="!rounded-full" trigger="change"
showClear stopValidateWithError={false}
/> className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-56">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="relative w-full md:w-56">
<Form.Input
field="searchToken"
prefix={<IconSearch />}
placeholder={t('密钥')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
searchTokens();
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div> </div>
<div className="relative w-full md:w-56"> </Form>
<Input
prefix={<IconSearch />}
placeholder={t('密钥')}
value={searchToken}
onChange={handleSearchTokenChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={searchTokens}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div> </div>
</div> </div>
); );
@@ -654,6 +730,14 @@ const TokensTable = () => {
loading={loading} loading={loading}
rowSelection={rowSelection} rowSelection={rowSelection}
onRow={handleRow} onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
></Table> ></Table>

View File

@@ -1,18 +1,37 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers'; 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 { import {
Button, Button,
Card, Card,
Divider, Divider,
Dropdown, Dropdown,
Input, Empty,
Form,
Modal, Modal,
Select,
Space, Space,
Table, Table,
Tag, Tag,
Typography, Typography
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { import {
IconPlus, IconPlus,
IconSearch, IconSearch,
@@ -23,7 +42,7 @@ import {
IconMore, IconMore,
IconUserAdd, IconUserAdd,
IconArrowUp, IconArrowUp,
IconArrowDown, IconArrowDown
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser'; import AddUser from '../../pages/User/AddUser';
@@ -39,25 +58,25 @@ const UsersTable = () => {
switch (role) { switch (role) {
case 1: case 1:
return ( return (
<Tag size='large' color='blue' shape='circle'> <Tag size='large' color='blue' shape='circle' prefixIcon={<User size={14} />}>
{t('普通用户')} {t('普通用户')}
</Tag> </Tag>
); );
case 10: case 10:
return ( return (
<Tag color='yellow' size='large' shape='circle'> <Tag color='yellow' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
{t('管理员')} {t('管理员')}
</Tag> </Tag>
); );
case 100: case 100:
return ( return (
<Tag color='orange' size='large' shape='circle'> <Tag color='orange' size='large' shape='circle' prefixIcon={<Crown size={14} />}>
{t('超级管理员')} {t('超级管理员')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag color='red' size='large' shape='circle'> <Tag color='red' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知身份')} {t('未知身份')}
</Tag> </Tag>
); );
@@ -67,16 +86,16 @@ const UsersTable = () => {
const renderStatus = (status) => { const renderStatus = (status) => {
switch (status) { switch (status) {
case 1: case 1:
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>; return <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
case 2: case 2:
return ( return (
<Tag size='large' color='red' shape='circle'> <Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已封禁')} {t('已封禁')}
</Tag> </Tag>
); );
default: default:
return ( return (
<Tag size='large' color='grey' shape='circle'> <Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')} {t('未知状态')}
</Tag> </Tag>
); );
@@ -106,13 +125,13 @@ const UsersTable = () => {
return ( return (
<div> <div>
<Space spacing={1}> <Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs"> <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('剩余')}: {renderQuota(record.quota)} {t('剩余')}: {renderQuota(record.quota)}
</Tag> </Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs"> <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('已用')}: {renderQuota(record.used_quota)} {t('已用')}: {renderQuota(record.used_quota)}
</Tag> </Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs"> <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
{t('调用')}: {renderNumber(record.request_count)} {t('调用')}: {renderNumber(record.request_count)}
</Tag> </Tag>
</Space> </Space>
@@ -127,13 +146,13 @@ const UsersTable = () => {
return ( return (
<div> <div>
<Space spacing={1}> <Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs"> <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
{t('邀请')}: {renderNumber(record.aff_count)} {t('邀请')}: {renderNumber(record.aff_count)}
</Tag> </Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs"> <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
{t('收益')}: {renderQuota(record.aff_history_quota)} {t('收益')}: {renderQuota(record.aff_history_quota)}
</Tag> </Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs"> <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`} {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
</Tag> </Tag>
</Space> </Space>
@@ -155,7 +174,7 @@ const UsersTable = () => {
return ( return (
<div> <div>
{record.DeletedAt !== null ? ( {record.DeletedAt !== null ? (
<Tag color='red' shape='circle'>{t('已注销')}</Tag> <Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>
) : ( ) : (
renderStatus(text) renderStatus(text)
)} )}
@@ -285,9 +304,7 @@ const UsersTable = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searchGroup, setSearchGroup] = useState('');
const [groupOptions, setGroupOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE); const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
const [showAddUser, setShowAddUser] = useState(false); const [showAddUser, setShowAddUser] = useState(false);
@@ -296,6 +313,24 @@ const UsersTable = () => {
id: undefined, 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) => { const removeRecord = (key) => {
let newDataSource = [...users]; let newDataSource = [...users];
if (key != null) { if (key != null) {
@@ -363,9 +398,16 @@ const UsersTable = () => {
const searchUsers = async ( const searchUsers = async (
startIdx, startIdx,
pageSize, pageSize,
searchKeyword, searchKeyword = null,
searchGroup, searchGroup = null,
) => { ) => {
// 如果没有传递参数,从表单获取值
if (searchKeyword === null || searchGroup === null) {
const formValues = getFormValues();
searchKeyword = formValues.searchKeyword;
searchGroup = formValues.searchGroup;
}
if (searchKeyword === '' && searchGroup === '') { if (searchKeyword === '' && searchGroup === '') {
// if keyword is blank, load files instead. // if keyword is blank, load files instead.
await loadUsers(startIdx, pageSize); await loadUsers(startIdx, pageSize);
@@ -387,12 +429,9 @@ const UsersTable = () => {
setSearching(false); setSearching(false);
}; };
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handlePageChange = (page) => { const handlePageChange = (page) => {
setActivePage(page); setActivePage(page);
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') { if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then(); loadUsers(page, pageSize).then();
} else { } else {
@@ -413,10 +452,11 @@ const UsersTable = () => {
const refresh = async () => { const refresh = async () => {
setActivePage(1); setActivePage(1);
if (searchKeyword === '') { const { searchKeyword, searchGroup } = getFormValues();
await loadUsers(activePage, pageSize); if (searchKeyword === '' && searchGroup === '') {
await loadUsers(1, pageSize);
} else { } else {
await searchUsers(activePage, pageSize, searchKeyword, searchGroup); await searchUsers(1, pageSize, searchKeyword, searchGroup);
} }
}; };
@@ -488,41 +528,76 @@ const UsersTable = () => {
</Button> </Button>
</div> </div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2"> <Form
<div className="relative w-full md:w-64"> initValues={formInitValues}
<Input getFormApi={(api) => setFormApi(api)}
prefix={<IconSearch />} onSubmit={() => {
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')} setActivePage(1);
value={searchKeyword} searchUsers(1, pageSize);
onChange={handleKeywordChange} }}
className="!rounded-full" allowEmpty={true}
showClear autoComplete="off"
/> layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(value) => {
// 分组变化时自动搜索
setTimeout(() => {
setActivePage(1);
searchUsers(1, pageSize);
}, 100);
}}
className="!rounded-full w-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadUsers(1, pageSize);
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div> </div>
<div className="w-full md:w-48"> </Form>
<Select
placeholder={t('选择分组')}
optionList={groupOptions}
value={searchGroup}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div> </div>
</div> </div>
); );
@@ -570,6 +645,14 @@ const UsersTable = () => {
}} }}
loading={loading} loading={loading}
onRow={handleRow} onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden" className="rounded-xl overflow-hidden"
size="middle" size="middle"
/> />

View File

@@ -24,6 +24,13 @@ import {
XAI, XAI,
Ollama, Ollama,
Doubao, Doubao,
Suno,
Xinference,
OpenRouter,
Dify,
Coze,
SiliconCloud,
FastGPT
} from '@lobehub/icons'; } from '@lobehub/icons';
import { import {
@@ -40,6 +47,7 @@ import {
User, User,
Settings, Settings,
CircleUser, CircleUser,
Users
} from 'lucide-react'; } from 'lucide-react';
// 侧边栏图标颜色映射 // 侧边栏图标颜色映射
@@ -308,6 +316,88 @@ export const getModelCategories = (() => {
}; };
})(); })();
/**
* 根据渠道类型返回对应的厂商图标
* @param {number} channelType - 渠道类型值
* @returns {JSX.Element|null} - 对应的厂商图标组件
*/
export function getChannelIcon(channelType) {
const iconSize = 14;
switch (channelType) {
case 1: // OpenAI
case 3: // Azure OpenAI
return <OpenAI size={iconSize} />;
case 2: // Midjourney Proxy
case 5: // Midjourney Proxy Plus
return <Midjourney size={iconSize} />;
case 36: // Suno API
return <Suno size={iconSize} />;
case 4: // Ollama
return <Ollama size={iconSize} />;
case 14: // Anthropic Claude
case 33: // AWS Claude
return <Claude.Color size={iconSize} />;
case 41: // Vertex AI
return <Gemini.Color size={iconSize} />;
case 34: // Cohere
return <Cohere.Color size={iconSize} />;
case 39: // Cloudflare
return <Cloudflare.Color size={iconSize} />;
case 43: // DeepSeek
return <DeepSeek.Color size={iconSize} />;
case 15: // 百度文心千帆
case 46: // 百度文心千帆V2
return <Wenxin.Color size={iconSize} />;
case 17: // 阿里通义千问
return <Qwen.Color size={iconSize} />;
case 18: // 讯飞星火认知
return <Spark.Color size={iconSize} />;
case 16: // 智谱 ChatGLM
case 26: // 智谱 GLM-4V
return <Zhipu.Color size={iconSize} />;
case 24: // Google Gemini
case 11: // Google PaLM2
return <Gemini.Color size={iconSize} />;
case 47: // Xinference
return <Xinference.Color size={iconSize} />;
case 25: // Moonshot
return <Moonshot size={iconSize} />;
case 20: // OpenRouter
return <OpenRouter size={iconSize} />;
case 19: // 360 智脑
return <Ai360.Color size={iconSize} />;
case 23: // 腾讯混元
return <Hunyuan.Color size={iconSize} />;
case 31: // 零一万物
return <Yi.Color size={iconSize} />;
case 35: // MiniMax
return <Minimax.Color size={iconSize} />;
case 37: // Dify
return <Dify.Color size={iconSize} />;
case 38: // Jina
return <Jina size={iconSize} />;
case 40: // SiliconCloud
return <SiliconCloud.Color size={iconSize} />;
case 42: // Mistral AI
return <Mistral.Color size={iconSize} />;
case 45: // 字节火山方舟、豆包通用
return <Doubao.Color size={iconSize} />;
case 48: // xAI
return <XAI size={iconSize} />;
case 49: // Coze
return <Coze size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库FastGPT
return <FastGPT.Color size={iconSize} />;
case 21: // 知识库AI Proxy
case 44: // 嵌入模型MokaAI M3E
default:
return null; // 未知类型或自定义渠道不显示图标
}
}
// 颜色列表 // 颜色列表
const colors = [ const colors = [
'amber', 'amber',
@@ -519,7 +609,7 @@ export function renderGroup(group) {
showSuccess(i18next.t('已复制:') + group); showSuccess(i18next.t('已复制:') + group);
} else { } else {
Modal.error({ Modal.error({
title: t('无法复制到剪贴板,请手动复制'), title: i18next.t('无法复制到剪贴板,请手动复制'),
content: group, content: group,
}); });
} }
@@ -956,23 +1046,23 @@ export function renderModelPrice(
const extraServices = [ const extraServices = [
webSearch && webSearchCallCount > 0 webSearch && webSearchCallCount > 0
? i18next.t( ? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}', ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{ {
count: webSearchCallCount, count: webSearchCallCount,
price: webSearchPrice, price: webSearchPrice,
ratio: groupRatio, ratio: groupRatio,
}, },
) )
: '', : '',
fileSearch && fileSearchCallCount > 0 fileSearch && fileSearchCallCount > 0
? i18next.t( ? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}', ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{ {
count: fileSearchCallCount, count: fileSearchCallCount,
price: fileSearchPrice, price: fileSearchPrice,
ratio: groupRatio, ratio: groupRatio,
}, },
) )
: '', : '',
].join(''); ].join('');
@@ -1156,10 +1246,10 @@ export function renderAudioModelPrice(
let audioPrice = let audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio + (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) * (audioCompletionTokens / 1000000) *
inputRatioPrice * inputRatioPrice *
audioRatio * audioRatio *
audioCompletionRatio * audioCompletionRatio *
groupRatio; groupRatio;
let price = textPrice + audioPrice; let price = textPrice + audioPrice;
return ( return (
<> <>
@@ -1215,27 +1305,27 @@ export function renderAudioModelPrice(
<p> <p>
{cacheTokens > 0 {cacheTokens > 0
? i18next.t( ? i18next.t(
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{ {
nonCacheInput: inputTokens - cacheTokens, nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens, cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio, cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice, price: inputRatioPrice,
completion: completionTokens, completion: completionTokens,
compPrice: completionRatioPrice, compPrice: completionRatioPrice,
total: textPrice.toFixed(6), total: textPrice.toFixed(6),
}, },
) )
: i18next.t( : i18next.t(
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}', '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{ {
input: inputTokens, input: inputTokens,
price: inputRatioPrice, price: inputRatioPrice,
completion: completionTokens, completion: completionTokens,
compPrice: completionRatioPrice, compPrice: completionRatioPrice,
total: textPrice.toFixed(6), total: textPrice.toFixed(6),
}, },
)} )}
</p> </p>
<p> <p>
{i18next.t( {i18next.t(
@@ -1372,33 +1462,33 @@ export function renderClaudeModelPrice(
<p> <p>
{cacheTokens > 0 || cacheCreationTokens > 0 {cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t( ? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}', '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{ {
nonCacheInput: nonCachedTokens, nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens, cacheInput: cacheTokens,
cacheRatio: cacheRatio, cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens, cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio, cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice, cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice, cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice, price: inputRatioPrice,
completion: completionTokens, completion: completionTokens,
compPrice: completionRatioPrice, compPrice: completionRatioPrice,
ratio: groupRatio, ratio: groupRatio,
total: price.toFixed(6), total: price.toFixed(6),
}, },
) )
: i18next.t( : i18next.t(
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}', '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{ {
input: inputTokens, input: inputTokens,
price: inputRatioPrice, price: inputRatioPrice,
completion: completionTokens, completion: completionTokens,
compPrice: completionRatioPrice, compPrice: completionRatioPrice,
ratio: groupRatio, ratio: groupRatio,
total: price.toFixed(6), total: price.toFixed(6),
}, },
)} )}
</p> </p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p> <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article> </article>

View File

@@ -1567,5 +1567,24 @@
"使用统计": "Usage Statistics", "使用统计": "Usage Statistics",
"资源消耗": "Resource Consumption", "资源消耗": "Resource Consumption",
"性能指标": "Performance Indicators", "性能指标": "Performance Indicators",
"模型数据分析": "Model Data Analysis" "模型数据分析": "Model Data Analysis",
"搜索无结果": "No results found",
"仪表盘配置": "Dashboard Configuration",
"API信息管理可以配置多个API地址用于状态展示和负载均衡": "API information management, you can configure multiple API addresses for status display and load balancing",
"线路描述": "Route description",
"颜色": "Color",
"标识颜色": "Identifier color",
"添加API": "Add API",
"保存配置": "Save Configuration",
"API信息": "API Information",
"暂无API信息配置": "No API information configured",
"暂无API信息": "No API information",
"请输入API地址": "Please enter the API address",
"请输入线路描述": "Please enter the route description",
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
"请输入说明": "Please enter the description",
"如:香港线路": "e.g. Hong Kong line",
"请联系管理员在系统设置中配置API信息": "Please contact the administrator to configure API information in the system settings.",
"确定要删除此API信息吗": "Are you sure you want to delete this API information?",
"测速": "Speed Test"
} }

View File

@@ -73,6 +73,10 @@ code {
.semi-page-item, .semi-page-item,
.semi-navigation-item, .semi-navigation-item,
.semi-tag-closable, .semi-tag-closable,
.semi-input-wrapper,
.semi-tabs-tab-button,
.semi-select,
.semi-button,
.semi-datepicker-range-input { .semi-datepicker-range-input {
border-radius: 9999px !important; border-radius: 9999px !important;
} }
@@ -322,6 +326,24 @@ code {
font-size: 1.1em; font-size: 1.1em;
} }
/* API信息卡片样式 */
.api-info-container {
position: relative;
}
.api-info-fade-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, var(--semi-color-bg-1));
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.3s ease;
}
/* ==================== 调试面板特定样式 ==================== */ /* ==================== 调试面板特定样式 ==================== */
.debug-panel .semi-tabs { .debug-panel .semi-tabs {
height: 100% !important; height: 100% !important;
@@ -378,6 +400,7 @@ code {
} }
/* 隐藏模型设置区域的滚动条 */ /* 隐藏模型设置区域的滚动条 */
.api-info-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar, .model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar, .thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar, .custom-request-textarea .semi-input::-webkit-scrollbar,
@@ -385,6 +408,7 @@ code {
display: none; display: none;
} }
.api-info-scroll,
.model-settings-scroll, .model-settings-scroll,
.thinking-content-scroll, .thinking-content-scroll,
.custom-request-textarea .semi-input, .custom-request-textarea .semi-input,

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import '@douyinfe/semi-ui/dist/css/semi.css';
import { UserProvider } from './context/User'; import { UserProvider } from './context/User';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status'; import { StatusProvider } from './context/Status';

View File

@@ -194,6 +194,24 @@ const EditTagModal = (props) => {
}, [originModelOptions, inputs.models]); }, [originModelOptions, inputs.models]);
useEffect(() => { useEffect(() => {
const fetchTagModels = async () => {
if (!tag) return;
setLoading(true);
try {
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
if (res?.data?.success) {
const models = res.data.data ? res.data.data.split(',') : [];
setInputs((inputs) => ({ ...inputs, models: models }));
} else {
showError(res.data.message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
setInputs({ setInputs({
...originInputs, ...originInputs,
tag: tag, tag: tag,
@@ -201,7 +219,8 @@ const EditTagModal = (props) => {
}); });
fetchModels().then(); fetchModels().then();
fetchGroups().then(); fetchGroups().then();
}, [visible]); fetchTagModels().then(); // Call the new function
}, [visible, tag]); // Add tag to dependency array
const addCustomModels = () => { const addCustomModels = () => {
if (customModel.trim() === '') return; if (customModel.trim() === '') return;
@@ -347,6 +366,11 @@ const EditTagModal = (props) => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Text strong className="block mb-2">{t('模型')}</Text> <Text strong className="block mb-2">{t('模型')}</Text>
<Banner
type="info"
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
className="!rounded-lg mb-4"
/>
<Select <Select
placeholder={t('请选择该渠道所支持的模型,留空则不更改')} placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
name='models' name='models'
@@ -388,19 +412,19 @@ const EditTagModal = (props) => {
/> />
<Space className="mt-2"> <Space className="mt-2">
<Text <Text
className="text-blue-500 cursor-pointer" className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))} onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
> >
{t('填入模板')} {t('填入模板')}
</Text> </Text>
<Text <Text
className="text-blue-500 cursor-pointer" className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))} onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}
> >
{t('清空重定向')} {t('清空重定向')}
</Text> </Text>
<Text <Text
className="text-blue-500 cursor-pointer" className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', '')} onClick={() => handleInputChange('model_mapping', '')}
> >
{t('不更改')} {t('不更改')}

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,7 @@ import { API, showError, isMobile } from '../../helpers';
import { StatusContext } from '../../context/Status'; import { StatusContext } from '../../context/Status';
import { marked } from 'marked'; import { marked } from 'marked';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconGithubLogo } from '@douyinfe/semi-icons'; import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons';
import exampleImage from '/example.png';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import NoticeModal from '../../components/layout/NoticeModal'; 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'; 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 [noticeVisible, setNoticeVisible] = useState(false);
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
useEffect(() => { useEffect(() => {
const checkNoticeAndShow = async () => { const checkNoticeAndShow = async () => {
@@ -85,132 +85,123 @@ const Home = () => {
{homePageContentLoaded && homePageContent === '' ? ( {homePageContentLoaded && homePageContent === '' ? (
<div className="w-full overflow-x-hidden"> <div className="w-full overflow-x-hidden">
{/* Banner 部分 */} {/* Banner 部分 */}
<div className="w-full border-b border-semi-color-border min-h-[500px] md:h-[650px] lg:h-[750px] relative overflow-x-hidden"> <div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
<div className="flex flex-col md:flex-row items-center justify-center h-full px-4 py-8 md:py-0"> <div className="flex items-center justify-center h-full px-4 py-12 md:py-16 lg:py-20">
{/* 左侧内容区 */} {/* 居中内容区 */}
<div className="flex-shrink-0 w-full md:w-[480px] md:mr-[60px] lg:mr-[120px] mb-8 md:mb-0"> <div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
<div className="flex items-center gap-2 justify-center md:justify-start"> <div className="flex flex-col items-center justify-center mb-6 md:mb-8">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-semibold text-semi-color-text-0 w-auto leading-normal md:leading-[67px]"> <h1 className="text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-semibold text-semi-color-text-0 leading-tight">
{statusState?.status?.system_name || 'New API'} {statusState?.status?.system_name || 'New API'}
</h1> </h1>
{statusState?.status?.version && (
<Tag color='light-blue' size='large' shape='circle' className="ml-1">
{statusState.status.version}
</Tag>
)}
</div> </div>
<p className="text-base md:text-lg text-semi-color-text-0 mt-4 md:mt-8 w-full md:w-[480px] leading-7 md:leading-8 text-center md:text-left"> <p className="text-base md:text-lg lg:text-xl text-semi-color-text-0 leading-7 md:leading-8 lg:leading-9 max-w-2xl px-4">
{t('新一代大模型网关与AI资产管理系统一键接入主流大模型轻松管理您的AI资产')} {t('新一代大模型网关与AI资产管理系统一键接入主流大模型轻松管理您的AI资产')}
</p> </p>
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="mt-6 md:mt-10 flex flex-wrap gap-4 justify-center md:justify-start"> <div className="mt-8 md:mt-10 lg:mt-12 flex flex-row gap-4 justify-center items-center">
<Link to="/console"> <Link to="/console">
<Button theme="solid" type="primary" size="large" className="!rounded-3xl"> <Button theme="solid" type="primary" size="large" className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
{t('开始使用')} {t('开始使用')}
</Button> </Button>
</Link> </Link>
{isDemoSiteMode && ( {isDemoSiteMode && statusState?.status?.version ? (
<Button <Button
size="large" size="large"
className="flex items-center !rounded-3xl" className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconGithubLogo />} icon={<IconGithubLogo />}
onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')} onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
> >
GitHub {statusState.status.version}
</Button> </Button>
) : (
docsLink && (
<Button
size="large"
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconFile />}
onClick={() => window.open(docsLink, '_blank')}
>
{t('文档')}
</Button>
)
)} )}
</div> </div>
{/* 框架兼容性图标 */} {/* 框架兼容性图标 */}
<div className="mt-8 md:mt-16"> <div className="mt-12 md:mt-16 lg:mt-20 w-full">
<div className="flex items-center mb-3 justify-center md:justify-start"> <div className="flex items-center mb-6 md:mb-8 justify-center">
<Text type="tertiary" className="text-lg md:text-xl font-light"> <Text type="tertiary" className="text-lg md:text-xl lg:text-2xl font-light">
{t('支持众多的大模型供应商')} {t('支持众多的大模型供应商')}
</Text> </Text>
</div> </div>
<div className="flex flex-wrap items-center relative mt-6 md:mt-8 gap-6 md:gap-8 justify-center md:justify-start"> <div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4">
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Moonshot size={40} /> <Moonshot size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<OpenAI size={40} /> <OpenAI size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<XAI size={40} /> <XAI size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Zhipu.Color size={40} /> <Zhipu.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Volcengine.Color size={40} /> <Volcengine.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Cohere.Color size={40} /> <Cohere.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Claude.Color size={40} /> <Claude.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Gemini.Color size={40} /> <Gemini.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Suno size={40} /> <Suno size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Minimax.Color size={40} /> <Minimax.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Wenxin.Color size={40} /> <Wenxin.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Spark.Color size={40} /> <Spark.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Qingyan.Color size={40} /> <Qingyan.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<DeepSeek.Color size={40} /> <DeepSeek.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Qwen.Color size={40} /> <Qwen.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Midjourney size={40} /> <Midjourney size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Grok size={40} /> <Grok size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<AzureAI.Color size={40} /> <AzureAI.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Hunyuan.Color size={40} /> <Hunyuan.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Xinference.Color size={40} /> <Xinference.Color size={40} />
</div> </div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Typography.Text className="!text-2xl font-bold">30+</Typography.Text> <Typography.Text className="!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold">30+</Typography.Text>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 右侧图片区域 - 在小屏幕上隐藏或调整位置 */}
<div className="flex-shrink-0 relative md:mr-[-200px] lg:mr-[-400px] hidden md:block lg:min-w-[1100px]">
<div className="absolute w-[320px] md:w-[500px] lg:w-[640px] h-[320px] md:h-[500px] lg:h-[640px] left-[-25px] md:left-[-40px] lg:left-[-50px] top-[-10px] md:top-[-15px] lg:top-[-20px] opacity-60"
style={{ filter: 'blur(120px)' }}>
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] top-[80px] md:top-[100px] lg:top-[132px] bg-semi-color-primary rounded-full opacity-30"></div>
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] left-[80px] md:left-[120px] lg:left-[166px] bg-semi-color-tertiary rounded-full opacity-30"></div>
</div>
<img
src={exampleImage}
alt="application demo"
className="relative h-[400px] md:h-[600px] lg:h-[721px] ml-[-15px] md:ml-[-20px] lg:ml-[-30px] mt-[-15px] md:mt-[-20px] lg:mt-[-30px]"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -223,7 +214,7 @@ const Home = () => {
/> />
) : ( ) : (
<div <div
className="text-base md:text-lg p-4 md:p-6 overflow-x-hidden" className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
dangerouslySetInnerHTML={{ __html: homePageContent }} dangerouslySetInnerHTML={{ __html: homePageContent }}
></div> ></div>
)} )}
@@ -234,3 +225,4 @@ const Home = () => {
}; };
export default Home; export default Home;

View File

@@ -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) => (
<Tag
color={record.color}
className="!rounded-full"
style={{ maxWidth: '280px' }}
>
{text}
</Tag>
),
},
{
title: t('线路描述'),
dataIndex: 'route',
render: (text, record) => (
<Tag shape='circle'>
{text}
</Tag>
),
},
{
title: t('说明'),
dataIndex: 'description',
ellipsis: true,
render: (text, record) => (
<Tag shape='circle'>
{text || '-'}
</Tag>
),
},
{
title: t('颜色'),
dataIndex: 'color',
render: (color) => (
<Avatar
size="extra-extra-small"
color={color}
/>
),
},
{
title: t('操作'),
fixed: 'right',
render: (_, record) => (
<Space>
<Button
icon={<Edit size={14} />}
theme='light'
type='tertiary'
size='small'
className="!rounded-full"
onClick={() => handleEditApi(record)}
>
{t('编辑')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
size='small'
className="!rounded-full"
onClick={() => handleDeleteApi(record)}
>
{t('删除')}
</Button>
</Space>
),
},
];
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Settings size={16} className="mr-2" />
<Text>{t('API信息管理可以配置多个API地址用于状态展示和负载均衡')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="!rounded-full w-full md:w-auto"
onClick={handleAddApi}
>
{t('添加API')}
</Button>
<Button
icon={<Save size={14} />}
onClick={submitApiInfo}
loading={loading}
disabled={!hasChanges}
type='secondary'
className="!rounded-full w-full md:w-auto"
>
{t('保存配置')}
</Button>
</div>
</div>
</div>
);
return (
<>
<Form.Section text={renderHeader()}>
<Table
columns={columns}
dataSource={apiInfoList}
scroll={{ x: 'max-content' }}
pagination={false}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('暂无API信息')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
/>
</Form.Section>
<Modal
title={editingApi ? t('编辑API') : t('添加API')}
visible={showApiModal}
onOk={handleSaveApi}
onCancel={() => setShowApiModal(false)}
okText={t('保存')}
cancelText={t('取消')}
className="rounded-xl"
confirmLoading={modalLoading}
>
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
<Form.Input
field='url'
label={t('API地址')}
placeholder='https://api.example.com'
rules={[{ required: true, message: t('请输入API地址') }]}
onChange={(value) => setApiForm({ ...apiForm, url: value })}
/>
<Form.Input
field='route'
label={t('线路描述')}
placeholder={t('如:香港线路')}
rules={[{ required: true, message: t('请输入线路描述') }]}
onChange={(value) => setApiForm({ ...apiForm, route: value })}
/>
<Form.Input
field='description'
label={t('说明')}
placeholder={t('如:大带宽批量分析图片推荐')}
rules={[{ required: true, message: t('请输入说明') }]}
onChange={(value) => setApiForm({ ...apiForm, description: value })}
/>
<Form.Select
field='color'
label={t('标识颜色')}
optionList={colorOptions}
onChange={(value) => setApiForm({ ...apiForm, color: value })}
render={(option) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar
size="extra-extra-small"
color={option.value}
/>
{option.label}
</div>
)}
/>
</Form>
</Modal>
<Modal
title={t('确认删除')}
visible={showDeleteModal}
onOk={confirmDeleteApi}
onCancel={() => {
setShowDeleteModal(false);
setDeletingApi(null);
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
className="rounded-xl"
okButtonProps={{
type: 'danger',
theme: 'solid'
}}
>
<Text>{t('确定要删除此API信息吗')}</Text>
</Modal>
</>
);
};
export default SettingsAPIInfo;

View File

@@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next';
import SystemSetting from '../../components/settings/SystemSetting.js'; import SystemSetting from '../../components/settings/SystemSetting.js';
import { isRoot } from '../../helpers'; import { isRoot } from '../../helpers';
import OtherSetting from '../../components/settings/OtherSetting'; import OtherSetting from '../../components/settings/OtherSetting';
import PersonalSetting from '../../components/settings/PersonalSetting.js';
import OperationSetting from '../../components/settings/OperationSetting.js'; import OperationSetting from '../../components/settings/OperationSetting.js';
import RateLimitSetting from '../../components/settings/RateLimitSetting.js'; import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
import ModelSetting from '../../components/settings/ModelSetting.js'; import ModelSetting from '../../components/settings/ModelSetting.js';
import DashboardSetting from '../../components/settings/DashboardSetting.js';
const Setting = () => { const Setting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -44,6 +44,11 @@ const Setting = () => {
content: <OtherSetting />, content: <OtherSetting />,
itemKey: 'other', itemKey: 'other',
}); });
panes.push({
tab: t('仪表盘配置'),
content: <DashboardSetting />,
itemKey: 'dashboard',
});
} }
const onChangeTab = (key) => { const onChangeTab = (key) => {
setTabActiveKey(key); setTabActiveKey(key);

View File

@@ -133,7 +133,7 @@ const Setup = () => {
}; };
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="bg-gray-50">
<Layout> <Layout>
<Layout.Content> <Layout.Content>
<div className="flex justify-center px-4 py-8"> <div className="flex justify-center px-4 py-8">

View File

@@ -219,9 +219,15 @@ const EditToken = (props) => {
let successCount = 0; // 记录成功创建的令牌数量 let successCount = 0; // 记录成功创建的令牌数量
for (let i = 0; i < tokenCount; i++) { for (let i = 0; i < tokenCount; i++) {
let localInputs = { ...inputs }; 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); localInputs.remain_quota = parseInt(localInputs.remain_quota);

View File

@@ -55,6 +55,7 @@ const TopUp = () => {
const [amountLoading, setAmountLoading] = useState(false); const [amountLoading, setAmountLoading] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false); const [paymentLoading, setPaymentLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 邀请相关状态 // 邀请相关状态
const [affLink, setAffLink] = useState(''); const [affLink, setAffLink] = useState('');
@@ -256,6 +257,32 @@ const TopUp = () => {
showSuccess(t('邀请链接已复制到剪切板')); 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(() => { useEffect(() => {
if (userState?.user?.id) { if (userState?.user?.id) {
setUserDataLoading(false); setUserDataLoading(false);
@@ -398,48 +425,45 @@ const TopUp = () => {
<div className="w-full"> <div className="w-full">
<Card className="!rounded-2xl shadow-lg border-0"> <Card className="!rounded-2xl shadow-lg border-0">
<Card <Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden" className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
style={{ style={{
background: 'linear-gradient(135deg, #1e3a8a 0%, #1e40af 25%, #2563eb 50%, #3b82f6 75%, #60a5fa 100%)', background: isDarkMode
? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
position: 'relative' position: 'relative'
}} }}
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div> <div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div> <div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 opacity-8 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div> <div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 opacity-6 rounded-full"></div>
</div> </div>
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}> <div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
<div className="flex justify-between items-start mb-4 sm:mb-6"> <div className="flex justify-between items-start mb-4 sm:mb-6">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{userDataLoading ? ( {userDataLoading ? (
<Skeleton.Title style={{ width: '200px', height: '20px' }} /> <Skeleton.Title style={{ width: '200px', height: '20px' }} />
) : ( ) : (
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}> <div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
{t('尊敬的')} {getUsername()} {t('尊敬的')} {getUsername()}
</div> </div>
)} )}
</div> </div>
<div <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2" <IconCreditCard size="default" className="text-white" />
style={{
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
}}
>
<IconCreditCard size="default" style={{ color: 'white' }} />
</div> </div>
</div> </div>
<div className="mb-4 sm:mb-6"> <div className="mb-4 sm:mb-6">
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}> <div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
{t('当前余额')} {t('当前余额')}
</div> </div>
{userDataLoading ? ( {userDataLoading ? (
<Skeleton.Title style={{ width: '180px', height: '32px' }} /> <Skeleton.Title style={{ width: '180px', height: '32px' }} />
) : ( ) : (
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}> <div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
{renderQuota(userState?.user?.quota || userQuota)} {renderQuota(userState?.user?.quota || userQuota)}
</div> </div>
)} )}
@@ -448,37 +472,37 @@ const TopUp = () => {
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0"> <div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}> <div className="text-xs text-gray-400 dark:text-gray-500">
{t('历史消耗')} {t('历史消耗')}
</div> </div>
{userDataLoading ? ( {userDataLoading ? (
<Skeleton.Title style={{ width: '60px', height: '14px' }} /> <Skeleton.Title style={{ width: '60px', height: '14px' }} />
) : ( ) : (
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}> <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{renderQuota(userState?.user?.used_quota || 0)} {renderQuota(userState?.user?.used_quota || 0)}
</div> </div>
)} )}
</div> </div>
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}> <div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户分组')} {t('用户分组')}
</div> </div>
{userDataLoading ? ( {userDataLoading ? (
<Skeleton.Title style={{ width: '50px', height: '14px' }} /> <Skeleton.Title style={{ width: '50px', height: '14px' }} />
) : ( ) : (
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}> <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState?.user?.group || t('默认')} {userState?.user?.group || t('默认')}
</div> </div>
)} )}
</div> </div>
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}> <div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户角色')} {t('用户角色')}
</div> </div>
{userDataLoading ? ( {userDataLoading ? (
<Skeleton.Title style={{ width: '60px', height: '14px' }} /> <Skeleton.Title style={{ width: '60px', height: '14px' }} />
) : ( ) : (
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}> <div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{getUserRole()} {getUserRole()}
</div> </div>
)} )}
@@ -489,32 +513,187 @@ const TopUp = () => {
{userDataLoading ? ( {userDataLoading ? (
<Skeleton.Title style={{ width: '50px', height: '24px' }} /> <Skeleton.Title style={{ width: '50px', height: '24px' }} />
) : ( ) : (
<div <div className="px-2 py-1 sm:px-3 rounded-md text-xs sm:text-sm font-medium inline-block bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-600">
className="px-2 py-1 sm:px-3 rounded-md text-xs sm:text-sm font-medium inline-block"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(10px)'
}}
>
ID: {userState?.user?.id || '---'} ID: {userState?.user?.id || '---'}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div> <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
</div> </div>
</Card> </Card>
<div className="p-6"> <div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 邀请信息部分 */} {/* 左侧:在线充值和兑换余额 */}
<div> <div className="lg:col-span-2 space-y-8">
{/* 在线充值部分 */}
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4">
<IconPlus size="large" className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<Text className="text-xl font-semibold">{t('在线充值')}</Text>
<div className="text-gray-500 text-sm">{t('支持多种支付方式')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-2">
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '80px', height: '14px' }} />
) : (
<Text type="tertiary">{t('实付金额:') + ' ' + renderAmount()}</Text>
)}
</div>
<InputNumber
disabled={!enableOnlineTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
step={1}
precision={0}
onChange={async (value) => {
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={<IconCreditCard />}
formatter={(value) => value ? `${value}` : ''}
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('zfb');
}}
size="large"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiAlipay size={20} />}
>
<span className="ml-2">{t('支付宝')}</span>
</Button>
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('wx');
}}
size="large"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiWechat size={20} />}
>
<span className="ml-2">{t('微信')}</span>
</Button>
</div>
{!enableOnlineTopUp && (
<Banner
fullMode={false}
type="warning"
icon={null}
closeIcon={null}
className="!rounded-lg"
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
{t('在线充值功能未开启')}
</div>
}
description={
<div>
{t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
</div>
}
/>
)}
</div>
</div>
{/* 兑换余额部分 */}
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4">
<IconGift size="large" className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<Text className="text-xl font-semibold">{t('兑换余额')}</Text>
<div className="text-gray-500 text-sm">{t('使用兑换码充值余额')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('兑换码')}</Text>
<Input
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={(value) => setRedemptionCode(value)}
size="large"
className="!rounded-lg"
prefix={<IconGift />}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{topUpLink && (
<Button
type="primary"
theme="solid"
onClick={openTopUpLink}
size="large"
className="!rounded-lg flex-1"
icon={<IconLink />}
>
{t('获取兑换码')}
</Button>
)}
<Button
type="warning"
theme="solid"
onClick={topUp}
disabled={isSubmitting}
loading={isSubmitting}
size="large"
className="!rounded-lg flex-1"
>
{isSubmitting ? t('兑换中...') : t('兑换')}
</Button>
</div>
</div>
</div>
</div>
{/* 右侧:邀请信息部分 */}
<div className="lg:col-span-1">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center"> <div className="flex items-center">
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4"> <div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4">
<IconLink size="large" className="text-orange-500" /> <IconLink size="large" className="text-slate-600 dark:text-slate-300" />
</div> </div>
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -524,7 +703,7 @@ const TopUp = () => {
theme="solid" theme="solid"
onClick={() => setOpenTransfer(true)} onClick={() => setOpenTransfer(true)}
size="small" size="small"
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600" className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
icon={<IconCreditCard />} icon={<IconCreditCard />}
> >
{t('划转')} {t('划转')}
@@ -536,7 +715,7 @@ const TopUp = () => {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 gap-3">
<Card <Card
className="!rounded-2xl text-center" className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }} bodyStyle={{ padding: '16px' }}
@@ -546,7 +725,6 @@ const TopUp = () => {
<div className="text-gray-900 text-lg font-bold mt-1"> <div className="text-gray-900 text-lg font-bold mt-1">
{renderQuota(userState?.user?.aff_quota)} {renderQuota(userState?.user?.aff_quota)}
</div> </div>
</Card> </Card>
<Card <Card
className="!rounded-2xl text-center" className="!rounded-2xl text-center"
@@ -583,162 +761,6 @@ const TopUp = () => {
</div> </div>
</div> </div>
</div> </div>
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mr-4">
<IconGift size="large" className="text-green-500" />
</div>
<div>
<Text className="text-xl font-semibold">{t('兑换余额')}</Text>
<div className="text-gray-500 text-sm">{t('使用兑换码充值余额')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('兑换码')}</Text>
<Input
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={(value) => setRedemptionCode(value)}
size="large"
className="!rounded-lg"
prefix={<IconGift />}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{topUpLink && (
<Button
type="primary"
theme="solid"
onClick={openTopUpLink}
size="large"
className="!rounded-lg flex-1"
icon={<IconLink />}
>
{t('获取兑换码')}
</Button>
)}
<Button
type="warning"
theme="solid"
onClick={topUp}
disabled={isSubmitting}
loading={isSubmitting}
size="large"
className="!rounded-lg flex-1"
>
{isSubmitting ? t('兑换中...') : t('兑换')}
</Button>
</div>
</div>
</div>
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4">
<IconPlus size="large" className="text-blue-500" />
</div>
<div>
<Text className="text-xl font-semibold">{t('在线充值')}</Text>
<div className="text-gray-500 text-sm">{t('支持多种支付方式')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-2">
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '80px', height: '14px' }} />
) : (
<Text type="tertiary">{t('实付金额:') + ' ' + renderAmount()}</Text>
)}
</div>
<InputNumber
disabled={!enableOnlineTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
step={1}
precision={0}
onChange={async (value) => {
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={<IconCreditCard />}
formatter={(value) => value ? `${value}` : ''}
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('zfb');
}}
size="large"
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiAlipay size={20} />}
>
<span className="ml-2">{t('支付宝')}</span>
</Button>
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('wx');
}}
size="large"
className="!rounded-lg !bg-green-500 hover:!bg-green-600 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiWechat size={20} />}
>
<span className="ml-2">{t('微信')}</span>
</Button>
</div>
{!enableOnlineTopUp && (
<Banner
fullMode={false}
type="warning"
icon={null}
closeIcon={null}
className="!rounded-lg"
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
{t('在线充值功能未开启')}
</div>
}
description={
<div>
{t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
</div>
}
/>
)}
</div>
</div>
</div> </div>
</div> </div>
</Card> </Card>