فهرست منبع

Merge pull request #1172 from QuantumNous/alpha

feat: 0.8 version
Calcium-Ion 11 ماه پیش
والد
کامیت
a26dbf5358
100فایلهای تغییر یافته به همراه14713 افزوده شده و 7584 حذف شده
  1. 13 12
      .github/workflows/docker-image-alpha.yml
  2. 0 54
      .github/workflows/docker-image-amd64.yml
  3. 0 54
      .github/workflows/linux-release.yml
  4. 0 45
      .github/workflows/macos-release.yml
  5. 0 48
      .github/workflows/windows-release.yml
  6. 5 0
      middleware/auth.go
  7. 12 11
      relay/channel/gemini/dto.go
  8. 20 3
      relay/channel/gemini/relay-gemini-native.go
  9. 77 29
      relay/channel/gemini/relay-gemini.go
  10. 38 9
      relay/relay-text.go
  11. 17 0
      service/audio.go
  12. 18 0
      setting/operation_setting/tools.go
  13. 22 3
      web/package.json
  14. 6 0
      web/postcss.config.js
  15. BIN
      web/public/example.png
  16. BIN
      web/public/favicon.ico
  17. BIN
      web/public/logo.png
  18. 34 31
      web/src/App.js
  19. 0 76
      web/src/components/Footer.js
  20. 0 494
      web/src/components/HeaderBar.js
  21. 0 12
      web/src/components/Loading.js
  22. 0 385
      web/src/components/LoginForm.js
  23. 0 660
      web/src/components/MjLogsTable.js
  24. 0 433
      web/src/components/ModelPricing.js
  25. 0 113
      web/src/components/PasswordResetConfirm.js
  26. 0 102
      web/src/components/PasswordResetForm.js
  27. 0 1193
      web/src/components/PersonalSetting.js
  28. 0 12
      web/src/components/PrivateRoute.js
  29. 0 434
      web/src/components/RegisterForm.js
  30. 0 535
      web/src/components/SiderBar.js
  31. 0 512
      web/src/components/TaskLogsTable.js
  32. 0 515
      web/src/components/UsersTable.js
  33. 536 0
      web/src/components/auth/LoginForm.js
  34. 12 9
      web/src/components/auth/OAuth2Callback.js
  35. 155 0
      web/src/components/auth/PasswordResetConfirm.js
  36. 156 0
      web/src/components/auth/PasswordResetForm.js
  37. 576 0
      web/src/components/auth/RegisterForm.js
  38. 24 0
      web/src/components/common/Loading.js
  39. 0 0
      web/src/components/common/logo/LinuxDoIcon.js
  40. 2 2
      web/src/components/common/logo/OIDCIcon.js
  41. 2 2
      web/src/components/common/logo/WeChatIcon.js
  42. 513 0
      web/src/components/common/markdown/MarkdownRenderer.js
  43. 444 0
      web/src/components/common/markdown/markdown.css
  44. 0 28
      web/src/components/custom/TextInput.js
  45. 0 21
      web/src/components/custom/TextNumberInput.js
  46. 0 68
      web/src/components/fetchTokenKeys.js
  47. 114 0
      web/src/components/layout/Footer.js
  48. 536 0
      web/src/components/layout/HeaderBar.js
  49. 94 0
      web/src/components/layout/NoticeModal.js
  50. 31 32
      web/src/components/layout/PageLayout.js
  51. 1 1
      web/src/components/layout/SetupCheck.js
  52. 448 0
      web/src/components/layout/SiderBar.js
  53. 113 0
      web/src/components/playground/ChatArea.js
  54. 313 0
      web/src/components/playground/CodeViewer.js
  55. 260 0
      web/src/components/playground/ConfigManager.js
  56. 58 0
      web/src/components/playground/CustomInputRender.js
  57. 190 0
      web/src/components/playground/CustomRequestEditor.js
  58. 193 0
      web/src/components/playground/DebugPanel.js
  59. 71 0
      web/src/components/playground/FloatingButtons.js
  60. 113 0
      web/src/components/playground/ImageUrlInput.js
  61. 121 0
      web/src/components/playground/MessageActions.js
  62. 313 0
      web/src/components/playground/MessageContent.js
  63. 60 0
      web/src/components/playground/OptimizedComponents.js
  64. 241 0
      web/src/components/playground/ParameterControl.js
  65. 234 0
      web/src/components/playground/SettingsPanel.js
  66. 125 0
      web/src/components/playground/ThinkingContent.js
  67. 203 0
      web/src/components/playground/configStorage.js
  68. 20 0
      web/src/components/playground/index.js
  69. 4 4
      web/src/components/settings/ModelSetting.js
  70. 13 13
      web/src/components/settings/OperationSetting.js
  71. 2 2
      web/src/components/settings/OtherSetting.js
  72. 1552 0
      web/src/components/settings/PersonalSetting.js
  73. 3 3
      web/src/components/settings/RateLimitSetting.js
  74. 3 3
      web/src/components/settings/SystemSetting.js
  75. 539 580
      web/src/components/table/ChannelsTable.js
  76. 307 316
      web/src/components/table/LogsTable.js
  77. 908 0
      web/src/components/table/MjLogsTable.js
  78. 637 0
      web/src/components/table/ModelPricing.js
  79. 244 154
      web/src/components/table/RedemptionsTable.js
  80. 743 0
      web/src/components/table/TaskLogsTable.js
  81. 233 181
      web/src/components/table/TokensTable.js
  82. 581 0
      web/src/components/table/UsersTable.js
  83. 0 76
      web/src/components/utils.js
  84. 1 1
      web/src/constants/channel.constants.js
  85. 3 2
      web/src/constants/index.js
  86. 95 0
      web/src/constants/playground.constants.js
  87. 204 83
      web/src/context/Style/index.js
  88. 184 1
      web/src/helpers/api.js
  89. 0 10
      web/src/helpers/auth-header.js
  90. 33 0
      web/src/helpers/auth.js
  91. 5 1
      web/src/helpers/index.js
  92. 1 1
      web/src/helpers/log.js
  93. 644 215
      web/src/helpers/render.js
  94. 45 0
      web/src/helpers/token.js
  95. 165 0
      web/src/helpers/utils.js
  96. 404 0
      web/src/hooks/useApiRequest.js
  97. 69 0
      web/src/hooks/useDataLoader.js
  98. 223 0
      web/src/hooks/useMessageActions.js
  99. 109 0
      web/src/hooks/useMessageEdit.js
  100. 225 0
      web/src/hooks/usePlaygroundState.js

+ 13 - 12
.github/workflows/docker-image-arm64.yml → .github/workflows/docker-image-alpha.yml

@@ -1,14 +1,15 @@
-name: Publish Docker image (arm64)
+name: Publish Docker image (alpha)
 
 on:
   push:
-    tags:
-      - '*'
+    branches:
+      - alpha
   workflow_dispatch:
     inputs:
       name:
-        description: 'reason'
+        description: "reason"
         required: false
+
 jobs:
   push_to_registries:
     name: Push Docker image to multiple registries
@@ -22,13 +23,7 @@ jobs:
 
       - name: Save version info
         run: |
-          git describe --tags > VERSION 
-
-      - name: Set up QEMU
-        uses: docker/setup-qemu-action@v3
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
+          echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
 
       - name: Log in to Docker Hub
         uses: docker/login-action@v3
@@ -43,6 +38,9 @@ jobs:
           username: ${{ github.actor }}
           password: ${{ secrets.GITHUB_TOKEN }}
 
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
       - name: Extract metadata (tags, labels) for Docker
         id: meta
         uses: docker/metadata-action@v5
@@ -50,6 +48,9 @@ jobs:
           images: |
             calciumion/new-api
             ghcr.io/${{ github.repository }}
+          tags: |
+            type=raw,value=alpha
+            type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
 
       - name: Build and push Docker images
         uses: docker/build-push-action@v5
@@ -58,4 +59,4 @@ jobs:
           platforms: linux/amd64,linux/arm64
           push: true
           tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
+          labels: ${{ steps.meta.outputs.labels }}

+ 0 - 54
.github/workflows/docker-image-amd64.yml

@@ -1,54 +0,0 @@
-name: Publish Docker image (amd64)
-
-on:
-  push:
-    tags:
-      - '*'
-  workflow_dispatch:
-    inputs:
-      name:
-        description: 'reason'
-        required: false
-jobs:
-  push_to_registries:
-    name: Push Docker image to multiple registries
-    runs-on: ubuntu-latest
-    permissions:
-      packages: write
-      contents: read
-    steps:
-      - name: Check out the repo
-        uses: actions/checkout@v4
-
-      - name: Save version info
-        run: |
-          git describe --tags > VERSION 
-
-      - name: Log in to Docker Hub
-        uses: docker/login-action@v3
-        with:
-          username: ${{ secrets.DOCKERHUB_USERNAME }}
-          password: ${{ secrets.DOCKERHUB_TOKEN }}
-
-      - name: Log in to the Container registry
-        uses: docker/login-action@v3
-        with:
-          registry: ghcr.io
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Extract metadata (tags, labels) for Docker
-        id: meta
-        uses: docker/metadata-action@v5
-        with:
-          images: |
-            calciumion/new-api
-            ghcr.io/${{ github.repository }}
-
-      - name: Build and push Docker images
-        uses: docker/build-push-action@v5
-        with:
-          context: .
-          push: true
-          tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}

+ 0 - 54
.github/workflows/linux-release.yml

@@ -1,54 +0,0 @@
-name: Linux Release
-permissions:
-  contents: write
-
-on:
-  push:
-    tags:
-      - '*'
-      - '!*-alpha*'
-jobs:
-  release:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v3
-        with:
-          fetch-depth: 0
-      - uses: actions/setup-node@v3
-        with:
-          node-version: 18
-      - name: Build Frontend
-        env:
-          CI: ""
-        run: |
-          cd web
-          npm install
-          REACT_APP_VERSION=$(git describe --tags) npm run build
-          cd ..
-      - name: Set up Go
-        uses: actions/setup-go@v3
-        with:
-          go-version: '>=1.18.0'
-      - name: Build Backend (amd64)
-        run: |
-          go mod download
-          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
-
-      - name: Build Backend (arm64)
-        run: |
-          sudo apt-get update
-          DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
-          CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
-
-      - name: Release
-        uses: softprops/action-gh-release@v1
-        if: startsWith(github.ref, 'refs/tags/')
-        with:
-          files: |
-            one-api
-            one-api-arm64
-          draft: true
-          generate_release_notes: true
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 0 - 45
.github/workflows/macos-release.yml

@@ -1,45 +0,0 @@
-name: macOS Release
-permissions:
-  contents: write
-
-on:
-  push:
-    tags:
-      - '*'
-      - '!*-alpha*'
-jobs:
-  release:
-    runs-on: macos-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v3
-        with:
-          fetch-depth: 0
-      - uses: actions/setup-node@v3
-        with:
-          node-version: 18
-      - name: Build Frontend
-        env:
-          CI: ""
-        run: |
-          cd web
-          npm install
-          REACT_APP_VERSION=$(git describe --tags) npm run build
-          cd ..
-      - name: Set up Go
-        uses: actions/setup-go@v3
-        with:
-          go-version: '>=1.18.0'
-      - name: Build Backend
-        run: |
-          go mod download
-          go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
-      - name: Release
-        uses: softprops/action-gh-release@v1
-        if: startsWith(github.ref, 'refs/tags/')
-        with:
-          files: one-api-macos
-          draft: true
-          generate_release_notes: true
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 0 - 48
.github/workflows/windows-release.yml

@@ -1,48 +0,0 @@
-name: Windows Release
-permissions:
-  contents: write
-
-on:
-  push:
-    tags:
-      - '*'
-      - '!*-alpha*'
-jobs:
-  release:
-    runs-on: windows-latest
-    defaults:
-      run:
-        shell: bash
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v3
-        with:
-          fetch-depth: 0
-      - uses: actions/setup-node@v3
-        with:
-          node-version: 18
-      - name: Build Frontend
-        env:
-          CI: ""
-        run: |
-          cd web
-          npm install
-          REACT_APP_VERSION=$(git describe --tags) npm run build
-          cd ..
-      - name: Set up Go
-        uses: actions/setup-go@v3
-        with:
-          go-version: '>=1.18.0'
-      - name: Build Backend
-        run: |
-          go mod download
-          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
-      - name: Release
-        uses: softprops/action-gh-release@v1
-        if: startsWith(github.ref, 'refs/tags/')
-        with:
-          files: one-api.exe
-          draft: true
-          generate_release_notes: true
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 5 - 0
middleware/auth.go

@@ -189,6 +189,11 @@ func TokenAuth() func(c *gin.Context) {
 			if skKey != "" {
 				c.Request.Header.Set("Authorization", "Bearer "+skKey)
 			}
+			// 从x-goog-api-key header中获取key
+			xGoogKey := c.Request.Header.Get("x-goog-api-key")
+			if xGoogKey != "" {
+				c.Request.Header.Set("Authorization", "Bearer "+xGoogKey)
+			}
 		}
 		key := c.Request.Header.Get("Authorization")
 		parts := make([]string, 0)

+ 12 - 11
relay/channel/gemini/dto.go

@@ -27,14 +27,9 @@ type FunctionCall struct {
 	Arguments    any    `json:"args"`
 }
 
-type GeminiFunctionResponseContent struct {
-	Name    string `json:"name"`
-	Content any    `json:"content"`
-}
-
 type FunctionResponse struct {
-	Name     string                        `json:"name"`
-	Response GeminiFunctionResponseContent `json:"response"`
+	Name     string                 `json:"name"`
+	Response map[string]interface{} `json:"response"`
 }
 
 type GeminiPartExecutableCode struct {
@@ -117,10 +112,16 @@ type GeminiChatResponse struct {
 }
 
 type GeminiUsageMetadata struct {
-	PromptTokenCount     int `json:"promptTokenCount"`
-	CandidatesTokenCount int `json:"candidatesTokenCount"`
-	TotalTokenCount      int `json:"totalTokenCount"`
-	ThoughtsTokenCount   int `json:"thoughtsTokenCount"`
+	PromptTokenCount     int                         `json:"promptTokenCount"`
+	CandidatesTokenCount int                         `json:"candidatesTokenCount"`
+	TotalTokenCount      int                         `json:"totalTokenCount"`
+	ThoughtsTokenCount   int                         `json:"thoughtsTokenCount"`
+	PromptTokensDetails  []GeminiPromptTokensDetails `json:"promptTokensDetails"`
+}
+
+type GeminiPromptTokensDetails struct {
+	Modality   string `json:"modality"`
+	TokenCount int    `json:"tokenCount"`
 }
 
 // Imagen related structs

+ 20 - 3
relay/channel/gemini/relay-gemini-native.go

@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
 		TotalTokens:      geminiResponse.UsageMetadata.TotalTokenCount,
 	}
 
+	usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
+
+	for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+		if detail.Modality == "AUDIO" {
+			usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+		} else if detail.Modality == "TEXT" {
+			usage.PromptTokensDetails.TextTokens = detail.TokenCount
+		}
+	}
+
 	// 直接返回 Gemini 原生格式的 JSON 响应
 	jsonResponse, err := json.Marshal(geminiResponse)
 	if err != nil {
@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
 			usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
 			usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
 			usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
+			usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
+			for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+				if detail.Modality == "AUDIO" {
+					usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+				} else if detail.Modality == "TEXT" {
+					usage.PromptTokensDetails.TextTokens = detail.TokenCount
+				}
+			}
 		}
 
 		// 直接发送 GeminiChatResponse 响应
@@ -118,11 +136,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
 	}
 
 	// 计算最终使用量
-	usage.PromptTokensDetails.TextTokens = usage.PromptTokens
 	usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
 
-	// 结束流式响应
-	helper.Done(c)
+	// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
+	//helper.Done(c)
 
 	return usage, nil
 }

+ 77 - 29
relay/channel/gemini/relay-gemini.go

@@ -57,25 +57,63 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 	}
 
 	if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
-	        if strings.HasSuffix(info.OriginModelName, "-thinking") {
-	            // 如果模型名以 gemini-2.5-pro 开头,不设置 ThinkingBudget
-	            if strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") {
-	                geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
-	                    IncludeThoughts: true,
-	                }
-	            } else {
-	                budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
-	                if budgetTokens == 0 || budgetTokens > 24576 {
-	                    budgetTokens = 24576
-	                }
-	                geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
-	                    ThinkingBudget:  common.GetPointer(int(budgetTokens)),
-	                    IncludeThoughts: true,
-	                }
-	            }
+		if strings.HasSuffix(info.OriginModelName, "-thinking") {
+			// 硬编码不支持 ThinkingBudget 的旧模型
+			unsupportedModels := []string{
+				"gemini-2.5-pro-preview-05-06",
+				"gemini-2.5-pro-preview-03-25",
+			}
+
+			isUnsupported := false
+			for _, unsupportedModel := range unsupportedModels {
+				if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
+					isUnsupported = true
+					break
+				}
+			}
+
+			if isUnsupported {
+				geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
+					IncludeThoughts: true,
+				}
+			} else {
+				budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
+
+				// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
+				isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
+					!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
+					!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
+
+				if isNew25Pro {
+					// 新的2.5pro模型:ThinkingBudget范围为128-32768
+					if budgetTokens == 0 || budgetTokens < 128 {
+						budgetTokens = 128
+					} else if budgetTokens > 32768 {
+						budgetTokens = 32768
+					}
+				} else {
+					// 其他模型:ThinkingBudget范围为0-24576
+					if budgetTokens == 0 || budgetTokens > 24576 {
+						budgetTokens = 24576
+					}
+				}
+
+				geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
+					ThinkingBudget:  common.GetPointer(int(budgetTokens)),
+					IncludeThoughts: true,
+				}
+			}
 		} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
-			geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
-				ThinkingBudget: common.GetPointer(0),
+			// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128)
+			isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
+				!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
+				!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
+
+			if !isNew25Pro {
+				// 只有非新2.5pro模型才支持-nothinking
+				geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
+					ThinkingBudget: common.GetPointer(0),
+				}
 			}
 		}
 	}
@@ -173,17 +211,12 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 			} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
 				name = val
 			}
-			content := common.StrToMap(message.StringContent())
+			contentMap := common.StrToMap(message.StringContent())
 			functionResp := &FunctionResponse{
-				Name: name,
-				Response: GeminiFunctionResponseContent{
-					Name:    name,
-					Content: content,
-				},
-			}
-			if content == nil {
-				functionResp.Response.Content = message.StringContent()
+				Name:     name,
+				Response: contentMap,
 			}
+
 			*parts = append(*parts, GeminiPart{
 				FunctionResponse: functionResp,
 			})
@@ -280,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 				if part.GetInputAudio().Data == "" {
 					return nil, fmt.Errorf("only base64 audio is supported in gemini")
 				}
-				format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data)
+				base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
 				if err != nil {
 					return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
 				}
 				parts = append(parts, GeminiPart{
 					InlineData: &GeminiInlineData{
-						MimeType: format,
+						MimeType: "audio/" + part.GetInputAudio().Format,
 						Data:     base64String,
 					},
 				})
@@ -738,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
 			usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
 			usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
 			usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
+			for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+				if detail.Modality == "AUDIO" {
+					usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+				} else if detail.Modality == "TEXT" {
+					usage.PromptTokensDetails.TextTokens = detail.TokenCount
+				}
+			}
 		}
 		err = helper.ObjectData(c, response)
 		if err != nil {
@@ -812,6 +852,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
 	usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
 	usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
 
+	for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+		if detail.Modality == "AUDIO" {
+			usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+		} else if detail.Modality == "TEXT" {
+			usage.PromptTokensDetails.TextTokens = detail.TokenCount
+		}
+	}
+
 	fullTextResponse.Usage = usage
 	jsonResponse, err := json.Marshal(fullTextResponse)
 	if err != nil {

+ 38 - 9
relay/relay-text.go

@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	promptTokens := usage.PromptTokens
 	cacheTokens := usage.PromptTokensDetails.CachedTokens
 	imageTokens := usage.PromptTokensDetails.ImageTokens
+	audioTokens := usage.PromptTokensDetails.AudioTokens
 	completionTokens := usage.CompletionTokens
 	modelName := relayInfo.OriginModelName
 
@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	dPromptTokens := decimal.NewFromInt(int64(promptTokens))
 	dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
 	dImageTokens := decimal.NewFromInt(int64(imageTokens))
+	dAudioTokens := decimal.NewFromInt(int64(audioTokens))
 	dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
 	dCompletionRatio := decimal.NewFromFloat(completionRatio)
 	dCacheRatio := decimal.NewFromFloat(cacheRatio)
@@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 			dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
 				Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
 				Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
-			extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s",
+			extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s",
 				fileSearchTool.CallCount, dFileSearchQuota.String())
 		}
 	}
 
 	var quotaCalculateDecimal decimal.Decimal
+
+	var audioInputQuota decimal.Decimal
+	var audioInputPrice float64
 	if !priceData.UsePrice {
-		nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
-		cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio)
-
-		promptQuota := nonCachedTokens.Add(cachedTokensWithRatio)
-		if imageTokens > 0 {
-			nonImageTokens := dPromptTokens.Sub(dImageTokens)
-			imageTokensWithRatio := dImageTokens.Mul(dImageRatio)
-			promptQuota = nonImageTokens.Add(imageTokensWithRatio)
+		baseTokens := dPromptTokens
+		// 减去 cached tokens
+		var cachedTokensWithRatio decimal.Decimal
+		if !dCacheTokens.IsZero() {
+			baseTokens = baseTokens.Sub(dCacheTokens)
+			cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
+		}
+
+		// 减去 image tokens
+		var imageTokensWithRatio decimal.Decimal
+		if !dImageTokens.IsZero() {
+			baseTokens = baseTokens.Sub(dImageTokens)
+			imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
 		}
 
+		// 减去 Gemini audio tokens
+		if !dAudioTokens.IsZero() {
+			audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
+			if audioInputPrice > 0 {
+				// 重新计算 base tokens
+				baseTokens = baseTokens.Sub(dAudioTokens)
+				audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
+				extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
+			}
+		}
+		promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
+
 		completionQuota := dCompletionTokens.Mul(dCompletionRatio)
 
 		quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	// 添加 responses tools call 调用的配额
 	quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
 	quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
+	// 添加 audio input 独立计费
+	quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
 
 	quota := int(quotaCalculateDecimal.Round(0).IntPart())
 	totalTokens := promptTokens + completionTokens
@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 			other["file_search_price"] = fileSearchPrice
 		}
 	}
+	if !audioInputQuota.IsZero() {
+		other["audio_input_seperate_price"] = true
+		other["audio_input_token_count"] = audioTokens
+		other["audio_input_price"] = audioInputPrice
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
 		tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
 }

+ 17 - 0
service/audio.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/base64"
 	"fmt"
+	"strings"
 )
 
 func parseAudio(audioBase64 string, format string) (duration float64, err error) {
@@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error)
 	duration = float64(samplesCount) / float64(sampleRate)
 	return duration, nil
 }
+
+func DecodeBase64AudioData(audioBase64 string) (string, error) {
+	// 检查并移除 data:audio/xxx;base64, 前缀
+	idx := strings.Index(audioBase64, ",")
+	if idx != -1 {
+		audioBase64 = audioBase64[idx+1:]
+	}
+
+	// 解码 Base64 数据
+	_, err := base64.StdEncoding.DecodeString(audioBase64)
+	if err != nil {
+		return "", fmt.Errorf("base64 decode error: %v", err)
+	}
+
+	return audioBase64, nil
+}

+ 18 - 0
setting/operation_setting/tools.go

@@ -14,6 +14,13 @@ const (
 	FileSearchPrice = 2.5
 )
 
+const (
+	// Gemini Audio Input Price
+	Gemini25FlashPreviewInputAudioPrice     = 1.00
+	Gemini25FlashNativeAudioInputAudioPrice = 3.00
+	Gemini20FlashInputAudioPrice            = 0.70
+)
+
 func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
 	// 确定模型类型
 	// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
 func GetFileSearchPricePerThousand() float64 {
 	return FileSearchPrice
 }
+
+func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
+	if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
+		return Gemini25FlashPreviewInputAudioPrice
+	} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
+		return Gemini25FlashNativeAudioInputAudioPrice
+	} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
+		return Gemini20FlashInputAudioPrice
+	}
+	return 0
+}

+ 22 - 3
web/package.json

@@ -6,27 +6,42 @@
   "dependencies": {
     "@douyinfe/semi-icons": "^2.63.1",
     "@douyinfe/semi-ui": "^2.69.1",
+    "@lobehub/icons": "^2.0.0",
     "@visactor/react-vchart": "~1.8.8",
     "@visactor/vchart": "~1.8.8",
     "@visactor/vchart-semi-theme": "~1.8.8",
     "axios": "^0.27.2",
+    "clsx": "^2.1.1",
+    "country-flag-icons": "^1.5.19",
     "dayjs": "^1.11.11",
     "history": "^5.3.0",
+    "i18next": "^23.16.8",
+    "i18next-browser-languagedetector": "^7.2.0",
+    "katex": "^0.16.22",
+    "lucide-react": "^0.511.0",
     "marked": "^4.1.1",
+    "mermaid": "^11.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-dropzone": "^14.2.3",
     "react-fireworks": "^1.0.4",
+    "react-i18next": "^13.0.0",
+    "react-icons": "^5.5.0",
+    "react-markdown": "^10.1.0",
     "react-router-dom": "^6.3.0",
     "react-telegram-login": "^1.1.2",
     "react-toastify": "^9.0.8",
     "react-turnstile": "^1.0.5",
+    "rehype-highlight": "^7.0.2",
+    "rehype-katex": "^7.0.1",
+    "remark-breaks": "^4.0.0",
+    "remark-gfm": "^4.0.1",
+    "remark-math": "^6.0.0",
     "semantic-ui-offline": "^2.5.0",
     "semantic-ui-react": "^2.1.3",
     "sse": "https://github.com/mpetazzoni/sse.js",
-    "i18next": "^23.16.8",
-    "react-i18next": "^13.0.0",
-    "i18next-browser-languagedetector": "^7.2.0"
+    "unist-util-visit": "^5.0.0",
+    "use-debounce": "^10.0.4"
   },
   "scripts": {
     "dev": "vite",
@@ -54,9 +69,13 @@
     ]
   },
   "devDependencies": {
+    "@douyinfe/semi-webpack-plugin": "^2.78.0",
     "@so1ve/prettier-config": "^3.1.0",
     "@vitejs/plugin-react": "^4.2.1",
+    "autoprefixer": "^10.4.21",
+    "postcss": "^8.5.3",
     "prettier": "^3.0.0",
+    "tailwindcss": "^3",
     "typescript": "4.4.2",
     "vite": "^5.2.0"
   },

+ 6 - 0
web/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

BIN
web/public/example.png


BIN
web/public/favicon.ico


BIN
web/public/logo.png


+ 34 - 31
web/src/App.js

@@ -1,15 +1,15 @@
-import React, { lazy, Suspense, useContext, useEffect } from 'react';
+import React, { lazy, Suspense } from 'react';
 import { Route, Routes, useLocation } from 'react-router-dom';
-import Loading from './components/Loading';
+import Loading from './components/common/Loading.js';
 import User from './pages/User';
-import { PrivateRoute } from './components/PrivateRoute';
-import RegisterForm from './components/RegisterForm';
-import LoginForm from './components/LoginForm';
+import { AuthRedirect, PrivateRoute } from './helpers';
+import RegisterForm from './components/auth/RegisterForm.js';
+import LoginForm from './components/auth/LoginForm.js';
 import NotFound from './pages/NotFound';
 import Setting from './pages/Setting';
 import EditUser from './pages/User/EditUser';
-import PasswordResetForm from './components/PasswordResetForm';
-import PasswordResetConfirm from './components/PasswordResetConfirm';
+import PasswordResetForm from './components/auth/PasswordResetForm.js';
+import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
 import Channel from './pages/Channel';
 import Token from './pages/Token';
 import EditChannel from './pages/Channel/EditChannel';
@@ -18,15 +18,14 @@ import TopUp from './pages/TopUp';
 import Log from './pages/Log';
 import Chat from './pages/Chat';
 import Chat2Link from './pages/Chat2Link';
-import { Layout } from '@douyinfe/semi-ui';
 import Midjourney from './pages/Midjourney';
 import Pricing from './pages/Pricing/index.js';
 import Task from './pages/Task/index.js';
-import Playground from './pages/Playground/Playground.js';
-import OAuth2Callback from './components/OAuth2Callback.js';
-import PersonalSetting from './components/PersonalSetting.js';
+import Playground from './pages/Playground/index.js';
+import OAuth2Callback from './components/auth/OAuth2Callback.js';
+import PersonalSetting from './components/settings/PersonalSetting.js';
 import Setup from './pages/Setup/index.js';
-import SetupCheck from './components/SetupCheck';
+import SetupCheck from './components/layout/SetupCheck.js';
 
 const Home = lazy(() => import('./pages/Home'));
 const Detail = lazy(() => import('./pages/Detail'));
@@ -55,7 +54,7 @@ function App() {
           }
         />
         <Route
-          path='/channel'
+          path='/console/channel'
           element={
             <PrivateRoute>
               <Channel />
@@ -63,7 +62,7 @@ function App() {
           }
         />
         <Route
-          path='/channel/edit/:id'
+          path='/console/channel/edit/:id'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
               <EditChannel />
@@ -71,7 +70,7 @@ function App() {
           }
         />
         <Route
-          path='/channel/add'
+          path='/console/channel/add'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
               <EditChannel />
@@ -79,7 +78,7 @@ function App() {
           }
         />
         <Route
-          path='/token'
+          path='/console/token'
           element={
             <PrivateRoute>
               <Token />
@@ -87,7 +86,7 @@ function App() {
           }
         />
         <Route
-          path='/playground'
+          path='/console/playground'
           element={
             <PrivateRoute>
               <Playground />
@@ -95,7 +94,7 @@ function App() {
           }
         />
         <Route
-          path='/redemption'
+          path='/console/redemption'
           element={
             <PrivateRoute>
               <Redemption />
@@ -103,7 +102,7 @@ function App() {
           }
         />
         <Route
-          path='/user'
+          path='/console/user'
           element={
             <PrivateRoute>
               <User />
@@ -111,7 +110,7 @@ function App() {
           }
         />
         <Route
-          path='/user/edit/:id'
+          path='/console/user/edit/:id'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
               <EditUser />
@@ -119,7 +118,7 @@ function App() {
           }
         />
         <Route
-          path='/user/edit'
+          path='/console/user/edit'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
               <EditUser />
@@ -138,7 +137,9 @@ function App() {
           path='/login'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-              <LoginForm />
+              <AuthRedirect>
+                <LoginForm />
+              </AuthRedirect>
             </Suspense>
           }
         />
@@ -146,7 +147,9 @@ function App() {
           path='/register'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
-              <RegisterForm />
+              <AuthRedirect>
+                <RegisterForm />
+              </AuthRedirect>
             </Suspense>
           }
         />
@@ -183,7 +186,7 @@ function App() {
           }
         />
         <Route
-          path='/setting'
+          path='/console/setting'
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -193,7 +196,7 @@ function App() {
           }
         />
         <Route
-          path='/personal'
+          path='/console/personal'
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -203,7 +206,7 @@ function App() {
           }
         />
         <Route
-          path='/topup'
+          path='/console/topup'
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -213,7 +216,7 @@ function App() {
           }
         />
         <Route
-          path='/log'
+          path='/console/log'
           element={
             <PrivateRoute>
               <Log />
@@ -221,7 +224,7 @@ function App() {
           }
         />
         <Route
-          path='/detail'
+          path='/console'
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -231,7 +234,7 @@ function App() {
           }
         />
         <Route
-          path='/midjourney'
+          path='/console/midjourney'
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -241,7 +244,7 @@ function App() {
           }
         />
         <Route
-          path='/task'
+          path='/console/task'
           element={
             <PrivateRoute>
               <Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -267,7 +270,7 @@ function App() {
           }
         />
         <Route
-          path='/chat/:id?'
+          path='/console/chat/:id?'
           element={
             <Suspense fallback={<Loading></Loading>} key={location.pathname}>
               <Chat />

+ 0 - 76
web/src/components/Footer.js

@@ -1,76 +0,0 @@
-import React, { useEffect, useState, useContext } from 'react';
-import { useTranslation } from 'react-i18next';
-import { getFooterHTML, getSystemName } from '../helpers';
-import { Layout, Tooltip } from '@douyinfe/semi-ui';
-import { StyleContext } from '../context/Style/index.js';
-
-const FooterBar = () => {
-  const { t } = useTranslation();
-  const systemName = getSystemName();
-  const [footer, setFooter] = useState(getFooterHTML());
-  const [styleState] = useContext(StyleContext);
-  let remainCheckTimes = 5;
-
-  const loadFooter = () => {
-    let footer_html = localStorage.getItem('footer_html');
-    if (footer_html) {
-      setFooter(footer_html);
-    }
-  };
-
-  const defaultFooter = (
-    <div className='custom-footer'>
-      <a
-        href='https://github.com/Calcium-Ion/new-api'
-        target='_blank'
-        rel='noreferrer'
-      >
-        New API {import.meta.env.VITE_REACT_APP_VERSION}{' '}
-      </a>
-      {t('由')}{' '}
-      <a href='https://github.com/Calcium-Ion' target='_blank' rel='noreferrer'>
-        Calcium-Ion
-      </a>{' '}
-      {t('开发,基于')}{' '}
-      <a
-        href='https://github.com/songquanpeng/one-api'
-        target='_blank'
-        rel='noreferrer'
-      >
-        One API
-      </a>
-    </div>
-  );
-
-  useEffect(() => {
-    const timer = setInterval(() => {
-      if (remainCheckTimes <= 0) {
-        clearInterval(timer);
-        return;
-      }
-      remainCheckTimes--;
-      loadFooter();
-    }, 200);
-    return () => clearTimeout(timer);
-  }, []);
-
-  return (
-    <div
-      style={{
-        textAlign: 'center',
-        paddingBottom: '5px',
-      }}
-    >
-      {footer ? (
-        <div
-          className='custom-footer'
-          dangerouslySetInnerHTML={{ __html: footer }}
-        ></div>
-      ) : (
-        defaultFooter
-      )}
-    </div>
-  );
-};
-
-export default FooterBar;

+ 0 - 494
web/src/components/HeaderBar.js

@@ -1,494 +0,0 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { UserContext } from '../context/User';
-import { useSetTheme, useTheme } from '../context/Theme';
-import { useTranslation } from 'react-i18next';
-
-import { API, getLogo, getSystemName, isMobile, showSuccess } from '../helpers';
-import '../index.css';
-
-import fireworks from 'react-fireworks';
-
-import {
-  IconClose,
-  IconHelpCircle,
-  IconHome,
-  IconHomeStroked,
-  IconIndentLeft,
-  IconComment,
-  IconKey,
-  IconMenu,
-  IconNoteMoneyStroked,
-  IconPriceTag,
-  IconUser,
-  IconLanguage,
-  IconInfoCircle,
-  IconCreditCard,
-  IconTerminal,
-} from '@douyinfe/semi-icons';
-import {
-  Avatar,
-  Button,
-  Dropdown,
-  Layout,
-  Nav,
-  Switch,
-  Tag,
-} from '@douyinfe/semi-ui';
-import { stringToColor } from '../helpers/render';
-import Text from '@douyinfe/semi-ui/lib/es/typography/text';
-import { StyleContext } from '../context/Style/index.js';
-import { StatusContext } from '../context/Status/index.js';
-
-// 自定义顶部栏样式
-const headerStyle = {
-  boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)',
-  borderBottom: '1px solid var(--semi-color-border)',
-  background: 'var(--semi-color-bg-0)',
-  transition: 'all 0.3s ease',
-  width: '100%',
-};
-
-// 自定义顶部栏按钮样式
-const headerItemStyle = {
-  borderRadius: '4px',
-  margin: '0 4px',
-  transition: 'all 0.3s ease',
-};
-
-// 自定义顶部栏按钮悬停样式
-const headerItemHoverStyle = {
-  backgroundColor: 'var(--semi-color-primary-light-default)',
-  color: 'var(--semi-color-primary)',
-};
-
-// 自定义顶部栏Logo样式
-const logoStyle = {
-  display: 'flex',
-  alignItems: 'center',
-  gap: '10px',
-  padding: '0 10px',
-  height: '100%',
-};
-
-// 自定义顶部栏系统名称样式
-const systemNameStyle = {
-  fontWeight: 'bold',
-  fontSize: '18px',
-  background:
-    'linear-gradient(45deg, var(--semi-color-primary), var(--semi-color-secondary))',
-  WebkitBackgroundClip: 'text',
-  WebkitTextFillColor: 'transparent',
-  padding: '0 5px',
-};
-
-// 自定义顶部栏按钮图标样式
-const headerIconStyle = {
-  fontSize: '18px',
-  transition: 'all 0.3s ease',
-};
-
-// 自定义头像样式
-const avatarStyle = {
-  margin: '4px',
-  cursor: 'pointer',
-  boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-  transition: 'all 0.3s ease',
-};
-
-// 自定义下拉菜单样式
-const dropdownStyle = {
-  borderRadius: '8px',
-  boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
-  overflow: 'hidden',
-};
-
-// 自定义主题切换开关样式
-const switchStyle = {
-  margin: '0 8px',
-};
-
-const HeaderBar = () => {
-  const { t, i18n } = useTranslation();
-  const [userState, userDispatch] = useContext(UserContext);
-  const [styleState, styleDispatch] = useContext(StyleContext);
-  const [statusState, statusDispatch] = useContext(StatusContext);
-  let navigate = useNavigate();
-  const [currentLang, setCurrentLang] = useState(i18n.language);
-
-  const systemName = getSystemName();
-  const logo = getLogo();
-  const currentDate = new Date();
-  // enable fireworks on new year(1.1 and 2.9-2.24)
-  const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
-
-  // Check if self-use mode is enabled
-  const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
-  const docsLink = statusState?.status?.docs_link || '';
-  const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
-
-  let buttons = [
-    {
-      text: t('首页'),
-      itemKey: 'home',
-      to: '/',
-      icon: <IconHome style={headerIconStyle} />,
-    },
-    {
-      text: t('控制台'),
-      itemKey: 'detail',
-      to: '/',
-      icon: <IconTerminal style={headerIconStyle} />,
-    },
-    {
-      text: t('定价'),
-      itemKey: 'pricing',
-      to: '/pricing',
-      icon: <IconPriceTag style={headerIconStyle} />,
-    },
-    // Only include the docs button if docsLink exists
-    ...(docsLink
-      ? [
-          {
-            text: t('文档'),
-            itemKey: 'docs',
-            isExternal: true,
-            externalLink: docsLink,
-            icon: <IconHelpCircle style={headerIconStyle} />,
-          },
-        ]
-      : []),
-    {
-      text: t('关于'),
-      itemKey: 'about',
-      to: '/about',
-      icon: <IconInfoCircle style={headerIconStyle} />,
-    },
-  ];
-
-  async function logout() {
-    await API.get('/api/user/logout');
-    showSuccess(t('注销成功!'));
-    userDispatch({ type: 'logout' });
-    localStorage.removeItem('user');
-    navigate('/login');
-  }
-
-  const handleNewYearClick = () => {
-    fireworks.init('root', {});
-    fireworks.start();
-    setTimeout(() => {
-      fireworks.stop();
-      setTimeout(() => {
-        window.location.reload();
-      }, 10000);
-    }, 3000);
-  };
-
-  const theme = useTheme();
-  const setTheme = useSetTheme();
-
-  useEffect(() => {
-    if (theme === 'dark') {
-      document.body.setAttribute('theme-mode', 'dark');
-    } else {
-      document.body.removeAttribute('theme-mode');
-    }
-    // 发送当前主题模式给子页面
-    const iframe = document.querySelector('iframe');
-    if (iframe) {
-      iframe.contentWindow.postMessage({ themeMode: theme }, '*');
-    }
-
-    if (isNewYear) {
-      console.log('Happy New Year!');
-    }
-  }, [theme]);
-
-  useEffect(() => {
-    const handleLanguageChanged = (lng) => {
-      setCurrentLang(lng);
-      const iframe = document.querySelector('iframe');
-      if (iframe) {
-        iframe.contentWindow.postMessage({ lang: lng }, '*');
-      }
-    };
-
-    i18n.on('languageChanged', handleLanguageChanged);
-
-    return () => {
-      i18n.off('languageChanged', handleLanguageChanged);
-    };
-  }, [i18n]);
-
-  const handleLanguageChange = (lang) => {
-    i18n.changeLanguage(lang);
-  };
-
-  return (
-    <>
-      <Layout>
-        <div style={{ width: '100%' }}>
-          <Nav
-            className={'topnav'}
-            mode={'horizontal'}
-            style={headerStyle}
-            itemStyle={headerItemStyle}
-            hoverStyle={headerItemHoverStyle}
-            renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
-              const routerMap = {
-                about: '/about',
-                login: '/login',
-                register: '/register',
-                pricing: '/pricing',
-                detail: '/detail',
-                home: '/',
-                chat: '/chat',
-              };
-              return (
-                <div
-                  onClick={(e) => {
-                    if (props.itemKey === 'home') {
-                      styleDispatch({
-                        type: 'SET_INNER_PADDING',
-                        payload: false,
-                      });
-                      styleDispatch({ type: 'SET_SIDER', payload: false });
-                    } else {
-                      styleDispatch({
-                        type: 'SET_INNER_PADDING',
-                        payload: true,
-                      });
-                      if (!styleState.isMobile) {
-                        styleDispatch({ type: 'SET_SIDER', payload: true });
-                      }
-                    }
-                  }}
-                >
-                  {props.isExternal ? (
-                    <a
-                      className='header-bar-text'
-                      style={{ textDecoration: 'none' }}
-                      href={props.externalLink}
-                      target='_blank'
-                      rel='noopener noreferrer'
-                    >
-                      {itemElement}
-                    </a>
-                  ) : (
-                    <Link
-                      className='header-bar-text'
-                      style={{ textDecoration: 'none' }}
-                      to={routerMap[props.itemKey]}
-                    >
-                      {itemElement}
-                    </Link>
-                  )}
-                </div>
-              );
-            }}
-            selectedKeys={[]}
-            // items={headerButtons}
-            onSelect={(key) => {}}
-            header={
-              styleState.isMobile
-                ? {
-                    logo: (
-                      <div
-                        style={{
-                          display: 'flex',
-                          alignItems: 'center',
-                          position: 'relative',
-                        }}
-                      >
-                        {!styleState.showSider ? (
-                          <Button
-                            icon={<IconMenu />}
-                            theme='light'
-                            aria-label={t('展开侧边栏')}
-                            onClick={() =>
-                              styleDispatch({
-                                type: 'SET_SIDER',
-                                payload: true,
-                              })
-                            }
-                          />
-                        ) : (
-                          <Button
-                            icon={<IconIndentLeft />}
-                            theme='light'
-                            aria-label={t('闭侧边栏')}
-                            onClick={() =>
-                              styleDispatch({
-                                type: 'SET_SIDER',
-                                payload: false,
-                              })
-                            }
-                          />
-                        )}
-                        {(isSelfUseMode || isDemoSiteMode) && (
-                          <Tag
-                            color={isSelfUseMode ? 'purple' : 'blue'}
-                            style={{
-                              position: 'absolute',
-                              top: '-8px',
-                              right: '-15px',
-                              fontSize: '0.7rem',
-                              padding: '0 4px',
-                              height: 'auto',
-                              lineHeight: '1.2',
-                              zIndex: 1,
-                              pointerEvents: 'none',
-                            }}
-                          >
-                            {isSelfUseMode ? t('自用模式') : t('演示站点')}
-                          </Tag>
-                        )}
-                      </div>
-                    ),
-                  }
-                : {
-                    logo: (
-                      <div style={logoStyle}>
-                        <img src={logo} alt='logo' style={{ height: '28px' }} />
-                      </div>
-                    ),
-                    text: (
-                      <div
-                        style={{
-                          position: 'relative',
-                          display: 'inline-block',
-                        }}
-                      >
-                        <span style={systemNameStyle}>{systemName}</span>
-                        {(isSelfUseMode || isDemoSiteMode) && (
-                          <Tag
-                            color={isSelfUseMode ? 'purple' : 'blue'}
-                            style={{
-                              position: 'absolute',
-                              top: '-10px',
-                              right: '-25px',
-                              fontSize: '0.7rem',
-                              padding: '0 4px',
-                              whiteSpace: 'nowrap',
-                              zIndex: 1,
-                              boxShadow: '0 0 3px rgba(255, 255, 255, 0.7)',
-                            }}
-                          >
-                            {isSelfUseMode ? t('自用模式') : t('演示站点')}
-                          </Tag>
-                        )}
-                      </div>
-                    ),
-                  }
-            }
-            items={buttons}
-            footer={
-              <>
-                {isNewYear && (
-                  // happy new year
-                  <Dropdown
-                    position='bottomRight'
-                    render={
-                      <Dropdown.Menu style={dropdownStyle}>
-                        <Dropdown.Item onClick={handleNewYearClick}>
-                          Happy New Year!!!
-                        </Dropdown.Item>
-                      </Dropdown.Menu>
-                    }
-                  >
-                    <Nav.Item itemKey={'new-year'} text={'🎉'} />
-                  </Dropdown>
-                )}
-                {/* <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} /> */}
-                <>
-                  <Switch
-                    checkedText='🌞'
-                    size={styleState.isMobile ? 'default' : 'large'}
-                    checked={theme === 'dark'}
-                    uncheckedText='🌙'
-                    style={switchStyle}
-                    onChange={(checked) => {
-                      setTheme(checked);
-                    }}
-                  />
-                </>
-                <Dropdown
-                  position='bottomRight'
-                  render={
-                    <Dropdown.Menu style={dropdownStyle}>
-                      <Dropdown.Item
-                        onClick={() => handleLanguageChange('zh')}
-                        type={currentLang === 'zh' ? 'primary' : 'tertiary'}
-                      >
-                        中文
-                      </Dropdown.Item>
-                      <Dropdown.Item
-                        onClick={() => handleLanguageChange('en')}
-                        type={currentLang === 'en' ? 'primary' : 'tertiary'}
-                      >
-                        English
-                      </Dropdown.Item>
-                    </Dropdown.Menu>
-                  }
-                >
-                  <Nav.Item
-                    itemKey={'language'}
-                    icon={<IconLanguage style={headerIconStyle} />}
-                  />
-                </Dropdown>
-                {userState.user ? (
-                  <>
-                    <Dropdown
-                      position='bottomRight'
-                      render={
-                        <Dropdown.Menu style={dropdownStyle}>
-                          <Dropdown.Item onClick={logout}>
-                            {t('退出')}
-                          </Dropdown.Item>
-                        </Dropdown.Menu>
-                      }
-                    >
-                      <Avatar
-                        size='small'
-                        color={stringToColor(userState.user.username)}
-                        style={avatarStyle}
-                      >
-                        {userState.user.username[0]}
-                      </Avatar>
-                      {styleState.isMobile ? null : (
-                        <Text style={{ marginLeft: '4px', fontWeight: '500' }}>
-                          {userState.user.username}
-                        </Text>
-                      )}
-                    </Dropdown>
-                  </>
-                ) : (
-                  <>
-                    <Nav.Item
-                      itemKey={'login'}
-                      text={!styleState.isMobile ? t('登录') : null}
-                      icon={<IconUser style={headerIconStyle} />}
-                    />
-                    {
-                      // Hide register option in self-use mode
-                      !styleState.isMobile && !isSelfUseMode && (
-                        <Nav.Item
-                          itemKey={'register'}
-                          text={t('注册')}
-                          icon={<IconKey style={headerIconStyle} />}
-                        />
-                      )
-                    }
-                  </>
-                )}
-              </>
-            }
-          ></Nav>
-        </div>
-      </Layout>
-    </>
-  );
-};
-
-export default HeaderBar;

+ 0 - 12
web/src/components/Loading.js

@@ -1,12 +0,0 @@
-import React from 'react';
-import { Spin } from '@douyinfe/semi-ui';
-
-const Loading = ({ prompt: name = 'page' }) => {
-  return (
-    <Spin style={{ height: 100 }} spinning={true}>
-      加载{name}中...
-    </Spin>
-  );
-};
-
-export default Loading;

+ 0 - 385
web/src/components/LoginForm.js

@@ -1,385 +0,0 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { Link, useNavigate, useSearchParams } from 'react-router-dom';
-import { UserContext } from '../context/User';
-import {
-  API,
-  getLogo,
-  showError,
-  showInfo,
-  showSuccess,
-  updateAPI,
-} from '../helpers';
-import {
-  onGitHubOAuthClicked,
-  onOIDCClicked,
-  onLinuxDOOAuthClicked,
-} from './utils';
-import Turnstile from 'react-turnstile';
-import {
-  Button,
-  Card,
-  Divider,
-  Form,
-  Icon,
-  Layout,
-  Modal,
-} from '@douyinfe/semi-ui';
-import Title from '@douyinfe/semi-ui/lib/es/typography/title';
-import Text from '@douyinfe/semi-ui/lib/es/typography/text';
-import TelegramLoginButton from 'react-telegram-login';
-
-import { IconGithubLogo, IconAlarm } from '@douyinfe/semi-icons';
-import OIDCIcon from './OIDCIcon.js';
-import WeChatIcon from './WeChatIcon';
-import { setUserData } from '../helpers/data.js';
-import LinuxDoIcon from './LinuxDoIcon.js';
-import { useTranslation } from 'react-i18next';
-
-const LoginForm = () => {
-  const [inputs, setInputs] = useState({
-    username: '',
-    password: '',
-    wechat_verification_code: '',
-  });
-  const [searchParams, setSearchParams] = useSearchParams();
-  const [submitted, setSubmitted] = useState(false);
-  const { username, password } = inputs;
-  const [userState, userDispatch] = useContext(UserContext);
-  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-  const [turnstileToken, setTurnstileToken] = useState('');
-  let navigate = useNavigate();
-  const [status, setStatus] = useState({});
-  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
-  const { t } = useTranslation();
-
-  const logo = getLogo();
-
-  let affCode = new URLSearchParams(window.location.search).get('aff');
-  if (affCode) {
-    localStorage.setItem('aff', affCode);
-  }
-
-  useEffect(() => {
-    if (searchParams.get('expired')) {
-      showError(t('未登录或登录已过期,请重新登录'));
-    }
-    let status = localStorage.getItem('status');
-    if (status) {
-      status = JSON.parse(status);
-      setStatus(status);
-      if (status.turnstile_check) {
-        setTurnstileEnabled(true);
-        setTurnstileSiteKey(status.turnstile_site_key);
-      }
-    }
-  }, []);
-
-  const onWeChatLoginClicked = () => {
-    setShowWeChatLoginModal(true);
-  };
-
-  const onSubmitWeChatVerificationCode = async () => {
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    const res = await API.get(
-      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      userDispatch({ type: 'login', payload: data });
-      localStorage.setItem('user', JSON.stringify(data));
-      setUserData(data);
-      updateAPI();
-      navigate('/');
-      showSuccess('登录成功!');
-      setShowWeChatLoginModal(false);
-    } else {
-      showError(message);
-    }
-  };
-
-  function handleChange(name, value) {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  }
-
-  async function handleSubmit(e) {
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    setSubmitted(true);
-    if (username && password) {
-      const res = await API.post(
-        `/api/user/login?turnstile=${turnstileToken}`,
-        {
-          username,
-          password,
-        },
-      );
-      const { success, message, data } = res.data;
-      if (success) {
-        userDispatch({ type: 'login', payload: data });
-        setUserData(data);
-        updateAPI();
-        showSuccess('登录成功!');
-        if (username === 'root' && password === '123456') {
-          Modal.error({
-            title: '您正在使用默认密码!',
-            content: '请立刻修改默认密码!',
-            centered: true,
-          });
-        }
-        navigate('/token');
-      } else {
-        showError(message);
-      }
-    } else {
-      showError('请输入用户名和密码!');
-    }
-  }
-
-  // 添加Telegram登录处理函数
-  const onTelegramLoginClicked = async (response) => {
-    const fields = [
-      'id',
-      'first_name',
-      'last_name',
-      'username',
-      'photo_url',
-      'auth_date',
-      'hash',
-      'lang',
-    ];
-    const params = {};
-    fields.forEach((field) => {
-      if (response[field]) {
-        params[field] = response[field];
-      }
-    });
-    const res = await API.get(`/api/oauth/telegram/login`, { params });
-    const { success, message, data } = res.data;
-    if (success) {
-      userDispatch({ type: 'login', payload: data });
-      localStorage.setItem('user', JSON.stringify(data));
-      showSuccess('登录成功!');
-      setUserData(data);
-      updateAPI();
-      navigate('/');
-    } else {
-      showError(message);
-    }
-  };
-
-  return (
-    <div>
-      <Layout>
-        <Layout.Header></Layout.Header>
-        <Layout.Content>
-          <div
-            style={{
-              justifyContent: 'center',
-              display: 'flex',
-              marginTop: 120,
-            }}
-          >
-            <div style={{ width: 500 }}>
-              <Card>
-                <Title heading={2} style={{ textAlign: 'center' }}>
-                  {t('用户登录')}
-                </Title>
-                <Form>
-                  <Form.Input
-                    field={'username'}
-                    label={t('用户名/邮箱')}
-                    placeholder={t('用户名/邮箱')}
-                    name='username'
-                    onChange={(value) => handleChange('username', value)}
-                  />
-                  <Form.Input
-                    field={'password'}
-                    label={t('密码')}
-                    placeholder={t('密码')}
-                    name='password'
-                    type='password'
-                    onChange={(value) => handleChange('password', value)}
-                  />
-
-                  <Button
-                    theme='solid'
-                    style={{ width: '100%' }}
-                    type={'primary'}
-                    size='large'
-                    htmlType={'submit'}
-                    onClick={handleSubmit}
-                  >
-                    {t('登录')}
-                  </Button>
-                </Form>
-                <div
-                  style={{
-                    display: 'flex',
-                    justifyContent: 'space-between',
-                    marginTop: 20,
-                  }}
-                >
-                  <Text>
-                    {t('没有账户?')}{' '}
-                    <Link to='/register'>{t('点击注册')}</Link>
-                  </Text>
-                  <Text>
-                    {t('忘记密码?')} <Link to='/reset'>{t('点击重置')}</Link>
-                  </Text>
-                </div>
-                {status.github_oauth ||
-                status.oidc_enabled ||
-                status.wechat_login ||
-                status.telegram_oauth ||
-                status.linuxdo_oauth ? (
-                  <>
-                    <Divider margin='12px' align='center'>
-                      {t('第三方登录')}
-                    </Divider>
-                    <div
-                      style={{
-                        display: 'flex',
-                        justifyContent: 'center',
-                        marginTop: 20,
-                      }}
-                    >
-                      {status.github_oauth ? (
-                        <Button
-                          type='primary'
-                          icon={<IconGithubLogo />}
-                          onClick={() =>
-                            onGitHubOAuthClicked(status.github_client_id)
-                          }
-                        />
-                      ) : (
-                        <></>
-                      )}
-                      {status.oidc_enabled ? (
-                        <Button
-                          type='primary'
-                          icon={<OIDCIcon />}
-                          onClick={() =>
-                            onOIDCClicked(
-                              status.oidc_authorization_endpoint,
-                              status.oidc_client_id,
-                            )
-                          }
-                        />
-                      ) : (
-                        <></>
-                      )}
-                      {status.linuxdo_oauth ? (
-                        <Button
-                          icon={<LinuxDoIcon />}
-                          onClick={() =>
-                            onLinuxDOOAuthClicked(status.linuxdo_client_id)
-                          }
-                        />
-                      ) : (
-                        <></>
-                      )}
-                      {status.wechat_login ? (
-                        <Button
-                          type='primary'
-                          style={{ color: 'rgba(var(--semi-green-5), 1)' }}
-                          icon={<Icon svg={<WeChatIcon />} />}
-                          onClick={onWeChatLoginClicked}
-                        />
-                      ) : (
-                        <></>
-                      )}
-                    </div>
-                    {status.telegram_oauth ? (
-                      <>
-                        <div
-                          style={{
-                            display: 'flex',
-                            justifyContent: 'center',
-                            marginTop: 5,
-                          }}
-                        >
-                          <TelegramLoginButton
-                            dataOnauth={onTelegramLoginClicked}
-                            botName={status.telegram_bot_name}
-                          />
-                        </div>
-                      </>
-                    ) : (
-                      <></>
-                    )}
-                  </>
-                ) : (
-                  <></>
-                )}
-                <Modal
-                  title={t('微信扫码登录')}
-                  visible={showWeChatLoginModal}
-                  maskClosable={true}
-                  onOk={onSubmitWeChatVerificationCode}
-                  onCancel={() => setShowWeChatLoginModal(false)}
-                  okText={t('登录')}
-                  size={'small'}
-                  centered={true}
-                >
-                  <div
-                    style={{
-                      display: 'flex',
-                      alignItem: 'center',
-                      flexDirection: 'column',
-                    }}
-                  >
-                    <img src={status.wechat_qrcode} />
-                  </div>
-                  <div style={{ textAlign: 'center' }}>
-                    <p>
-                      {t(
-                        '微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)',
-                      )}
-                    </p>
-                  </div>
-                  <Form size='large'>
-                    <Form.Input
-                      field={'wechat_verification_code'}
-                      placeholder={t('验证码')}
-                      label={t('验证码')}
-                      value={inputs.wechat_verification_code}
-                      onChange={(value) =>
-                        handleChange('wechat_verification_code', value)
-                      }
-                    />
-                  </Form>
-                </Modal>
-              </Card>
-              {turnstileEnabled ? (
-                <div
-                  style={{
-                    display: 'flex',
-                    justifyContent: 'center',
-                    marginTop: 20,
-                  }}
-                >
-                  <Turnstile
-                    sitekey={turnstileSiteKey}
-                    onVerify={(token) => {
-                      setTurnstileToken(token);
-                    }}
-                  />
-                </div>
-              ) : (
-                <></>
-              )}
-            </div>
-          </div>
-        </Layout.Content>
-      </Layout>
-    </div>
-  );
-};
-
-export default LoginForm;

+ 0 - 660
web/src/components/MjLogsTable.js

@@ -1,660 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import {
-  API,
-  copy,
-  isAdmin,
-  showError,
-  showSuccess,
-  timestamp2string,
-} from '../helpers';
-
-import {
-  Banner,
-  Button,
-  Form,
-  ImagePreview,
-  Layout,
-  Modal,
-  Progress,
-  Table,
-  Tag,
-  Typography,
-} from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
-import { useTranslation } from 'react-i18next';
-
-const colors = [
-  'amber',
-  'blue',
-  'cyan',
-  'green',
-  'grey',
-  'indigo',
-  'light-blue',
-  'lime',
-  'orange',
-  'pink',
-  'purple',
-  'red',
-  'teal',
-  'violet',
-  'yellow',
-];
-
-const LogsTable = () => {
-  const { t } = useTranslation();
-  const [isModalOpen, setIsModalOpen] = useState(false);
-  const [modalContent, setModalContent] = useState('');
-  function renderType(type) {
-    switch (type) {
-      case 'IMAGINE':
-        return (
-          <Tag color='blue' size='large'>
-            {t('绘图')}
-          </Tag>
-        );
-      case 'UPSCALE':
-        return (
-          <Tag color='orange' size='large'>
-            {t('放大')}
-          </Tag>
-        );
-      case 'VARIATION':
-        return (
-          <Tag color='purple' size='large'>
-            {t('变换')}
-          </Tag>
-        );
-      case 'HIGH_VARIATION':
-        return (
-          <Tag color='purple' size='large'>
-            {t('强变换')}
-          </Tag>
-        );
-      case 'LOW_VARIATION':
-        return (
-          <Tag color='purple' size='large'>
-            {t('弱变换')}
-          </Tag>
-        );
-      case 'PAN':
-        return (
-          <Tag color='cyan' size='large'>
-            {t('平移')}
-          </Tag>
-        );
-      case 'DESCRIBE':
-        return (
-          <Tag color='yellow' size='large'>
-            {t('图生文')}
-          </Tag>
-        );
-      case 'BLEND':
-        return (
-          <Tag color='lime' size='large'>
-            {t('图混合')}
-          </Tag>
-        );
-      case 'UPLOAD':
-        return (
-          <Tag color='blue' size='large'>
-            上传文件
-          </Tag>
-        );
-      case 'SHORTEN':
-        return (
-          <Tag color='pink' size='large'>
-            {t('缩词')}
-          </Tag>
-        );
-      case 'REROLL':
-        return (
-          <Tag color='indigo' size='large'>
-            {t('重绘')}
-          </Tag>
-        );
-      case 'INPAINT':
-        return (
-          <Tag color='violet' size='large'>
-            {t('局部重绘-提交')}
-          </Tag>
-        );
-      case 'ZOOM':
-        return (
-          <Tag color='teal' size='large'>
-            {t('变焦')}
-          </Tag>
-        );
-      case 'CUSTOM_ZOOM':
-        return (
-          <Tag color='teal' size='large'>
-            {t('自定义变焦-提交')}
-          </Tag>
-        );
-      case 'MODAL':
-        return (
-          <Tag color='green' size='large'>
-            {t('窗口处理')}
-          </Tag>
-        );
-      case 'SWAP_FACE':
-        return (
-          <Tag color='light-green' size='large'>
-            {t('换脸')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' size='large'>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  function renderCode(code) {
-    switch (code) {
-      case 1:
-        return (
-          <Tag color='green' size='large'>
-            {t('已提交')}
-          </Tag>
-        );
-      case 21:
-        return (
-          <Tag color='lime' size='large'>
-            {t('等待中')}
-          </Tag>
-        );
-      case 22:
-        return (
-          <Tag color='orange' size='large'>
-            {t('重复提交')}
-          </Tag>
-        );
-      case 0:
-        return (
-          <Tag color='yellow' size='large'>
-            {t('未提交')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' size='large'>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  function renderStatus(type) {
-    switch (type) {
-      case 'SUCCESS':
-        return (
-          <Tag color='green' size='large'>
-            {t('成功')}
-          </Tag>
-        );
-      case 'NOT_START':
-        return (
-          <Tag color='grey' size='large'>
-            {t('未启动')}
-          </Tag>
-        );
-      case 'SUBMITTED':
-        return (
-          <Tag color='yellow' size='large'>
-            {t('队列中')}
-          </Tag>
-        );
-      case 'IN_PROGRESS':
-        return (
-          <Tag color='blue' size='large'>
-            {t('执行中')}
-          </Tag>
-        );
-      case 'FAILURE':
-        return (
-          <Tag color='red' size='large'>
-            {t('失败')}
-          </Tag>
-        );
-      case 'MODAL':
-        return (
-          <Tag color='yellow' size='large'>
-            {t('窗口等待')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='white' size='large'>
-            {t('未知')}
-          </Tag>
-        );
-    }
-  }
-
-  const renderTimestamp = (timestampInSeconds) => {
-    const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
-
-    const year = date.getFullYear(); // 获取年份
-    const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
-    const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
-    const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
-    const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
-    const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
-
-    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
-  };
-  // 修改renderDuration函数以包含颜色逻辑
-  function renderDuration(submit_time, finishTime) {
-    if (!submit_time || !finishTime) return 'N/A';
-
-    const start = new Date(submit_time);
-    const finish = new Date(finishTime);
-    const durationMs = finish - start;
-    const durationSec = (durationMs / 1000).toFixed(1);
-    const color = durationSec > 60 ? 'red' : 'green';
-
-    return (
-      <Tag color={color} size='large'>
-        {durationSec} {t('秒')}
-      </Tag>
-    );
-  }
-  const columns = [
-    {
-      title: t('提交时间'),
-      dataIndex: 'submit_time',
-      render: (text, record, index) => {
-        return <div>{renderTimestamp(text / 1000)}</div>;
-      },
-    },
-    {
-      title: t('花费时间'),
-      dataIndex: 'finish_time', // 以finish_time作为dataIndex
-      key: 'finish_time',
-      render: (finish, record) => {
-        // 假设record.start_time是存在的,并且finish是完成时间的时间戳
-        return renderDuration(record.submit_time, finish);
-      },
-    },
-    {
-      title: t('渠道'),
-      dataIndex: 'channel_id',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Tag
-              color={colors[parseInt(text) % colors.length]}
-              size='large'
-              onClick={() => {
-                copyText(text); // 假设copyText是用于文本复制的函数
-              }}
-            >
-              {' '}
-              {text}{' '}
-            </Tag>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('类型'),
-      dataIndex: 'action',
-      render: (text, record, index) => {
-        return <div>{renderType(text)}</div>;
-      },
-    },
-    {
-      title: t('任务ID'),
-      dataIndex: 'mj_id',
-      render: (text, record, index) => {
-        return <div>{text}</div>;
-      },
-    },
-    {
-      title: t('提交结果'),
-      dataIndex: 'code',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return <div>{renderCode(text)}</div>;
-      },
-    },
-    {
-      title: t('任务状态'),
-      dataIndex: 'status',
-      className: isAdmin() ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text)}</div>;
-      },
-    },
-    {
-      title: t('进度'),
-      dataIndex: 'progress',
-      render: (text, record, index) => {
-        return (
-          <div>
-            {
-              // 转换例如100%为数字100,如果text未定义,返回0
-              <Progress
-                stroke={
-                  record.status === 'FAILURE'
-                    ? 'var(--semi-color-warning)'
-                    : null
-                }
-                percent={text ? parseInt(text.replace('%', '')) : 0}
-                showInfo={true}
-                aria-label='drawing progress'
-              />
-            }
-          </div>
-        );
-      },
-    },
-    {
-      title: t('结果图片'),
-      dataIndex: 'image_url',
-      render: (text, record, index) => {
-        if (!text) {
-          return t('无');
-        }
-        return (
-          <Button
-            onClick={() => {
-              setModalImageUrl(text); // 更新图片URL状态
-              setIsModalOpenurl(true); // 打开模态框
-            }}
-          >
-            {t('查看图片')}
-          </Button>
-        );
-      },
-    },
-    {
-      title: 'Prompt',
-      dataIndex: 'prompt',
-      render: (text, record, index) => {
-        // 如果text未定义,返回替代文本,例如空字符串''或其他
-        if (!text) {
-          return t('无');
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-    {
-      title: 'PromptEn',
-      dataIndex: 'prompt_en',
-      render: (text, record, index) => {
-        // 如果text未定义,返回替代文本,例如空字符串''或其他
-        if (!text) {
-          return t('无');
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-    {
-      title: t('失败原因'),
-      dataIndex: 'fail_reason',
-      render: (text, record, index) => {
-        // 如果text未定义,返回替代文本,例如空字符串''或其他
-        if (!text) {
-          return t('无');
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-  ];
-
-  const [logs, setLogs] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
-  const [logType, setLogType] = useState(0);
-  const isAdminUser = isAdmin();
-  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
-  const [showBanner, setShowBanner] = useState(false);
-
-  // 定义模态框图片URL的状态和更新函数
-  const [modalImageUrl, setModalImageUrl] = useState('');
-  let now = new Date();
-  // 初始化start_timestamp为前一天
-  const [inputs, setInputs] = useState({
-    channel_id: '',
-    mj_id: '',
-    start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
-    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
-  });
-  const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
-
-  const [stat, setStat] = useState({
-    quota: 0,
-    token: 0,
-  });
-
-  const handleInputChange = (value, name) => {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
-
-  const setLogsFormat = (logs) => {
-    for (let i = 0; i < logs.length; i++) {
-      logs[i].timestamp2string = timestamp2string(logs[i].created_at);
-      logs[i].key = '' + logs[i].id;
-    }
-    // data.key = '' + data.id
-    setLogs(logs);
-    setLogCount(logs.length + ITEMS_PER_PAGE);
-    // console.log(logCount);
-  };
-
-  const loadLogs = async (startIdx) => {
-    setLoading(true);
-
-    let url = '';
-    let localStartTimestamp = Date.parse(start_timestamp);
-    let localEndTimestamp = Date.parse(end_timestamp);
-    if (isAdminUser) {
-      url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    } else {
-      url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    }
-    const res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      if (startIdx === 0) {
-        setLogsFormat(data);
-      } else {
-        let newLogs = [...logs];
-        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
-        setLogsFormat(newLogs);
-      }
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const pageData = logs.slice(
-    (activePage - 1) * ITEMS_PER_PAGE,
-    activePage * ITEMS_PER_PAGE,
-  );
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
-      // In this case we have to load more data and then append them.
-      loadLogs(page - 1).then((r) => {});
-    }
-  };
-
-  const refresh = async () => {
-    // setLoading(true);
-    setActivePage(1);
-    await loadLogs(0);
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess('已复制:' + text);
-    } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
-    }
-  };
-
-  useEffect(() => {
-    refresh().then();
-  }, [logType]);
-
-  useEffect(() => {
-    const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
-    if (mjNotifyEnabled !== 'true') {
-      setShowBanner(true);
-    }
-  }, []);
-
-  return (
-    <>
-      <Layout>
-        {isAdminUser && showBanner ? (
-          <Banner
-            type='info'
-            description={t(
-              '当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。',
-            )}
-          />
-        ) : (
-          <></>
-        )}
-        <Form layout='horizontal' style={{ marginTop: 10 }}>
-          <>
-            <Form.Input
-              field='channel_id'
-              label={t('渠道 ID')}
-              style={{ width: 176 }}
-              value={channel_id}
-              placeholder={t('可选值')}
-              name='channel_id'
-              onChange={(value) => handleInputChange(value, 'channel_id')}
-            />
-            <Form.Input
-              field='mj_id'
-              label={t('任务 ID')}
-              style={{ width: 176 }}
-              value={mj_id}
-              placeholder={t('可选值')}
-              name='mj_id'
-              onChange={(value) => handleInputChange(value, 'mj_id')}
-            />
-            <Form.DatePicker
-              field='start_timestamp'
-              label={t('起始时间')}
-              style={{ width: 272 }}
-              initValue={start_timestamp}
-              value={start_timestamp}
-              type='dateTime'
-              name='start_timestamp'
-              onChange={(value) => handleInputChange(value, 'start_timestamp')}
-            />
-            <Form.DatePicker
-              field='end_timestamp'
-              fluid
-              label={t('结束时间')}
-              style={{ width: 272 }}
-              initValue={end_timestamp}
-              value={end_timestamp}
-              type='dateTime'
-              name='end_timestamp'
-              onChange={(value) => handleInputChange(value, 'end_timestamp')}
-            />
-
-            <Form.Section>
-              <Button
-                label={t('查询')}
-                type='primary'
-                htmlType='submit'
-                className='btn-margin-right'
-                onClick={refresh}
-              >
-                {t('查询')}
-              </Button>
-            </Form.Section>
-          </>
-        </Form>
-        <Table
-          style={{ marginTop: 5 }}
-          columns={columns}
-          dataSource={pageData}
-          pagination={{
-            currentPage: activePage,
-            pageSize: ITEMS_PER_PAGE,
-            total: logCount,
-            pageSizeOpts: [10, 20, 50, 100],
-            onPageChange: handlePageChange,
-            formatPageText: (page) =>
-              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                start: page.currentStart,
-                end: page.currentEnd,
-                total: logCount,
-              }),
-          }}
-          loading={loading}
-        />
-        <Modal
-          visible={isModalOpen}
-          onOk={() => setIsModalOpen(false)}
-          onCancel={() => setIsModalOpen(false)}
-          closable={null}
-          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
-          width={800} // 设置模态框宽度
-        >
-          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
-        </Modal>
-        <ImagePreview
-          src={modalImageUrl}
-          visible={isModalOpenurl}
-          onVisibleChange={(visible) => setIsModalOpenurl(visible)}
-        />
-      </Layout>
-    </>
-  );
-};
-
-export default LogsTable;

+ 0 - 433
web/src/components/ModelPricing.js

@@ -1,433 +0,0 @@
-import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
-import { API, copy, showError, showInfo, showSuccess } from '../helpers';
-import { useTranslation } from 'react-i18next';
-
-import {
-  Banner,
-  Input,
-  Layout,
-  Modal,
-  Space,
-  Table,
-  Tag,
-  Tooltip,
-  Popover,
-  ImagePreview,
-  Button,
-} from '@douyinfe/semi-ui';
-import {
-  IconMore,
-  IconVerify,
-  IconUploadError,
-  IconHelpCircle,
-} from '@douyinfe/semi-icons';
-import { UserContext } from '../context/User/index.js';
-import Text from '@douyinfe/semi-ui/lib/es/typography/text';
-
-const ModelPricing = () => {
-  const { t } = useTranslation();
-  const [filteredValue, setFilteredValue] = useState([]);
-  const compositionRef = useRef({ isComposition: false });
-  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
-  const [modalImageUrl, setModalImageUrl] = useState('');
-  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
-  const [selectedGroup, setSelectedGroup] = useState('default');
-
-  const rowSelection = useMemo(
-    () => ({
-      onChange: (selectedRowKeys, selectedRows) => {
-        setSelectedRowKeys(selectedRowKeys);
-      },
-    }),
-    [],
-  );
-
-  const handleChange = (value) => {
-    if (compositionRef.current.isComposition) {
-      return;
-    }
-    const newFilteredValue = value ? [value] : [];
-    setFilteredValue(newFilteredValue);
-  };
-  const handleCompositionStart = () => {
-    compositionRef.current.isComposition = true;
-  };
-
-  const handleCompositionEnd = (event) => {
-    compositionRef.current.isComposition = false;
-    const value = event.target.value;
-    const newFilteredValue = value ? [value] : [];
-    setFilteredValue(newFilteredValue);
-  };
-
-  function renderQuotaType(type) {
-    // Ensure all cases are string literals by adding quotes.
-    switch (type) {
-      case 1:
-        return (
-          <Tag color='teal' size='large'>
-            {t('按次计费')}
-          </Tag>
-        );
-      case 0:
-        return (
-          <Tag color='violet' size='large'>
-            {t('按量计费')}
-          </Tag>
-        );
-      default:
-        return t('未知');
-    }
-  }
-
-  function renderAvailable(available) {
-    return available ? (
-      <Popover
-        content={
-          <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
-        }
-        position='top'
-        key={available}
-        style={{
-          backgroundColor: 'rgba(var(--semi-blue-4),1)',
-          borderColor: 'rgba(var(--semi-blue-4),1)',
-          color: 'var(--semi-color-white)',
-          borderWidth: 1,
-          borderStyle: 'solid',
-        }}
-      >
-        <IconVerify style={{ color: 'green' }} size='large' />
-      </Popover>
-    ) : null;
-  }
-
-  const columns = [
-    {
-      title: t('可用性'),
-      dataIndex: 'available',
-      render: (text, record, index) => {
-        // if record.enable_groups contains selectedGroup, then available is true
-        return renderAvailable(record.enable_groups.includes(selectedGroup));
-      },
-      sorter: (a, b) => {
-        const aAvailable = a.enable_groups.includes(selectedGroup);
-        const bAvailable = b.enable_groups.includes(selectedGroup);
-        return Number(aAvailable) - Number(bAvailable);
-      },
-      defaultSortOrder: 'descend',
-    },
-    {
-      title: t('模型名称'),
-      dataIndex: 'model_name',
-      render: (text, record, index) => {
-        return (
-          <>
-            <Tag
-              color='green'
-              size='large'
-              onClick={() => {
-                copyText(text);
-              }}
-            >
-              {text}
-            </Tag>
-          </>
-        );
-      },
-      onFilter: (value, record) =>
-        record.model_name.toLowerCase().includes(value.toLowerCase()),
-      filteredValue,
-    },
-    {
-      title: t('计费类型'),
-      dataIndex: 'quota_type',
-      render: (text, record, index) => {
-        return renderQuotaType(parseInt(text));
-      },
-      sorter: (a, b) => a.quota_type - b.quota_type,
-    },
-    {
-      title: t('可用分组'),
-      dataIndex: 'enable_groups',
-      render: (text, record, index) => {
-        // enable_groups is a string array
-        return (
-          <Space>
-            {text.map((group) => {
-              if (usableGroup[group]) {
-                if (group === selectedGroup) {
-                  return (
-                    <Tag color='blue' size='large' prefixIcon={<IconVerify />}>
-                      {group}
-                    </Tag>
-                  );
-                } else {
-                  return (
-                    <Tag
-                      color='blue'
-                      size='large'
-                      onClick={() => {
-                        setSelectedGroup(group);
-                        showInfo(
-                          t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
-                            group: group,
-                            ratio: groupRatio[group],
-                          }),
-                        );
-                      }}
-                    >
-                      {group}
-                    </Tag>
-                  );
-                }
-              }
-            })}
-          </Space>
-        );
-      },
-    },
-    {
-      title: () => (
-        <span style={{ display: 'flex', alignItems: 'center' }}>
-          {t('倍率')}
-          <Popover
-            content={
-              <div style={{ padding: 8 }}>
-                {t('倍率是为了方便换算不同价格的模型')}
-                <br />
-                {t('点击查看倍率说明')}
-              </div>
-            }
-            position='top'
-            style={{
-              backgroundColor: 'rgba(var(--semi-blue-4),1)',
-              borderColor: 'rgba(var(--semi-blue-4),1)',
-              color: 'var(--semi-color-white)',
-              borderWidth: 1,
-              borderStyle: 'solid',
-            }}
-          >
-            <IconHelpCircle
-              onClick={() => {
-                setModalImageUrl('/ratio.png');
-                setIsModalOpenurl(true);
-              }}
-            />
-          </Popover>
-        </span>
-      ),
-      dataIndex: 'model_ratio',
-      render: (text, record, index) => {
-        let content = text;
-        let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
-        content = (
-          <>
-            <Text>
-              {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
-            </Text>
-            <br />
-            <Text>
-              {t('补全倍率')}:
-              {record.quota_type === 0 ? completionRatio : t('无')}
-            </Text>
-            <br />
-            <Text>
-              {t('分组倍率')}:{groupRatio[selectedGroup]}
-            </Text>
-          </>
-        );
-        return <div>{content}</div>;
-      },
-    },
-    {
-      title: t('模型价格'),
-      dataIndex: 'model_price',
-      render: (text, record, index) => {
-        let content = text;
-        if (record.quota_type === 0) {
-          // 这里的 *2 是因为 1倍率=0.002刀,请勿删除
-          let inputRatioPrice =
-            record.model_ratio * 2 * groupRatio[selectedGroup];
-          let completionRatioPrice =
-            record.model_ratio *
-            record.completion_ratio *
-            2 *
-            groupRatio[selectedGroup];
-          content = (
-            <>
-              <Text>
-                {t('提示')} ${inputRatioPrice} / 1M tokens
-              </Text>
-              <br />
-              <Text>
-                {t('补全')} ${completionRatioPrice} / 1M tokens
-              </Text>
-            </>
-          );
-        } else {
-          let price = parseFloat(text) * groupRatio[selectedGroup];
-          content = (
-            <>
-              ${t('模型价格')}:${price}
-            </>
-          );
-        }
-        return <div>{content}</div>;
-      },
-    },
-  ];
-
-  const [models, setModels] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [userState, userDispatch] = useContext(UserContext);
-  const [groupRatio, setGroupRatio] = useState({});
-  const [usableGroup, setUsableGroup] = useState({});
-
-  const setModelsFormat = (models, groupRatio) => {
-    for (let i = 0; i < models.length; i++) {
-      models[i].key = models[i].model_name;
-      models[i].group_ratio = groupRatio[models[i].model_name];
-    }
-    // sort by quota_type
-    models.sort((a, b) => {
-      return a.quota_type - b.quota_type;
-    });
-
-    // sort by model_name, start with gpt is max, other use localeCompare
-    models.sort((a, b) => {
-      if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
-        return -1;
-      } else if (
-        !a.model_name.startsWith('gpt') &&
-        b.model_name.startsWith('gpt')
-      ) {
-        return 1;
-      } else {
-        return a.model_name.localeCompare(b.model_name);
-      }
-    });
-
-    setModels(models);
-  };
-
-  const loadPricing = async () => {
-    setLoading(true);
-
-    let url = '';
-    url = `/api/pricing`;
-    const res = await API.get(url);
-    const { success, message, data, group_ratio, usable_group } = res.data;
-    if (success) {
-      setGroupRatio(group_ratio);
-      setUsableGroup(usable_group);
-      setSelectedGroup(userState.user ? userState.user.group : 'default');
-      setModelsFormat(data, group_ratio);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const refresh = async () => {
-    await loadPricing();
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess('已复制:' + text);
-    } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
-    }
-  };
-
-  useEffect(() => {
-    refresh().then();
-  }, []);
-
-  return (
-    <>
-      <Layout>
-        {userState.user ? (
-          <Banner
-            type='success'
-            fullMode={false}
-            closeIcon='null'
-            description={t('您的默认分组为:{{group}},分组倍率为:{{ratio}}', {
-              group: userState.user.group,
-              ratio: groupRatio[userState.user.group],
-            })}
-          />
-        ) : (
-          <Banner
-            type='warning'
-            fullMode={false}
-            closeIcon='null'
-            description={t('您还未登陆,显示的价格为默认分组倍率: {{ratio}}', {
-              ratio: groupRatio['default'],
-            })}
-          />
-        )}
-        <br />
-        <Banner
-          type='info'
-          fullMode={false}
-          description={
-            <div>
-              {t(
-                '按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)',
-              )}
-            </div>
-          }
-          closeIcon='null'
-        />
-        <br />
-        <Space style={{ marginBottom: 16 }}>
-          <Input
-            placeholder={t('模糊搜索模型名称')}
-            style={{ width: 200 }}
-            onCompositionStart={handleCompositionStart}
-            onCompositionEnd={handleCompositionEnd}
-            onChange={handleChange}
-            showClear
-          />
-          <Button
-            theme='light'
-            type='tertiary'
-            style={{ width: 150 }}
-            onClick={() => {
-              copyText(selectedRowKeys);
-            }}
-            disabled={selectedRowKeys == ''}
-          >
-            {t('复制选中模型')}
-          </Button>
-        </Space>
-        <Table
-          style={{ marginTop: 5 }}
-          columns={columns}
-          dataSource={models}
-          loading={loading}
-          pagination={{
-            formatPageText: (page) =>
-              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-                start: page.currentStart,
-                end: page.currentEnd,
-                total: models.length,
-              }),
-            pageSize: models.length,
-            showSizeChanger: false,
-          }}
-          rowSelection={rowSelection}
-        />
-        <ImagePreview
-          src={modalImageUrl}
-          visible={isModalOpenurl}
-          onVisibleChange={(visible) => setIsModalOpenurl(visible)}
-        />
-      </Layout>
-    </>
-  );
-};
-
-export default ModelPricing;

+ 0 - 113
web/src/components/PasswordResetConfirm.js

@@ -1,113 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
-import { API, copy, showError, showNotice } from '../helpers';
-import { useSearchParams } from 'react-router-dom';
-
-const PasswordResetConfirm = () => {
-  const [inputs, setInputs] = useState({
-    email: '',
-    token: '',
-  });
-  const { email, token } = inputs;
-
-  const [loading, setLoading] = useState(false);
-
-  const [disableButton, setDisableButton] = useState(false);
-  const [countdown, setCountdown] = useState(30);
-
-  const [newPassword, setNewPassword] = useState('');
-
-  const [searchParams, setSearchParams] = useSearchParams();
-  useEffect(() => {
-    let token = searchParams.get('token');
-    let email = searchParams.get('email');
-    setInputs({
-      token,
-      email,
-    });
-  }, []);
-
-  useEffect(() => {
-    let countdownInterval = null;
-    if (disableButton && countdown > 0) {
-      countdownInterval = setInterval(() => {
-        setCountdown(countdown - 1);
-      }, 1000);
-    } else if (countdown === 0) {
-      setDisableButton(false);
-      setCountdown(30);
-    }
-    return () => clearInterval(countdownInterval);
-  }, [disableButton, countdown]);
-
-  async function handleSubmit(e) {
-    setDisableButton(true);
-    if (!email) return;
-    setLoading(true);
-    const res = await API.post(`/api/user/reset`, {
-      email,
-      token,
-    });
-    const { success, message } = res.data;
-    if (success) {
-      let password = res.data.data;
-      setNewPassword(password);
-      await copy(password);
-      showNotice(`新密码已复制到剪贴板:${password}`);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  }
-
-  return (
-    <Grid textAlign='center' style={{ marginTop: '48px' }}>
-      <Grid.Column style={{ maxWidth: 450 }}>
-        <Header as='h2' color='' textAlign='center'>
-          <Image src='/logo.png' /> 密码重置确认
-        </Header>
-        <Form size='large'>
-          <Segment>
-            <Form.Input
-              fluid
-              icon='mail'
-              iconPosition='left'
-              placeholder='邮箱地址'
-              name='email'
-              value={email}
-              readOnly
-            />
-            {newPassword && (
-              <Form.Input
-                fluid
-                icon='lock'
-                iconPosition='left'
-                placeholder='新密码'
-                name='newPassword'
-                value={newPassword}
-                readOnly
-                onClick={(e) => {
-                  e.target.select();
-                  navigator.clipboard.writeText(newPassword);
-                  showNotice(`密码已复制到剪贴板:${newPassword}`);
-                }}
-              />
-            )}
-            <Button
-              color='green'
-              fluid
-              size='large'
-              onClick={handleSubmit}
-              loading={loading}
-              disabled={disableButton}
-            >
-              {disableButton ? `密码重置完成` : '提交'}
-            </Button>
-          </Segment>
-        </Form>
-      </Grid.Column>
-    </Grid>
-  );
-};
-
-export default PasswordResetConfirm;

+ 0 - 102
web/src/components/PasswordResetForm.js

@@ -1,102 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
-import { API, showError, showInfo, showSuccess } from '../helpers';
-import Turnstile from 'react-turnstile';
-
-const PasswordResetForm = () => {
-  const [inputs, setInputs] = useState({
-    email: '',
-  });
-  const { email } = inputs;
-
-  const [loading, setLoading] = useState(false);
-  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-  const [turnstileToken, setTurnstileToken] = useState('');
-  const [disableButton, setDisableButton] = useState(false);
-  const [countdown, setCountdown] = useState(30);
-
-  useEffect(() => {
-    let countdownInterval = null;
-    if (disableButton && countdown > 0) {
-      countdownInterval = setInterval(() => {
-        setCountdown(countdown - 1);
-      }, 1000);
-    } else if (countdown === 0) {
-      setDisableButton(false);
-      setCountdown(30);
-    }
-    return () => clearInterval(countdownInterval);
-  }, [disableButton, countdown]);
-
-  function handleChange(e) {
-    const { name, value } = e.target;
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  }
-
-  async function handleSubmit(e) {
-    setDisableButton(true);
-    if (!email) return;
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    setLoading(true);
-    const res = await API.get(
-      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('重置邮件发送成功,请检查邮箱!');
-      setInputs({ ...inputs, email: '' });
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  }
-
-  return (
-    <Grid textAlign='center' style={{ marginTop: '48px' }}>
-      <Grid.Column style={{ maxWidth: 450 }}>
-        <Header as='h2' color='' textAlign='center'>
-          <Image src='/logo.png' /> 密码重置
-        </Header>
-        <Form size='large'>
-          <Segment>
-            <Form.Input
-              fluid
-              icon='mail'
-              iconPosition='left'
-              placeholder='邮箱地址'
-              name='email'
-              value={email}
-              onChange={handleChange}
-            />
-            {turnstileEnabled ? (
-              <Turnstile
-                sitekey={turnstileSiteKey}
-                onVerify={(token) => {
-                  setTurnstileToken(token);
-                }}
-              />
-            ) : (
-              <></>
-            )}
-            <Button
-              color='green'
-              fluid
-              size='large'
-              onClick={handleSubmit}
-              loading={loading}
-              disabled={disableButton}
-            >
-              {disableButton ? `重试 (${countdown})` : '提交'}
-            </Button>
-          </Segment>
-        </Form>
-      </Grid.Column>
-    </Grid>
-  );
-};
-
-export default PasswordResetForm;

+ 0 - 1193
web/src/components/PersonalSetting.js

@@ -1,1193 +0,0 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import {
-  API,
-  copy,
-  isRoot,
-  showError,
-  showInfo,
-  showSuccess,
-} from '../helpers';
-import Turnstile from 'react-turnstile';
-import { UserContext } from '../context/User';
-import {
-  onGitHubOAuthClicked,
-  onOIDCClicked,
-  onLinuxDOOAuthClicked,
-} from './utils';
-import {
-  Avatar,
-  Banner,
-  Button,
-  Card,
-  Descriptions,
-  Image,
-  Input,
-  InputNumber,
-  Layout,
-  Modal,
-  Space,
-  Tag,
-  Typography,
-  Collapsible,
-  Select,
-  Radio,
-  RadioGroup,
-  AutoComplete,
-  Checkbox,
-  Tabs,
-  TabPane,
-} from '@douyinfe/semi-ui';
-import {
-  getQuotaPerUnit,
-  renderQuota,
-  renderQuotaWithPrompt,
-  stringToColor,
-} from '../helpers/render';
-import TelegramLoginButton from 'react-telegram-login';
-import { useTranslation } from 'react-i18next';
-
-const PersonalSetting = () => {
-  const [userState, userDispatch] = useContext(UserContext);
-  let navigate = useNavigate();
-  const { t } = useTranslation();
-
-  const [inputs, setInputs] = useState({
-    wechat_verification_code: '',
-    email_verification_code: '',
-    email: '',
-    self_account_deletion_confirmation: '',
-    original_password: '',
-    set_new_password: '',
-    set_new_password_confirmation: '',
-  });
-  const [status, setStatus] = useState({});
-  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
-  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
-  const [showEmailBindModal, setShowEmailBindModal] = useState(false);
-  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
-  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-  const [turnstileToken, setTurnstileToken] = useState('');
-  const [loading, setLoading] = useState(false);
-  const [disableButton, setDisableButton] = useState(false);
-  const [countdown, setCountdown] = useState(30);
-  const [affLink, setAffLink] = useState('');
-  const [systemToken, setSystemToken] = useState('');
-  const [models, setModels] = useState([]);
-  const [openTransfer, setOpenTransfer] = useState(false);
-  const [transferAmount, setTransferAmount] = useState(0);
-  const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
-    // Initialize from localStorage if available
-    const savedState = localStorage.getItem('modelsExpanded');
-    return savedState ? JSON.parse(savedState) : false;
-  });
-  const MODELS_DISPLAY_COUNT = 10; // 默认显示的模型数量
-  const [notificationSettings, setNotificationSettings] = useState({
-    warningType: 'email',
-    warningThreshold: 100000,
-    webhookUrl: '',
-    webhookSecret: '',
-    notificationEmail: '',
-    acceptUnsetModelRatioModel: false,
-  });
-  const [showWebhookDocs, setShowWebhookDocs] = useState(false);
-
-  useEffect(() => {
-    let status = localStorage.getItem('status');
-    if (status) {
-      status = JSON.parse(status);
-      setStatus(status);
-      if (status.turnstile_check) {
-        setTurnstileEnabled(true);
-        setTurnstileSiteKey(status.turnstile_site_key);
-      }
-    }
-    getUserData().then((res) => {
-      console.log(userState);
-    });
-    loadModels().then();
-    getAffLink().then();
-    setTransferAmount(getQuotaPerUnit());
-  }, []);
-
-  useEffect(() => {
-    let countdownInterval = null;
-    if (disableButton && countdown > 0) {
-      countdownInterval = setInterval(() => {
-        setCountdown(countdown - 1);
-      }, 1000);
-    } else if (countdown === 0) {
-      setDisableButton(false);
-      setCountdown(30);
-    }
-    return () => clearInterval(countdownInterval); // Clean up on unmount
-  }, [disableButton, countdown]);
-
-  useEffect(() => {
-    if (userState?.user?.setting) {
-      const settings = JSON.parse(userState.user.setting);
-      setNotificationSettings({
-        warningType: settings.notify_type || 'email',
-        warningThreshold: settings.quota_warning_threshold || 500000,
-        webhookUrl: settings.webhook_url || '',
-        webhookSecret: settings.webhook_secret || '',
-        notificationEmail: settings.notification_email || '',
-        acceptUnsetModelRatioModel:
-          settings.accept_unset_model_ratio_model || false,
-      });
-    }
-  }, [userState?.user?.setting]);
-
-  // Save models expanded state to localStorage whenever it changes
-  useEffect(() => {
-    localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded));
-  }, [isModelsExpanded]);
-
-  const handleInputChange = (name, value) => {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
-
-  const generateAccessToken = async () => {
-    const res = await API.get('/api/user/token');
-    const { success, message, data } = res.data;
-    if (success) {
-      setSystemToken(data);
-      await copy(data);
-      showSuccess(t('令牌已重置并已复制到剪贴板'));
-    } else {
-      showError(message);
-    }
-  };
-
-  const getAffLink = async () => {
-    const res = await API.get('/api/user/aff');
-    const { success, message, data } = res.data;
-    if (success) {
-      let link = `${window.location.origin}/register?aff=${data}`;
-      setAffLink(link);
-    } else {
-      showError(message);
-    }
-  };
-
-  const getUserData = async () => {
-    let res = await API.get(`/api/user/self`);
-    const { success, message, data } = res.data;
-    if (success) {
-      userDispatch({ type: 'login', payload: data });
-    } else {
-      showError(message);
-    }
-  };
-
-  const loadModels = async () => {
-    let res = await API.get(`/api/user/models`);
-    const { success, message, data } = res.data;
-    if (success) {
-      if (data != null) {
-        setModels(data);
-      }
-    } else {
-      showError(message);
-    }
-  };
-
-  const handleAffLinkClick = async (e) => {
-    e.target.select();
-    await copy(e.target.value);
-    showSuccess(t('邀请链接已复制到剪切板'));
-  };
-
-  const handleSystemTokenClick = async (e) => {
-    e.target.select();
-    await copy(e.target.value);
-    showSuccess(t('系统令牌已复制到剪切板'));
-  };
-
-  const deleteAccount = async () => {
-    if (inputs.self_account_deletion_confirmation !== userState.user.username) {
-      showError(t('请输入你的账户名以确认删除!'));
-      return;
-    }
-
-    const res = await API.delete('/api/user/self');
-    const { success, message } = res.data;
-
-    if (success) {
-      showSuccess(t('账户已删除!'));
-      await API.get('/api/user/logout');
-      userDispatch({ type: 'logout' });
-      localStorage.removeItem('user');
-      navigate('/login');
-    } else {
-      showError(message);
-    }
-  };
-
-  const bindWeChat = async () => {
-    if (inputs.wechat_verification_code === '') return;
-    const res = await API.get(
-      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('微信账户绑定成功!'));
-      setShowWeChatBindModal(false);
-    } else {
-      showError(message);
-    }
-  };
-
-  const changePassword = async () => {
-    if (inputs.original_password === '') {
-      showError(t('请输入原密码!'));
-      return;
-    }
-    if (inputs.set_new_password === '') {
-      showError(t('请输入新密码!'));
-      return;
-    }
-    if (inputs.original_password === inputs.set_new_password) {
-      showError(t('新密码需要和原密码不一致!'));
-      return;
-    }
-    if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
-      showError(t('两次输入的密码不一致!'));
-      return;
-    }
-    const res = await API.put(`/api/user/self`, {
-      original_password: inputs.original_password,
-      password: inputs.set_new_password,
-    });
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('密码修改成功!'));
-      setShowWeChatBindModal(false);
-    } else {
-      showError(message);
-    }
-    setShowChangePasswordModal(false);
-  };
-
-  const transfer = async () => {
-    if (transferAmount < getQuotaPerUnit()) {
-      showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
-      return;
-    }
-    const res = await API.post(`/api/user/aff_transfer`, {
-      quota: transferAmount,
-    });
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(message);
-      setOpenTransfer(false);
-      getUserData().then();
-    } else {
-      showError(message);
-    }
-  };
-
-  const sendVerificationCode = async () => {
-    if (inputs.email === '') {
-      showError(t('请输入邮箱!'));
-      return;
-    }
-    setDisableButton(true);
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    setLoading(true);
-    const res = await API.get(
-      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('验证码发送成功,请检查邮箱!'));
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const bindEmail = async () => {
-    if (inputs.email_verification_code === '') {
-      showError(t('请输入邮箱验证码!'));
-      return;
-    }
-    setLoading(true);
-    const res = await API.get(
-      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess(t('邮箱账户绑定成功!'));
-      setShowEmailBindModal(false);
-      userState.user.email = inputs.email;
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const getUsername = () => {
-    if (userState.user) {
-      return userState.user.username;
-    } else {
-      return 'null';
-    }
-  };
-
-  const handleCancel = () => {
-    setOpenTransfer(false);
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制:') + text);
-    } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
-    }
-  };
-
-  const handleNotificationSettingChange = (type, value) => {
-    setNotificationSettings((prev) => ({
-      ...prev,
-      [type]: value.target ? value.target.value : value, // 处理 Radio 事件对象
-    }));
-  };
-
-  const saveNotificationSettings = async () => {
-    try {
-      const res = await API.put('/api/user/setting', {
-        notify_type: notificationSettings.warningType,
-        quota_warning_threshold: parseFloat(
-          notificationSettings.warningThreshold,
-        ),
-        webhook_url: notificationSettings.webhookUrl,
-        webhook_secret: notificationSettings.webhookSecret,
-        notification_email: notificationSettings.notificationEmail,
-        accept_unset_model_ratio_model:
-          notificationSettings.acceptUnsetModelRatioModel,
-      });
-
-      if (res.data.success) {
-        showSuccess(t('通知设置已更新'));
-        await getUserData();
-      } else {
-        showError(res.data.message);
-      }
-    } catch (error) {
-      showError(t('更新通知设置失败'));
-    }
-  };
-
-  return (
-    <div>
-      <Layout>
-        <Layout.Content>
-          <Modal
-            title={t('请输入要划转的数量')}
-            visible={openTransfer}
-            onOk={transfer}
-            onCancel={handleCancel}
-            maskClosable={false}
-            size={'small'}
-            centered={true}
-          >
-            <div style={{ marginTop: 20 }}>
-              <Typography.Text>
-                {t('可用额度')}
-                {renderQuotaWithPrompt(userState?.user?.aff_quota)}
-              </Typography.Text>
-              <Input
-                style={{ marginTop: 5 }}
-                value={userState?.user?.aff_quota}
-                disabled={true}
-              ></Input>
-            </div>
-            <div style={{ marginTop: 20 }}>
-              <Typography.Text>
-                {t('划转额度')}
-                {renderQuotaWithPrompt(transferAmount)}{' '}
-                {t('最低') + renderQuota(getQuotaPerUnit())}
-              </Typography.Text>
-              <div>
-                <InputNumber
-                  min={0}
-                  style={{ marginTop: 5 }}
-                  value={transferAmount}
-                  onChange={(value) => setTransferAmount(value)}
-                  disabled={false}
-                ></InputNumber>
-              </div>
-            </div>
-          </Modal>
-          <div>
-            <Card
-              title={
-                <Card.Meta
-                  avatar={
-                    <Avatar
-                      size='default'
-                      color={stringToColor(getUsername())}
-                      style={{ marginRight: 4 }}
-                    >
-                      {typeof getUsername() === 'string' &&
-                        getUsername().slice(0, 1)}
-                    </Avatar>
-                  }
-                  title={<Typography.Text>{getUsername()}</Typography.Text>}
-                  description={
-                    isRoot() ? (
-                      <Tag color='red'>{t('管理员')}</Tag>
-                    ) : (
-                      <Tag color='blue'>{t('普通用户')}</Tag>
-                    )
-                  }
-                ></Card.Meta>
-              }
-              headerExtraContent={
-                <>
-                  <Space vertical align='start'>
-                    <Tag color='green'>{'ID: ' + userState?.user?.id}</Tag>
-                    <Tag color='blue'>{userState?.user?.group}</Tag>
-                  </Space>
-                </>
-              }
-              footer={
-                <>
-                  <div
-                    style={{ display: 'flex', alignItems: 'center', gap: 8 }}
-                  >
-                    <Typography.Title heading={6}>
-                      {t('可用模型')}
-                    </Typography.Title>
-                  </div>
-                  <div style={{ marginTop: 10 }}>
-                    {models.length <= MODELS_DISPLAY_COUNT ? (
-                      <Space wrap>
-                        {models.map((model) => (
-                          <Tag
-                            key={model}
-                            color='cyan'
-                            onClick={() => {
-                              copyText(model);
-                            }}
-                          >
-                            {model}
-                          </Tag>
-                        ))}
-                      </Space>
-                    ) : (
-                      <>
-                        <Collapsible isOpen={isModelsExpanded}>
-                          <Space wrap>
-                            {models.map((model) => (
-                              <Tag
-                                key={model}
-                                color='cyan'
-                                onClick={() => {
-                                  copyText(model);
-                                }}
-                              >
-                                {model}
-                              </Tag>
-                            ))}
-                            <Tag
-                              color='blue'
-                              type='light'
-                              style={{ cursor: 'pointer' }}
-                              onClick={() => setIsModelsExpanded(false)}
-                            >
-                              {t('收起')}
-                            </Tag>
-                          </Space>
-                        </Collapsible>
-                        {!isModelsExpanded && (
-                          <Space wrap>
-                            {models
-                              .slice(0, MODELS_DISPLAY_COUNT)
-                              .map((model) => (
-                                <Tag
-                                  key={model}
-                                  color='cyan'
-                                  onClick={() => {
-                                    copyText(model);
-                                  }}
-                                >
-                                  {model}
-                                </Tag>
-                              ))}
-                            <Tag
-                              color='blue'
-                              type='light'
-                              style={{ cursor: 'pointer' }}
-                              onClick={() => setIsModelsExpanded(true)}
-                            >
-                              {t('更多')} {models.length - MODELS_DISPLAY_COUNT}{' '}
-                              {t('个模型')}
-                            </Tag>
-                          </Space>
-                        )}
-                      </>
-                    )}
-                  </div>
-                </>
-              }
-            >
-              <Descriptions row>
-                <Descriptions.Item itemKey={t('当前余额')}>
-                  {renderQuota(userState?.user?.quota)}
-                </Descriptions.Item>
-                <Descriptions.Item itemKey={t('历史消耗')}>
-                  {renderQuota(userState?.user?.used_quota)}
-                </Descriptions.Item>
-                <Descriptions.Item itemKey={t('请求次数')}>
-                  {userState.user?.request_count}
-                </Descriptions.Item>
-              </Descriptions>
-            </Card>
-            <Card
-              style={{ marginTop: 10 }}
-              footer={
-                <div>
-                  <Typography.Text>{t('邀请链接')}</Typography.Text>
-                  <Input
-                    style={{ marginTop: 10 }}
-                    value={affLink}
-                    onClick={handleAffLinkClick}
-                    readOnly
-                  />
-                </div>
-              }
-            >
-              <Typography.Title heading={6}>{t('邀请信息')}</Typography.Title>
-              <div style={{ marginTop: 10 }}>
-                <Descriptions row>
-                  <Descriptions.Item itemKey={t('待使用收益')}>
-                    <span style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
-                      {renderQuota(userState?.user?.aff_quota)}
-                    </span>
-                    <Button
-                      type={'secondary'}
-                      onClick={() => setOpenTransfer(true)}
-                      size={'small'}
-                      style={{ marginLeft: 10 }}
-                    >
-                      {t('划转')}
-                    </Button>
-                  </Descriptions.Item>
-                  <Descriptions.Item itemKey={t('总收益')}>
-                    {renderQuota(userState?.user?.aff_history_quota)}
-                  </Descriptions.Item>
-                  <Descriptions.Item itemKey={t('邀请人数')}>
-                    {userState?.user?.aff_count}
-                  </Descriptions.Item>
-                </Descriptions>
-              </div>
-            </Card>
-            <Card style={{ marginTop: 10 }}>
-              <Typography.Title heading={6}>{t('个人信息')}</Typography.Title>
-              <div style={{ marginTop: 20 }}>
-                <Typography.Text strong>{t('邮箱')}</Typography.Text>
-                <div
-                  style={{ display: 'flex', justifyContent: 'space-between' }}
-                >
-                  <div>
-                    <Input
-                      value={
-                        userState.user && userState.user.email !== ''
-                          ? userState.user.email
-                          : t('未绑定')
-                      }
-                      readonly={true}
-                    ></Input>
-                  </div>
-                  <div>
-                    <Button
-                      onClick={() => {
-                        setShowEmailBindModal(true);
-                      }}
-                    >
-                      {userState.user && userState.user.email !== ''
-                        ? t('修改绑定')
-                        : t('绑定邮箱')}
-                    </Button>
-                  </div>
-                </div>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>{t('微信')}</Typography.Text>
-                <div
-                  style={{ display: 'flex', justifyContent: 'space-between' }}
-                >
-                  <div>
-                    <Input
-                      value={
-                        userState.user && userState.user.wechat_id !== ''
-                          ? t('已绑定')
-                          : t('未绑定')
-                      }
-                      readonly={true}
-                    ></Input>
-                  </div>
-                  <div>
-                    <Button
-                      disabled={!status.wechat_login}
-                      onClick={() => {
-                        setShowWeChatBindModal(true);
-                      }}
-                    >
-                      {userState.user && userState.user.wechat_id !== ''
-                        ? t('修改绑定')
-                        : status.wechat_login
-                          ? t('绑定')
-                          : t('未启用')}
-                    </Button>
-                  </div>
-                </div>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>{t('GitHub')}</Typography.Text>
-                <div
-                  style={{ display: 'flex', justifyContent: 'space-between' }}
-                >
-                  <div>
-                    <Input
-                      value={
-                        userState.user && userState.user.github_id !== ''
-                          ? userState.user.github_id
-                          : t('未绑定')
-                      }
-                      readonly={true}
-                    ></Input>
-                  </div>
-                  <div>
-                    <Button
-                      onClick={() => {
-                        onGitHubOAuthClicked(status.github_client_id);
-                      }}
-                      disabled={
-                        (userState.user && userState.user.github_id !== '') ||
-                        !status.github_oauth
-                      }
-                    >
-                      {status.github_oauth ? t('绑定') : t('未启用')}
-                    </Button>
-                  </div>
-                </div>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>{t('OIDC')}</Typography.Text>
-                <div
-                  style={{ display: 'flex', justifyContent: 'space-between' }}
-                >
-                  <div>
-                    <Input
-                      value={
-                        userState.user && userState.user.oidc_id !== ''
-                          ? userState.user.oidc_id
-                          : t('未绑定')
-                      }
-                      readonly={true}
-                    ></Input>
-                  </div>
-                  <div>
-                    <Button
-                      onClick={() => {
-                        onOIDCClicked(
-                          status.oidc_authorization_endpoint,
-                          status.oidc_client_id,
-                        );
-                      }}
-                      disabled={
-                        (userState.user && userState.user.oidc_id !== '') ||
-                        !status.oidc_enabled
-                      }
-                    >
-                      {status.oidc_enabled ? t('绑定') : t('未启用')}
-                    </Button>
-                  </div>
-                </div>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>{t('Telegram')}</Typography.Text>
-                <div
-                  style={{ display: 'flex', justifyContent: 'space-between' }}
-                >
-                  <div>
-                    <Input
-                      value={
-                        userState.user && userState.user.telegram_id !== ''
-                          ? userState.user.telegram_id
-                          : t('未绑定')
-                      }
-                      readonly={true}
-                    ></Input>
-                  </div>
-                  <div>
-                    {status.telegram_oauth ? (
-                      userState.user.telegram_id !== '' ? (
-                        <Button disabled={true}>{t('已绑定')}</Button>
-                      ) : (
-                        <TelegramLoginButton
-                          dataAuthUrl='/api/oauth/telegram/bind'
-                          botName={status.telegram_bot_name}
-                        />
-                      )
-                    ) : (
-                      <Button disabled={true}>{t('未启用')}</Button>
-                    )}
-                  </div>
-                </div>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>{t('LinuxDO')}</Typography.Text>
-                <div
-                  style={{ display: 'flex', justifyContent: 'space-between' }}
-                >
-                  <div>
-                    <Input
-                      value={
-                        userState.user && userState.user.linux_do_id !== ''
-                          ? userState.user.linux_do_id
-                          : t('未绑定')
-                      }
-                      readonly={true}
-                    ></Input>
-                  </div>
-                  <div>
-                    <Button
-                      onClick={() => {
-                        onLinuxDOOAuthClicked(status.linuxdo_client_id);
-                      }}
-                      disabled={
-                        (userState.user && userState.user.linux_do_id !== '') ||
-                        !status.linuxdo_oauth
-                      }
-                    >
-                      {status.linuxdo_oauth ? t('绑定') : t('未启用')}
-                    </Button>
-                  </div>
-                </div>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Space>
-                  <Button onClick={generateAccessToken}>
-                    {t('生成系统访问令牌')}
-                  </Button>
-                  <Button
-                    onClick={() => {
-                      setShowChangePasswordModal(true);
-                    }}
-                  >
-                    {t('修改密码')}
-                  </Button>
-                  <Button
-                    type={'danger'}
-                    onClick={() => {
-                      setShowAccountDeleteModal(true);
-                    }}
-                  >
-                    {t('删除个人账户')}
-                  </Button>
-                </Space>
-
-                {systemToken && (
-                  <Input
-                    readOnly
-                    value={systemToken}
-                    onClick={handleSystemTokenClick}
-                    style={{ marginTop: '10px' }}
-                  />
-                )}
-                <Modal
-                  onCancel={() => setShowWeChatBindModal(false)}
-                  visible={showWeChatBindModal}
-                  size={'small'}
-                >
-                  <Image src={status.wechat_qrcode} />
-                  <div style={{ textAlign: 'center' }}>
-                    <p>
-                      微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
-                    </p>
-                  </div>
-                  <Input
-                    placeholder='验证码'
-                    name='wechat_verification_code'
-                    value={inputs.wechat_verification_code}
-                    onChange={(v) =>
-                      handleInputChange('wechat_verification_code', v)
-                    }
-                  />
-                  <Button color='' fluid size='large' onClick={bindWeChat}>
-                    {t('绑定')}
-                  </Button>
-                </Modal>
-              </div>
-            </Card>
-            <Card style={{ marginTop: 10 }}>
-              <Tabs type='line' defaultActiveKey='notification'>
-                <TabPane tab={t('通知设置')} itemKey='notification'>
-                  <div style={{ marginTop: 20 }}>
-                    <Typography.Text strong>{t('通知方式')}</Typography.Text>
-                    <div style={{ marginTop: 10 }}>
-                      <RadioGroup
-                        value={notificationSettings.warningType}
-                        onChange={(value) =>
-                          handleNotificationSettingChange('warningType', value)
-                        }
-                      >
-                        <Radio value='email'>{t('邮件通知')}</Radio>
-                        <Radio value='webhook'>{t('Webhook通知')}</Radio>
-                      </RadioGroup>
-                    </div>
-                  </div>
-                  {notificationSettings.warningType === 'webhook' && (
-                    <>
-                      <div style={{ marginTop: 20 }}>
-                        <Typography.Text strong>
-                          {t('Webhook地址')}
-                        </Typography.Text>
-                        <div style={{ marginTop: 10 }}>
-                          <Input
-                            value={notificationSettings.webhookUrl}
-                            onChange={(val) =>
-                              handleNotificationSettingChange('webhookUrl', val)
-                            }
-                            placeholder={t(
-                              '请输入Webhook地址,例如: https://example.com/webhook',
-                            )}
-                          />
-                          <Typography.Text
-                            type='secondary'
-                            style={{ marginTop: 8, display: 'block' }}
-                          >
-                            {t(
-                              '只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求',
-                            )}
-                          </Typography.Text>
-                          <Typography.Text
-                            type='secondary'
-                            style={{ marginTop: 8, display: 'block' }}
-                          >
-                            <div
-                              style={{ cursor: 'pointer' }}
-                              onClick={() =>
-                                setShowWebhookDocs(!showWebhookDocs)
-                              }
-                            >
-                              {t('Webhook请求结构')}{' '}
-                              {showWebhookDocs ? '▼' : '▶'}
-                            </div>
-                            <Collapsible isOpen={showWebhookDocs}>
-                              <pre
-                                style={{
-                                  marginTop: 4,
-                                  background: 'var(--semi-color-fill-0)',
-                                  padding: 8,
-                                  borderRadius: 4,
-                                }}
-                              >
-                                {`{
-    "type": "quota_exceed",      // 通知类型
-    "title": "标题",             // 通知标题
-    "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
-    "values": ["值1", "值2"],    // 按顺序替换content中的 {{value}} 占位符
-    "timestamp": 1739950503      // 时间戳
-}
-
-示例:
-{
-    "type": "quota_exceed",
-    "title": "额度预警通知",
-    "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
-    "values": ["$0.99"],
-    "timestamp": 1739950503
-}`}
-                              </pre>
-                            </Collapsible>
-                          </Typography.Text>
-                        </div>
-                      </div>
-                      <div style={{ marginTop: 20 }}>
-                        <Typography.Text strong>
-                          {t('接口凭证(可选)')}
-                        </Typography.Text>
-                        <div style={{ marginTop: 10 }}>
-                          <Input
-                            value={notificationSettings.webhookSecret}
-                            onChange={(val) =>
-                              handleNotificationSettingChange(
-                                'webhookSecret',
-                                val,
-                              )
-                            }
-                            placeholder={t('请输入密钥')}
-                          />
-                          <Typography.Text
-                            type='secondary'
-                            style={{ marginTop: 8, display: 'block' }}
-                          >
-                            {t(
-                              '密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性',
-                            )}
-                          </Typography.Text>
-                          <Typography.Text
-                            type='secondary'
-                            style={{ marginTop: 4, display: 'block' }}
-                          >
-                            {t('Authorization: Bearer your-secret-key')}
-                          </Typography.Text>
-                        </div>
-                      </div>
-                    </>
-                  )}
-                  {notificationSettings.warningType === 'email' && (
-                    <div style={{ marginTop: 20 }}>
-                      <Typography.Text strong>{t('通知邮箱')}</Typography.Text>
-                      <div style={{ marginTop: 10 }}>
-                        <Input
-                          value={notificationSettings.notificationEmail}
-                          onChange={(val) =>
-                            handleNotificationSettingChange(
-                              'notificationEmail',
-                              val,
-                            )
-                          }
-                          placeholder={t('留空则使用账号绑定的邮箱')}
-                        />
-                        <Typography.Text
-                          type='secondary'
-                          style={{ marginTop: 8, display: 'block' }}
-                        >
-                          {t(
-                            '设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱',
-                          )}
-                        </Typography.Text>
-                      </div>
-                    </div>
-                  )}
-                  <div style={{ marginTop: 20 }}>
-                    <Typography.Text strong>
-                      {t('额度预警阈值')}{' '}
-                      {renderQuotaWithPrompt(
-                        notificationSettings.warningThreshold,
-                      )}
-                    </Typography.Text>
-                    <div style={{ marginTop: 10 }}>
-                      <AutoComplete
-                        value={notificationSettings.warningThreshold}
-                        onChange={(val) =>
-                          handleNotificationSettingChange(
-                            'warningThreshold',
-                            val,
-                          )
-                        }
-                        style={{ width: 200 }}
-                        placeholder={t('请输入预警额度')}
-                        data={[
-                          { value: 100000, label: '0.2$' },
-                          { value: 500000, label: '1$' },
-                          { value: 1000000, label: '5$' },
-                          { value: 5000000, label: '10$' },
-                        ]}
-                      />
-                    </div>
-                    <Typography.Text
-                      type='secondary'
-                      style={{ marginTop: 10, display: 'block' }}
-                    >
-                      {t(
-                        '当剩余额度低于此数值时,系统将通过选择的方式发送通知',
-                      )}
-                    </Typography.Text>
-                  </div>
-                </TabPane>
-                <TabPane tab={t('价格设置')} itemKey='price'>
-                  <div style={{ marginTop: 20 }}>
-                    <Typography.Text strong>
-                      {t('接受未设置价格模型')}
-                    </Typography.Text>
-                    <div style={{ marginTop: 10 }}>
-                      <Checkbox
-                        checked={
-                          notificationSettings.acceptUnsetModelRatioModel
-                        }
-                        onChange={(e) =>
-                          handleNotificationSettingChange(
-                            'acceptUnsetModelRatioModel',
-                            e.target.checked,
-                          )
-                        }
-                      >
-                        {t('接受未设置价格模型')}
-                      </Checkbox>
-                      <Typography.Text
-                        type='secondary'
-                        style={{ marginTop: 8, display: 'block' }}
-                      >
-                        {t(
-                          '当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用',
-                        )}
-                      </Typography.Text>
-                    </div>
-                  </div>
-                </TabPane>
-              </Tabs>
-              <div style={{ marginTop: 20 }}>
-                <Button type='primary' onClick={saveNotificationSettings}>
-                  {t('保存设置')}
-                </Button>
-              </div>
-            </Card>
-            <Modal
-              onCancel={() => setShowEmailBindModal(false)}
-              onOk={bindEmail}
-              visible={showEmailBindModal}
-              size={'small'}
-              centered={true}
-              maskClosable={false}
-            >
-              <Typography.Title heading={6}>
-                {t('绑定邮箱地址')}
-              </Typography.Title>
-              <div
-                style={{
-                  marginTop: 20,
-                  display: 'flex',
-                  justifyContent: 'space-between',
-                }}
-              >
-                <Input
-                  fluid
-                  placeholder='输入邮箱地址'
-                  onChange={(value) => handleInputChange('email', value)}
-                  name='email'
-                  type='email'
-                />
-                <Button
-                  onClick={sendVerificationCode}
-                  disabled={disableButton || loading}
-                >
-                  {disableButton ? `重新发送 (${countdown})` : '获取验证码'}
-                </Button>
-              </div>
-              <div style={{ marginTop: 10 }}>
-                <Input
-                  fluid
-                  placeholder='验证码'
-                  name='email_verification_code'
-                  value={inputs.email_verification_code}
-                  onChange={(value) =>
-                    handleInputChange('email_verification_code', value)
-                  }
-                />
-              </div>
-              {turnstileEnabled ? (
-                <Turnstile
-                  sitekey={turnstileSiteKey}
-                  onVerify={(token) => {
-                    setTurnstileToken(token);
-                  }}
-                />
-              ) : (
-                <></>
-              )}
-            </Modal>
-            <Modal
-              onCancel={() => setShowAccountDeleteModal(false)}
-              visible={showAccountDeleteModal}
-              size={'small'}
-              centered={true}
-              onOk={deleteAccount}
-            >
-              <div style={{ marginTop: 20 }}>
-                <Banner
-                  type='danger'
-                  description='您正在删除自己的帐户,将清空所有数据且不可恢复'
-                  closeIcon={null}
-                />
-              </div>
-              <div style={{ marginTop: 20 }}>
-                <Input
-                  placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
-                  name='self_account_deletion_confirmation'
-                  value={inputs.self_account_deletion_confirmation}
-                  onChange={(value) =>
-                    handleInputChange(
-                      'self_account_deletion_confirmation',
-                      value,
-                    )
-                  }
-                />
-                {turnstileEnabled ? (
-                  <Turnstile
-                    sitekey={turnstileSiteKey}
-                    onVerify={(token) => {
-                      setTurnstileToken(token);
-                    }}
-                  />
-                ) : (
-                  <></>
-                )}
-              </div>
-            </Modal>
-            <Modal
-              onCancel={() => setShowChangePasswordModal(false)}
-              visible={showChangePasswordModal}
-              size={'small'}
-              centered={true}
-              onOk={changePassword}
-            >
-              <div style={{ marginTop: 20 }}>
-                <Input
-                  name='original_password'
-                  placeholder={t('原密码')}
-                  type='password'
-                  value={inputs.original_password}
-                  onChange={(value) =>
-                    handleInputChange('original_password', value)
-                  }
-                />
-                <Input
-                  style={{ marginTop: 20 }}
-                  name='set_new_password'
-                  placeholder={t('新密码')}
-                  value={inputs.set_new_password}
-                  onChange={(value) =>
-                    handleInputChange('set_new_password', value)
-                  }
-                />
-                <Input
-                  style={{ marginTop: 20 }}
-                  name='set_new_password_confirmation'
-                  placeholder={t('确认新密码')}
-                  value={inputs.set_new_password_confirmation}
-                  onChange={(value) =>
-                    handleInputChange('set_new_password_confirmation', value)
-                  }
-                />
-                {turnstileEnabled ? (
-                  <Turnstile
-                    sitekey={turnstileSiteKey}
-                    onVerify={(token) => {
-                      setTurnstileToken(token);
-                    }}
-                  />
-                ) : (
-                  <></>
-                )}
-              </div>
-            </Modal>
-          </div>
-        </Layout.Content>
-      </Layout>
-    </div>
-  );
-};
-
-export default PersonalSetting;

+ 0 - 12
web/src/components/PrivateRoute.js

@@ -1,12 +0,0 @@
-import { Navigate } from 'react-router-dom';
-
-import { history } from '../helpers';
-
-function PrivateRoute({ children }) {
-  if (!localStorage.getItem('user')) {
-    return <Navigate to='/login' state={{ from: history.location }} />;
-  }
-  return children;
-}
-
-export { PrivateRoute };

+ 0 - 434
web/src/components/RegisterForm.js

@@ -1,434 +0,0 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import {
-  API,
-  getLogo,
-  showError,
-  showInfo,
-  showSuccess,
-  updateAPI,
-} from '../helpers';
-import Turnstile from 'react-turnstile';
-import {
-  Button,
-  Card,
-  Divider,
-  Form,
-  Icon,
-  Layout,
-  Modal,
-} from '@douyinfe/semi-ui';
-import Title from '@douyinfe/semi-ui/lib/es/typography/title';
-import Text from '@douyinfe/semi-ui/lib/es/typography/text';
-import { IconGithubLogo } from '@douyinfe/semi-icons';
-import {
-  onGitHubOAuthClicked,
-  onLinuxDOOAuthClicked,
-  onOIDCClicked,
-} from './utils.js';
-import OIDCIcon from './OIDCIcon.js';
-import LinuxDoIcon from './LinuxDoIcon.js';
-import WeChatIcon from './WeChatIcon.js';
-import TelegramLoginButton from 'react-telegram-login/src';
-import { setUserData } from '../helpers/data.js';
-import { UserContext } from '../context/User/index.js';
-import { useTranslation } from 'react-i18next';
-
-const RegisterForm = () => {
-  const { t } = useTranslation();
-  const [inputs, setInputs] = useState({
-    username: '',
-    password: '',
-    password2: '',
-    email: '',
-    verification_code: '',
-  });
-  const { username, password, password2 } = inputs;
-  const [showEmailVerification, setShowEmailVerification] = useState(false);
-  const [userState, userDispatch] = useContext(UserContext);
-  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-  const [turnstileToken, setTurnstileToken] = useState('');
-  const [loading, setLoading] = useState(false);
-  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
-  const [status, setStatus] = useState({});
-  let navigate = useNavigate();
-  const logo = getLogo();
-
-  let affCode = new URLSearchParams(window.location.search).get('aff');
-  if (affCode) {
-    localStorage.setItem('aff', affCode);
-  }
-
-  useEffect(() => {
-    let status = localStorage.getItem('status');
-    if (status) {
-      status = JSON.parse(status);
-      setStatus(status);
-      setShowEmailVerification(status.email_verification);
-      if (status.turnstile_check) {
-        setTurnstileEnabled(true);
-        setTurnstileSiteKey(status.turnstile_site_key);
-      }
-    }
-  });
-
-  const onWeChatLoginClicked = () => {
-    setShowWeChatLoginModal(true);
-  };
-
-  const onSubmitWeChatVerificationCode = async () => {
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    const res = await API.get(
-      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      userDispatch({ type: 'login', payload: data });
-      localStorage.setItem('user', JSON.stringify(data));
-      setUserData(data);
-      updateAPI();
-      navigate('/');
-      showSuccess('登录成功!');
-      setShowWeChatLoginModal(false);
-    } else {
-      showError(message);
-    }
-  };
-
-  function handleChange(name, value) {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  }
-
-  async function handleSubmit(e) {
-    if (password.length < 8) {
-      showInfo('密码长度不得小于 8 位!');
-      return;
-    }
-    if (password !== password2) {
-      showInfo('两次输入的密码不一致');
-      return;
-    }
-    if (username && password) {
-      if (turnstileEnabled && turnstileToken === '') {
-        showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-        return;
-      }
-      setLoading(true);
-      if (!affCode) {
-        affCode = localStorage.getItem('aff');
-      }
-      inputs.aff_code = affCode;
-      const res = await API.post(
-        `/api/user/register?turnstile=${turnstileToken}`,
-        inputs,
-      );
-      const { success, message } = res.data;
-      if (success) {
-        navigate('/login');
-        showSuccess('注册成功!');
-      } else {
-        showError(message);
-      }
-      setLoading(false);
-    }
-  }
-
-  const sendVerificationCode = async () => {
-    if (inputs.email === '') return;
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    setLoading(true);
-    const res = await API.get(
-      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('验证码发送成功,请检查你的邮箱!');
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const onTelegramLoginClicked = async (response) => {
-    const fields = [
-      'id',
-      'first_name',
-      'last_name',
-      'username',
-      'photo_url',
-      'auth_date',
-      'hash',
-      'lang',
-    ];
-    const params = {};
-    fields.forEach((field) => {
-      if (response[field]) {
-        params[field] = response[field];
-      }
-    });
-    const res = await API.get(`/api/oauth/telegram/login`, { params });
-    const { success, message, data } = res.data;
-    if (success) {
-      userDispatch({ type: 'login', payload: data });
-      localStorage.setItem('user', JSON.stringify(data));
-      showSuccess('登录成功!');
-      setUserData(data);
-      updateAPI();
-      navigate('/');
-    } else {
-      showError(message);
-    }
-  };
-
-  return (
-    <div>
-      <Layout>
-        <Layout.Header></Layout.Header>
-        <Layout.Content>
-          <div
-            style={{
-              justifyContent: 'center',
-              display: 'flex',
-              marginTop: 120,
-            }}
-          >
-            <div style={{ width: 500 }}>
-              <Card>
-                <Title heading={2} style={{ textAlign: 'center' }}>
-                  {t('新用户注册')}
-                </Title>
-                <Form size='large'>
-                  <Form.Input
-                    field={'username'}
-                    label={t('用户名')}
-                    placeholder={t('用户名')}
-                    name='username'
-                    onChange={(value) => handleChange('username', value)}
-                  />
-                  <Form.Input
-                    field={'password'}
-                    label={t('密码')}
-                    placeholder={t('输入密码,最短 8 位,最长 20 位')}
-                    name='password'
-                    type='password'
-                    onChange={(value) => handleChange('password', value)}
-                  />
-                  <Form.Input
-                    field={'password2'}
-                    label={t('确认密码')}
-                    placeholder={t('确认密码')}
-                    name='password2'
-                    type='password'
-                    onChange={(value) => handleChange('password2', value)}
-                  />
-                  {showEmailVerification ? (
-                    <>
-                      <Form.Input
-                        field={'email'}
-                        label={t('邮箱')}
-                        placeholder={t('输入邮箱地址')}
-                        onChange={(value) => handleChange('email', value)}
-                        name='email'
-                        type='email'
-                        suffix={
-                          <Button
-                            onClick={sendVerificationCode}
-                            disabled={loading}
-                          >
-                            {t('获取验证码')}
-                          </Button>
-                        }
-                      />
-                      <Form.Input
-                        field={'verification_code'}
-                        label={t('验证码')}
-                        placeholder={t('输入验证码')}
-                        onChange={(value) =>
-                          handleChange('verification_code', value)
-                        }
-                        name='verification_code'
-                      />
-                    </>
-                  ) : (
-                    <></>
-                  )}
-                  <Button
-                    theme='solid'
-                    style={{ width: '100%' }}
-                    type={'primary'}
-                    size='large'
-                    htmlType={'submit'}
-                    onClick={handleSubmit}
-                  >
-                    {t('注册')}
-                  </Button>
-                </Form>
-                <div
-                  style={{
-                    display: 'flex',
-                    justifyContent: 'space-between',
-                    marginTop: 20,
-                  }}
-                >
-                  <Text>
-                    {t('已有账户?')}
-                    <Link to='/login'>{t('点击登录')}</Link>
-                  </Text>
-                </div>
-                {status.github_oauth ||
-                status.oidc_enabled ||
-                status.wechat_login ||
-                status.telegram_oauth ||
-                status.linuxdo_oauth ? (
-                  <>
-                    <Divider margin='12px' align='center'>
-                      {t('第三方登录')}
-                    </Divider>
-                    <div
-                      style={{
-                        display: 'flex',
-                        justifyContent: 'center',
-                        marginTop: 20,
-                      }}
-                    >
-                      {status.github_oauth ? (
-                        <Button
-                          type='primary'
-                          icon={<IconGithubLogo />}
-                          onClick={() =>
-                            onGitHubOAuthClicked(status.github_client_id)
-                          }
-                        />
-                      ) : (
-                        <></>
-                      )}
-                      {status.oidc_enabled ? (
-                        <Button
-                          type='primary'
-                          icon={<OIDCIcon />}
-                          onClick={() =>
-                            onOIDCClicked(
-                              status.oidc_authorization_endpoint,
-                              status.oidc_client_id,
-                            )
-                          }
-                        />
-                      ) : (
-                        <></>
-                      )}
-                      {status.linuxdo_oauth ? (
-                        <Button
-                          icon={<LinuxDoIcon />}
-                          onClick={() =>
-                            onLinuxDOOAuthClicked(status.linuxdo_client_id)
-                          }
-                        />
-                      ) : (
-                        <></>
-                      )}
-                      {status.wechat_login ? (
-                        <Button
-                          type='primary'
-                          style={{ color: 'rgba(var(--semi-green-5), 1)' }}
-                          icon={<Icon svg={<WeChatIcon />} />}
-                          onClick={onWeChatLoginClicked}
-                        />
-                      ) : (
-                        <></>
-                      )}
-                    </div>
-                    {status.telegram_oauth ? (
-                      <>
-                        <div
-                          style={{
-                            display: 'flex',
-                            justifyContent: 'center',
-                            marginTop: 5,
-                          }}
-                        >
-                          <TelegramLoginButton
-                            dataOnauth={onTelegramLoginClicked}
-                            botName={status.telegram_bot_name}
-                          />
-                        </div>
-                      </>
-                    ) : (
-                      <></>
-                    )}
-                  </>
-                ) : (
-                  <></>
-                )}
-              </Card>
-              <Modal
-                title={t('微信扫码登录')}
-                visible={showWeChatLoginModal}
-                maskClosable={true}
-                onOk={onSubmitWeChatVerificationCode}
-                onCancel={() => setShowWeChatLoginModal(false)}
-                okText={t('登录')}
-                size={'small'}
-                centered={true}
-              >
-                <div
-                  style={{
-                    display: 'flex',
-                    alignItem: 'center',
-                    flexDirection: 'column',
-                  }}
-                >
-                  <img src={status.wechat_qrcode} />
-                </div>
-                <div style={{ textAlign: 'center' }}>
-                  <p>
-                    {t(
-                      '微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)',
-                    )}
-                  </p>
-                </div>
-                <Form size='large'>
-                  <Form.Input
-                    field={'wechat_verification_code'}
-                    placeholder={t('验证码')}
-                    label={t('验证码')}
-                    value={inputs.wechat_verification_code}
-                    onChange={(value) =>
-                      handleChange('wechat_verification_code', value)
-                    }
-                  />
-                </Form>
-              </Modal>
-              {turnstileEnabled ? (
-                <div
-                  style={{
-                    display: 'flex',
-                    justifyContent: 'center',
-                    marginTop: 20,
-                  }}
-                >
-                  <Turnstile
-                    sitekey={turnstileSiteKey}
-                    onVerify={(token) => {
-                      setTurnstileToken(token);
-                    }}
-                  />
-                </div>
-              ) : (
-                <></>
-              )}
-            </div>
-          </div>
-        </Layout.Content>
-      </Layout>
-    </div>
-  );
-};
-
-export default RegisterForm;

+ 0 - 535
web/src/components/SiderBar.js

@@ -1,535 +0,0 @@
-import React, { useContext, useEffect, useMemo, useState } from 'react';
-import { Link, useNavigate, useLocation } from 'react-router-dom';
-import { UserContext } from '../context/User';
-import { StatusContext } from '../context/Status';
-import { useTranslation } from 'react-i18next';
-
-import {
-  API,
-  getLogo,
-  getSystemName,
-  isAdmin,
-  isMobile,
-  showError,
-} from '../helpers';
-import '../index.css';
-
-import {
-  IconCalendarClock,
-  IconChecklistStroked,
-  IconComment,
-  IconCommentStroked,
-  IconCreditCard,
-  IconGift,
-  IconHelpCircle,
-  IconHistogram,
-  IconHome,
-  IconImage,
-  IconKey,
-  IconLayers,
-  IconPriceTag,
-  IconSetting,
-  IconUser,
-} from '@douyinfe/semi-icons';
-import {
-  Avatar,
-  Dropdown,
-  Layout,
-  Nav,
-  Switch,
-  Divider,
-} from '@douyinfe/semi-ui';
-import { setStatusData } from '../helpers/data.js';
-import { stringToColor } from '../helpers/render.js';
-import { useSetTheme, useTheme } from '../context/Theme/index.js';
-import { StyleContext } from '../context/Style/index.js';
-import Text from '@douyinfe/semi-ui/lib/es/typography/text';
-
-// 自定义侧边栏按钮样式
-const navItemStyle = {
-  borderRadius: '6px',
-  margin: '4px 8px',
-};
-
-// 自定义侧边栏按钮悬停样式
-const navItemHoverStyle = {
-  backgroundColor: 'var(--semi-color-primary-light-default)',
-  color: 'var(--semi-color-primary)',
-};
-
-// 自定义侧边栏按钮选中样式
-const navItemSelectedStyle = {
-  backgroundColor: 'var(--semi-color-primary-light-default)',
-  color: 'var(--semi-color-primary)',
-  fontWeight: '600',
-};
-
-// 自定义图标样式
-const iconStyle = (itemKey, selectedKeys) => {
-  return {
-    fontSize: '18px',
-    color: selectedKeys.includes(itemKey)
-      ? 'var(--semi-color-primary)'
-      : 'var(--semi-color-text-2)',
-  };
-};
-
-// Define routerMap as a constant outside the component
-const routerMap = {
-  home: '/',
-  channel: '/channel',
-  token: '/token',
-  redemption: '/redemption',
-  topup: '/topup',
-  user: '/user',
-  log: '/log',
-  midjourney: '/midjourney',
-  setting: '/setting',
-  about: '/about',
-  detail: '/detail',
-  pricing: '/pricing',
-  task: '/task',
-  playground: '/playground',
-  personal: '/personal',
-};
-
-const SiderBar = () => {
-  const { t } = useTranslation();
-  const [styleState, styleDispatch] = useContext(StyleContext);
-  const [statusState, statusDispatch] = useContext(StatusContext);
-  const defaultIsCollapsed =
-    localStorage.getItem('default_collapse_sidebar') === 'true';
-
-  const [selectedKeys, setSelectedKeys] = useState(['home']);
-  const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
-  const [chatItems, setChatItems] = useState([]);
-  const [openedKeys, setOpenedKeys] = useState([]);
-  const theme = useTheme();
-  const setTheme = useSetTheme();
-  const location = useLocation();
-  const [routerMapState, setRouterMapState] = useState(routerMap);
-
-  // 预先计算所有可能的图标样式
-  const allItemKeys = useMemo(() => {
-    const keys = [
-      'home',
-      'channel',
-      'token',
-      'redemption',
-      'topup',
-      'user',
-      'log',
-      'midjourney',
-      'setting',
-      'about',
-      'chat',
-      'detail',
-      'pricing',
-      'task',
-      'playground',
-      'personal',
-    ];
-    // 添加聊天项的keys
-    for (let i = 0; i < chatItems.length; i++) {
-      keys.push('chat' + i);
-    }
-    return keys;
-  }, [chatItems]);
-
-  // 使用useMemo一次性计算所有图标样式
-  const iconStyles = useMemo(() => {
-    const styles = {};
-    allItemKeys.forEach((key) => {
-      styles[key] = iconStyle(key, selectedKeys);
-    });
-    return styles;
-  }, [allItemKeys, selectedKeys]);
-
-  const workspaceItems = useMemo(
-    () => [
-      {
-        text: t('数据看板'),
-        itemKey: 'detail',
-        to: '/detail',
-        icon: <IconCalendarClock />,
-        className:
-          localStorage.getItem('enable_data_export') === 'true'
-            ? ''
-            : 'tableHiddle',
-      },
-      {
-        text: t('API令牌'),
-        itemKey: 'token',
-        to: '/token',
-        icon: <IconKey />,
-      },
-      {
-        text: t('使用日志'),
-        itemKey: 'log',
-        to: '/log',
-        icon: <IconHistogram />,
-      },
-      {
-        text: t('绘图日志'),
-        itemKey: 'midjourney',
-        to: '/midjourney',
-        icon: <IconImage />,
-        className:
-          localStorage.getItem('enable_drawing') === 'true'
-            ? ''
-            : 'tableHiddle',
-      },
-      {
-        text: t('任务日志'),
-        itemKey: 'task',
-        to: '/task',
-        icon: <IconChecklistStroked />,
-        className:
-          localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
-      },
-    ],
-    [
-      localStorage.getItem('enable_data_export'),
-      localStorage.getItem('enable_drawing'),
-      localStorage.getItem('enable_task'),
-      t,
-    ],
-  );
-
-  const financeItems = useMemo(
-    () => [
-      {
-        text: t('钱包'),
-        itemKey: 'topup',
-        to: '/topup',
-        icon: <IconCreditCard />,
-      },
-      {
-        text: t('个人设置'),
-        itemKey: 'personal',
-        to: '/personal',
-        icon: <IconUser />,
-      },
-    ],
-    [t],
-  );
-
-  const adminItems = useMemo(
-    () => [
-      {
-        text: t('渠道'),
-        itemKey: 'channel',
-        to: '/channel',
-        icon: <IconLayers />,
-        className: isAdmin() ? '' : 'tableHiddle',
-      },
-      {
-        text: t('兑换码'),
-        itemKey: 'redemption',
-        to: '/redemption',
-        icon: <IconGift />,
-        className: isAdmin() ? '' : 'tableHiddle',
-      },
-      {
-        text: t('用户管理'),
-        itemKey: 'user',
-        to: '/user',
-        icon: <IconUser />,
-      },
-      {
-        text: t('系统设置'),
-        itemKey: 'setting',
-        to: '/setting',
-        icon: <IconSetting />,
-      },
-    ],
-    [isAdmin(), t],
-  );
-
-  const chatMenuItems = useMemo(
-    () => [
-      {
-        text: 'Playground',
-        itemKey: 'playground',
-        to: '/playground',
-        icon: <IconCommentStroked />,
-      },
-      {
-        text: t('聊天'),
-        itemKey: 'chat',
-        items: chatItems,
-        icon: <IconComment />,
-      },
-    ],
-    [chatItems, t],
-  );
-
-  // Function to update router map with chat routes
-  const updateRouterMapWithChats = (chats) => {
-    const newRouterMap = { ...routerMap };
-
-    if (Array.isArray(chats) && chats.length > 0) {
-      for (let i = 0; i < chats.length; i++) {
-        newRouterMap['chat' + i] = '/chat/' + i;
-      }
-    }
-
-    setRouterMapState(newRouterMap);
-    return newRouterMap;
-  };
-
-  // Update the useEffect for chat items
-  useEffect(() => {
-    let chats = localStorage.getItem('chats');
-    if (chats) {
-      try {
-        chats = JSON.parse(chats);
-        if (Array.isArray(chats)) {
-          let chatItems = [];
-          for (let i = 0; i < chats.length; i++) {
-            let chat = {};
-            for (let key in chats[i]) {
-              chat.text = key;
-              chat.itemKey = 'chat' + i;
-              chat.to = '/chat/' + i;
-            }
-            chatItems.push(chat);
-          }
-          setChatItems(chatItems);
-
-          // Update router map with chat routes
-          updateRouterMapWithChats(chats);
-        }
-      } catch (e) {
-        console.error(e);
-        showError('聊天数据解析失败');
-      }
-    }
-  }, []);
-
-  // Update the useEffect for route selection
-  useEffect(() => {
-    const currentPath = location.pathname;
-    let matchingKey = Object.keys(routerMapState).find(
-      (key) => routerMapState[key] === currentPath,
-    );
-
-    // Handle chat routes
-    if (!matchingKey && currentPath.startsWith('/chat/')) {
-      const chatIndex = currentPath.split('/').pop();
-      if (!isNaN(chatIndex)) {
-        matchingKey = 'chat' + chatIndex;
-      } else {
-        matchingKey = 'chat';
-      }
-    }
-
-    // If we found a matching key, update the selected keys
-    if (matchingKey) {
-      setSelectedKeys([matchingKey]);
-    }
-  }, [location.pathname, routerMapState]);
-
-  useEffect(() => {
-    setIsCollapsed(styleState.siderCollapsed);
-  }, [styleState.siderCollapsed]);
-
-  // Custom divider style
-  const dividerStyle = {
-    margin: '8px 0',
-    opacity: 0.6,
-  };
-
-  // Custom group label style
-  const groupLabelStyle = {
-    padding: '8px 16px',
-    color: 'var(--semi-color-text-2)',
-    fontSize: '12px',
-    fontWeight: 'bold',
-    textTransform: 'uppercase',
-    letterSpacing: '0.5px',
-  };
-
-  return (
-    <>
-      <Nav
-        className='custom-sidebar-nav'
-        style={{
-          width: isCollapsed ? '60px' : '200px',
-          boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
-          borderRight: '1px solid var(--semi-color-border)',
-          background: 'var(--semi-color-bg-1)',
-          borderRadius: styleState.isMobile ? '0' : '0 8px 8px 0',
-          position: 'relative',
-          zIndex: 95,
-          height: '100%',
-          overflowY: 'auto',
-          WebkitOverflowScrolling: 'touch', // Improve scrolling on iOS devices
-        }}
-        defaultIsCollapsed={
-          localStorage.getItem('default_collapse_sidebar') === 'true'
-        }
-        isCollapsed={isCollapsed}
-        onCollapseChange={(collapsed) => {
-          setIsCollapsed(collapsed);
-          // styleDispatch({ type: 'SET_SIDER', payload: true });
-          styleDispatch({ type: 'SET_SIDER_COLLAPSED', payload: collapsed });
-          localStorage.setItem('default_collapse_sidebar', collapsed);
-
-          // 确保在收起侧边栏时有选中的项目,避免不必要的计算
-          if (selectedKeys.length === 0) {
-            const currentPath = location.pathname;
-            const matchingKey = Object.keys(routerMapState).find(
-              (key) => routerMapState[key] === currentPath,
-            );
-
-            if (matchingKey) {
-              setSelectedKeys([matchingKey]);
-            } else if (currentPath.startsWith('/chat/')) {
-              setSelectedKeys(['chat']);
-            } else {
-              setSelectedKeys(['detail']); // 默认选中首页
-            }
-          }
-        }}
-        selectedKeys={selectedKeys}
-        itemStyle={navItemStyle}
-        hoverStyle={navItemHoverStyle}
-        selectedStyle={navItemSelectedStyle}
-        renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
-          return (
-            <Link
-              style={{ textDecoration: 'none' }}
-              to={routerMapState[props.itemKey] || routerMap[props.itemKey]}
-            >
-              {itemElement}
-            </Link>
-          );
-        }}
-        onSelect={(key) => {
-          if (key.itemKey.toString().startsWith('chat')) {
-            styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
-          } else {
-            styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
-          }
-
-          // 如果点击的是已经展开的子菜单的父项,则收起子菜单
-          if (openedKeys.includes(key.itemKey)) {
-            setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
-          }
-
-          setSelectedKeys([key.itemKey]);
-        }}
-        openKeys={openedKeys}
-        onOpenChange={(data) => {
-          setOpenedKeys(data.openKeys);
-        }}
-      >
-        {/* Chat Section - Only show if there are chat items */}
-        {chatMenuItems.map((item) => {
-          if (item.items && item.items.length > 0) {
-            return (
-              <Nav.Sub
-                key={item.itemKey}
-                itemKey={item.itemKey}
-                text={item.text}
-                icon={React.cloneElement(item.icon, {
-                  style: iconStyles[item.itemKey],
-                })}
-              >
-                {item.items.map((subItem) => (
-                  <Nav.Item
-                    key={subItem.itemKey}
-                    itemKey={subItem.itemKey}
-                    text={subItem.text}
-                  />
-                ))}
-              </Nav.Sub>
-            );
-          } else {
-            return (
-              <Nav.Item
-                key={item.itemKey}
-                itemKey={item.itemKey}
-                text={item.text}
-                icon={React.cloneElement(item.icon, {
-                  style: iconStyles[item.itemKey],
-                })}
-              />
-            );
-          }
-        })}
-
-        {/* Divider */}
-        <Divider style={dividerStyle} />
-
-        {/* Workspace Section */}
-        {!isCollapsed && <Text style={groupLabelStyle}>{t('控制台')}</Text>}
-        {workspaceItems.map((item) => (
-          <Nav.Item
-            key={item.itemKey}
-            itemKey={item.itemKey}
-            text={item.text}
-            icon={React.cloneElement(item.icon, {
-              style: iconStyles[item.itemKey],
-            })}
-            className={item.className}
-          />
-        ))}
-
-        {isAdmin() && (
-          <>
-            {/* Divider */}
-            <Divider style={dividerStyle} />
-
-            {/* Admin Section */}
-            {!isCollapsed && <Text style={groupLabelStyle}>{t('管理员')}</Text>}
-            {adminItems.map((item) => (
-              <Nav.Item
-                key={item.itemKey}
-                itemKey={item.itemKey}
-                text={item.text}
-                icon={React.cloneElement(item.icon, {
-                  style: iconStyles[item.itemKey],
-                })}
-                className={item.className}
-              />
-            ))}
-          </>
-        )}
-
-        {/* Divider */}
-        <Divider style={dividerStyle} />
-
-        {/* Finance Management Section */}
-        {!isCollapsed && <Text style={groupLabelStyle}>{t('个人中心')}</Text>}
-        {financeItems.map((item) => (
-          <Nav.Item
-            key={item.itemKey}
-            itemKey={item.itemKey}
-            text={item.text}
-            icon={React.cloneElement(item.icon, {
-              style: iconStyles[item.itemKey],
-            })}
-            className={item.className}
-          />
-        ))}
-
-        <Nav.Footer
-          style={{
-            paddingBottom: styleState?.isMobile ? '112px' : '',
-          }}
-          collapseButton={true}
-          collapseText={(collapsed) => {
-            if (collapsed) {
-              return t('展开侧边栏');
-            }
-            return t('收起侧边栏');
-          }}
-        />
-      </Nav>
-    </>
-  );
-};
-
-export default SiderBar;

+ 0 - 512
web/src/components/TaskLogsTable.js

@@ -1,512 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { Label } from 'semantic-ui-react';
-import {
-  API,
-  copy,
-  isAdmin,
-  showError,
-  showSuccess,
-  timestamp2string,
-} from '../helpers';
-
-import {
-  Table,
-  Tag,
-  Form,
-  Button,
-  Layout,
-  Modal,
-  Typography,
-  Progress,
-  Card,
-} from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
-
-const colors = [
-  'amber',
-  'blue',
-  'cyan',
-  'green',
-  'grey',
-  'indigo',
-  'light-blue',
-  'lime',
-  'orange',
-  'pink',
-  'purple',
-  'red',
-  'teal',
-  'violet',
-  'yellow',
-];
-
-const renderTimestamp = (timestampInSeconds) => {
-  const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
-
-  const year = date.getFullYear(); // 获取年份
-  const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
-  const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
-  const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
-  const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
-  const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
-
-  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
-};
-
-function renderDuration(submit_time, finishTime) {
-  // 确保startTime和finishTime都是有效的时间戳
-  if (!submit_time || !finishTime) return 'N/A';
-
-  // 将时间戳转换为Date对象
-  const start = new Date(submit_time);
-  const finish = new Date(finishTime);
-
-  // 计算时间差(毫秒)
-  const durationMs = finish - start;
-
-  // 将时间差转换为秒,并保留一位小数
-  const durationSec = (durationMs / 1000).toFixed(1);
-
-  // 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
-  const color = durationSec > 60 ? 'red' : 'green';
-
-  // 返回带有样式的颜色标签
-  return (
-    <Tag color={color} size='large'>
-      {durationSec} 秒
-    </Tag>
-  );
-}
-
-const LogsTable = () => {
-  const [isModalOpen, setIsModalOpen] = useState(false);
-  const [modalContent, setModalContent] = useState('');
-  const isAdminUser = isAdmin();
-  const columns = [
-    {
-      title: '提交时间',
-      dataIndex: 'submit_time',
-      render: (text, record, index) => {
-        return <div>{text ? renderTimestamp(text) : '-'}</div>;
-      },
-    },
-    {
-      title: '结束时间',
-      dataIndex: 'finish_time',
-      render: (text, record, index) => {
-        return <div>{text ? renderTimestamp(text) : '-'}</div>;
-      },
-    },
-    {
-      title: '进度',
-      dataIndex: 'progress',
-      width: 50,
-      render: (text, record, index) => {
-        return (
-          <div>
-            {
-              // 转换例如100%为数字100,如果text未定义,返回0
-              isNaN(text.replace('%', '')) ? (
-                text
-              ) : (
-                <Progress
-                  width={42}
-                  type='circle'
-                  showInfo={true}
-                  percent={Number(text.replace('%', '') || 0)}
-                  aria-label='drawing progress'
-                />
-              )
-            }
-          </div>
-        );
-      },
-    },
-    {
-      title: '花费时间',
-      dataIndex: 'finish_time', // 以finish_time作为dataIndex
-      key: 'finish_time',
-      render: (finish, record) => {
-        // 假设record.start_time是存在的,并且finish是完成时间的时间戳
-        return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
-      },
-    },
-    {
-      title: '渠道',
-      dataIndex: 'channel_id',
-      className: isAdminUser ? 'tableShow' : 'tableHiddle',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Tag
-              color={colors[parseInt(text) % colors.length]}
-              size='large'
-              onClick={() => {
-                copyText(text); // 假设copyText是用于文本复制的函数
-              }}
-            >
-              {' '}
-              {text}{' '}
-            </Tag>
-          </div>
-        );
-      },
-    },
-    {
-      title: '平台',
-      dataIndex: 'platform',
-      render: (text, record, index) => {
-        return <div>{renderPlatform(text)}</div>;
-      },
-    },
-    {
-      title: '类型',
-      dataIndex: 'action',
-      render: (text, record, index) => {
-        return <div>{renderType(text)}</div>;
-      },
-    },
-    {
-      title: '任务ID(点击查看详情)',
-      dataIndex: 'task_id',
-      render: (text, record, index) => {
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            //style={{width: 100}}
-            onClick={() => {
-              setModalContent(JSON.stringify(record, null, 2));
-              setIsModalOpen(true);
-            }}
-          >
-            <div>{text}</div>
-          </Typography.Text>
-        );
-      },
-    },
-    {
-      title: '任务状态',
-      dataIndex: 'status',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text)}</div>;
-      },
-    },
-
-    {
-      title: '失败原因',
-      dataIndex: 'fail_reason',
-      render: (text, record, index) => {
-        // 如果text未定义,返回替代文本,例如空字符串''或其他
-        if (!text) {
-          return '无';
-        }
-
-        return (
-          <Typography.Text
-            ellipsis={{ showTooltip: true }}
-            style={{ width: 100 }}
-            onClick={() => {
-              setModalContent(text);
-              setIsModalOpen(true);
-            }}
-          >
-            {text}
-          </Typography.Text>
-        );
-      },
-    },
-  ];
-
-  const [logs, setLogs] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
-  const [logType] = useState(0);
-
-  let now = new Date();
-  // 初始化start_timestamp为前一天
-  let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-  const [inputs, setInputs] = useState({
-    channel_id: '',
-    task_id: '',
-    start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
-    end_timestamp: '',
-  });
-  const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
-
-  const handleInputChange = (value, name) => {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
-
-  const setLogsFormat = (logs) => {
-    for (let i = 0; i < logs.length; i++) {
-      logs[i].timestamp2string = timestamp2string(logs[i].created_at);
-      logs[i].key = '' + logs[i].id;
-    }
-    // data.key = '' + data.id
-    setLogs(logs);
-    setLogCount(logs.length + ITEMS_PER_PAGE);
-    // console.log(logCount);
-  };
-
-  const loadLogs = async (startIdx) => {
-    setLoading(true);
-
-    let url = '';
-    let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
-    let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
-    if (isAdminUser) {
-      url = `/api/task/?p=${startIdx}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    } else {
-      url = `/api/task/self?p=${startIdx}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-    }
-    const res = await API.get(url);
-    let { success, message, data } = res.data;
-    if (success) {
-      if (startIdx === 0) {
-        setLogsFormat(data);
-      } else {
-        let newLogs = [...logs];
-        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
-        setLogsFormat(newLogs);
-      }
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const pageData = logs.slice(
-    (activePage - 1) * ITEMS_PER_PAGE,
-    activePage * ITEMS_PER_PAGE,
-  );
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
-      // In this case we have to load more data and then append them.
-      loadLogs(page - 1).then((r) => {});
-    }
-  };
-
-  const refresh = async () => {
-    // setLoading(true);
-    setActivePage(1);
-    await loadLogs(0);
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess('已复制:' + text);
-    } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
-    }
-  };
-
-  useEffect(() => {
-    refresh().then();
-  }, [logType]);
-
-  const renderType = (type) => {
-    switch (type) {
-      case 'MUSIC':
-        return (
-          <Label basic color='grey'>
-            {' '}
-            生成音乐{' '}
-          </Label>
-        );
-      case 'LYRICS':
-        return (
-          <Label basic color='pink'>
-            {' '}
-            生成歌词{' '}
-          </Label>
-        );
-
-      default:
-        return (
-          <Label basic color='black'>
-            {' '}
-            未知{' '}
-          </Label>
-        );
-    }
-  };
-
-  const renderPlatform = (type) => {
-    switch (type) {
-      case 'suno':
-        return (
-          <Label basic color='green'>
-            {' '}
-            Suno{' '}
-          </Label>
-        );
-      default:
-        return (
-          <Label basic color='black'>
-            {' '}
-            未知{' '}
-          </Label>
-        );
-    }
-  };
-
-  const renderStatus = (type) => {
-    switch (type) {
-      case 'SUCCESS':
-        return (
-          <Label basic color='green'>
-            {' '}
-            成功{' '}
-          </Label>
-        );
-      case 'NOT_START':
-        return (
-          <Label basic color='black'>
-            {' '}
-            未启动{' '}
-          </Label>
-        );
-      case 'SUBMITTED':
-        return (
-          <Label basic color='yellow'>
-            {' '}
-            队列中{' '}
-          </Label>
-        );
-      case 'IN_PROGRESS':
-        return (
-          <Label basic color='blue'>
-            {' '}
-            执行中{' '}
-          </Label>
-        );
-      case 'FAILURE':
-        return (
-          <Label basic color='red'>
-            {' '}
-            失败{' '}
-          </Label>
-        );
-      case 'QUEUED':
-        return (
-          <Label basic color='red'>
-            {' '}
-            排队中{' '}
-          </Label>
-        );
-      case 'UNKNOWN':
-        return (
-          <Label basic color='red'>
-            {' '}
-            未知{' '}
-          </Label>
-        );
-      case '':
-        return (
-          <Label basic color='black'>
-            {' '}
-            正在提交{' '}
-          </Label>
-        );
-      default:
-        return (
-          <Label basic color='black'>
-            {' '}
-            未知{' '}
-          </Label>
-        );
-    }
-  };
-
-  return (
-    <>
-      <Layout>
-        <Form layout='horizontal' labelPosition='inset'>
-          <>
-            {isAdminUser && (
-              <Form.Input
-                field='channel_id'
-                label='渠道 ID'
-                style={{ width: '236px', marginBottom: '10px' }}
-                value={channel_id}
-                placeholder={'可选值'}
-                name='channel_id'
-                onChange={(value) => handleInputChange(value, 'channel_id')}
-              />
-            )}
-            <Form.Input
-              field='task_id'
-              label={'任务 ID'}
-              style={{ width: '236px', marginBottom: '10px' }}
-              value={task_id}
-              placeholder={'可选值'}
-              name='task_id'
-              onChange={(value) => handleInputChange(value, 'task_id')}
-            />
-
-            <Form.DatePicker
-              field='start_timestamp'
-              label={'起始时间'}
-              style={{ width: '236px', marginBottom: '10px' }}
-              initValue={start_timestamp}
-              value={start_timestamp}
-              type='dateTime'
-              name='start_timestamp'
-              onChange={(value) => handleInputChange(value, 'start_timestamp')}
-            />
-            <Form.DatePicker
-              field='end_timestamp'
-              fluid
-              label={'结束时间'}
-              style={{ width: '236px', marginBottom: '10px' }}
-              initValue={end_timestamp}
-              value={end_timestamp}
-              type='dateTime'
-              name='end_timestamp'
-              onChange={(value) => handleInputChange(value, 'end_timestamp')}
-            />
-            <Button
-              label={'查询'}
-              type='primary'
-              htmlType='submit'
-              className='btn-margin-right'
-              onClick={refresh}
-            >
-              查询
-            </Button>
-          </>
-        </Form>
-        <Card>
-          <Table
-            columns={columns}
-            dataSource={pageData}
-            pagination={{
-              currentPage: activePage,
-              pageSize: ITEMS_PER_PAGE,
-              total: logCount,
-              pageSizeOpts: [10, 20, 50, 100],
-              onPageChange: handlePageChange,
-            }}
-            loading={loading}
-          />
-        </Card>
-        <Modal
-          visible={isModalOpen}
-          onOk={() => setIsModalOpen(false)}
-          onCancel={() => setIsModalOpen(false)}
-          closable={null}
-          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
-          width={800} // 设置模态框宽度
-        >
-          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
-        </Modal>
-      </Layout>
-    </>
-  );
-};
-
-export default LogsTable;

+ 0 - 515
web/src/components/UsersTable.js

@@ -1,515 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { API, showError, showSuccess } from '../helpers';
-import {
-  Button,
-  Form,
-  Popconfirm,
-  Space,
-  Table,
-  Tag,
-  Tooltip,
-} from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
-import AddUser from '../pages/User/AddUser';
-import EditUser from '../pages/User/EditUser';
-import { useTranslation } from 'react-i18next';
-
-const UsersTable = () => {
-  const { t } = useTranslation();
-
-  function renderRole(role) {
-    switch (role) {
-      case 1:
-        return <Tag size='large'>{t('普通用户')}</Tag>;
-      case 10:
-        return (
-          <Tag color='yellow' size='large'>
-            {t('管理员')}
-          </Tag>
-        );
-      case 100:
-        return (
-          <Tag color='orange' size='large'>
-            {t('超级管理员')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='red' size='large'>
-            {t('未知身份')}
-          </Tag>
-        );
-    }
-  }
-  const columns = [
-    {
-      title: 'ID',
-      dataIndex: 'id',
-    },
-    {
-      title: t('用户名'),
-      dataIndex: 'username',
-    },
-    {
-      title: t('分组'),
-      dataIndex: 'group',
-      render: (text, record, index) => {
-        return <div>{renderGroup(text)}</div>;
-      },
-    },
-    {
-      title: t('统计信息'),
-      dataIndex: 'info',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Space spacing={1}>
-              <Tooltip content={t('剩余额度')}>
-                <Tag color='white' size='large'>
-                  {renderQuota(record.quota)}
-                </Tag>
-              </Tooltip>
-              <Tooltip content={t('已用额度')}>
-                <Tag color='white' size='large'>
-                  {renderQuota(record.used_quota)}
-                </Tag>
-              </Tooltip>
-              <Tooltip content={t('调用次数')}>
-                <Tag color='white' size='large'>
-                  {renderNumber(record.request_count)}
-                </Tag>
-              </Tooltip>
-            </Space>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('邀请信息'),
-      dataIndex: 'invite',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Space spacing={1}>
-              <Tooltip content={t('邀请人数')}>
-                <Tag color='white' size='large'>
-                  {renderNumber(record.aff_count)}
-                </Tag>
-              </Tooltip>
-              <Tooltip content={t('邀请总收益')}>
-                <Tag color='white' size='large'>
-                  {renderQuota(record.aff_history_quota)}
-                </Tag>
-              </Tooltip>
-              <Tooltip content={t('邀请人ID')}>
-                {record.inviter_id === 0 ? (
-                  <Tag color='white' size='large'>
-                    {t('无')}
-                  </Tag>
-                ) : (
-                  <Tag color='white' size='large'>
-                    {record.inviter_id}
-                  </Tag>
-                )}
-              </Tooltip>
-            </Space>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('角色'),
-      dataIndex: 'role',
-      render: (text, record, index) => {
-        return <div>{renderRole(text)}</div>;
-      },
-    },
-    {
-      title: t('状态'),
-      dataIndex: 'status',
-      render: (text, record, index) => {
-        return (
-          <div>
-            {record.DeletedAt !== null ? (
-              <Tag color='red'>{t('已注销')}</Tag>
-            ) : (
-              renderStatus(text)
-            )}
-          </div>
-        );
-      },
-    },
-    {
-      title: '',
-      dataIndex: 'operate',
-      render: (text, record, index) => (
-        <div>
-          {record.DeletedAt !== null ? (
-            <></>
-          ) : (
-            <>
-              <Popconfirm
-                title={t('确定?')}
-                okType={'warning'}
-                onConfirm={() => {
-                  manageUser(record.id, 'promote', record);
-                }}
-              >
-                <Button theme='light' type='warning' style={{ marginRight: 1 }}>
-                  {t('提升')}
-                </Button>
-              </Popconfirm>
-              <Popconfirm
-                title={t('确定?')}
-                okType={'warning'}
-                onConfirm={() => {
-                  manageUser(record.id, 'demote', record);
-                }}
-              >
-                <Button
-                  theme='light'
-                  type='secondary'
-                  style={{ marginRight: 1 }}
-                >
-                  {t('降级')}
-                </Button>
-              </Popconfirm>
-              {record.status === 1 ? (
-                <Button
-                  theme='light'
-                  type='warning'
-                  style={{ marginRight: 1 }}
-                  onClick={async () => {
-                    manageUser(record.id, 'disable', record);
-                  }}
-                >
-                  {t('禁用')}
-                </Button>
-              ) : (
-                <Button
-                  theme='light'
-                  type='secondary'
-                  style={{ marginRight: 1 }}
-                  onClick={async () => {
-                    manageUser(record.id, 'enable', record);
-                  }}
-                  disabled={record.status === 3}
-                >
-                  {t('启用')}
-                </Button>
-              )}
-              <Button
-                theme='light'
-                type='tertiary'
-                style={{ marginRight: 1 }}
-                onClick={() => {
-                  setEditingUser(record);
-                  setShowEditUser(true);
-                }}
-              >
-                {t('编辑')}
-              </Button>
-              <Popconfirm
-                title={t('确定是否要注销此用户?')}
-                content={t('相当于删除用户,此修改将不可逆')}
-                okType={'danger'}
-                position={'left'}
-                onConfirm={() => {
-                  manageUser(record.id, 'delete', record).then(() => {
-                    removeRecord(record.id);
-                  });
-                }}
-              >
-                <Button theme='light' type='danger' style={{ marginRight: 1 }}>
-                  {t('注销')}
-                </Button>
-              </Popconfirm>
-            </>
-          )}
-        </div>
-      ),
-    },
-  ];
-
-  const [users, setUsers] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [searchKeyword, setSearchKeyword] = useState('');
-  const [searching, setSearching] = useState(false);
-  const [searchGroup, setSearchGroup] = useState('');
-  const [groupOptions, setGroupOptions] = useState([]);
-  const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
-  const [showAddUser, setShowAddUser] = useState(false);
-  const [showEditUser, setShowEditUser] = useState(false);
-  const [editingUser, setEditingUser] = useState({
-    id: undefined,
-  });
-
-  const removeRecord = (key) => {
-    let newDataSource = [...users];
-    if (key != null) {
-      let idx = newDataSource.findIndex((data) => data.id === key);
-
-      if (idx > -1) {
-        // update deletedAt
-        newDataSource[idx].DeletedAt = new Date();
-        setUsers(newDataSource);
-      }
-    }
-  };
-
-  const setUserFormat = (users) => {
-    for (let i = 0; i < users.length; i++) {
-      users[i].key = users[i].id;
-    }
-    setUsers(users);
-  };
-
-  const loadUsers = async (startIdx, pageSize) => {
-    const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setUserCount(data.total);
-      setUserFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  useEffect(() => {
-    loadUsers(0, pageSize)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-    fetchGroups().then();
-  }, []);
-
-  const manageUser = async (userId, action, record) => {
-    const res = await API.post('/api/user/manage', {
-      id: userId,
-      action,
-    });
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('操作成功完成!');
-      let user = res.data.data;
-      let newUsers = [...users];
-      if (action === 'delete') {
-      } else {
-        record.status = user.status;
-        record.role = user.role;
-      }
-      setUsers(newUsers);
-    } else {
-      showError(message);
-    }
-  };
-
-  const renderStatus = (status) => {
-    switch (status) {
-      case 1:
-        return <Tag size='large'>{t('已激活')}</Tag>;
-      case 2:
-        return (
-          <Tag size='large' color='red'>
-            {t('已封禁')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag size='large' color='grey'>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const searchUsers = async (
-    startIdx,
-    pageSize,
-    searchKeyword,
-    searchGroup,
-  ) => {
-    if (searchKeyword === '' && searchGroup === '') {
-      // if keyword is blank, load files instead.
-      await loadUsers(startIdx, pageSize);
-      return;
-    }
-    setSearching(true);
-    const res = await API.get(
-      `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setUserCount(data.total);
-      setUserFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setSearching(false);
-  };
-
-  const handleKeywordChange = async (value) => {
-    setSearchKeyword(value.trim());
-  };
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    if (searchKeyword === '' && searchGroup === '') {
-      loadUsers(page, pageSize).then();
-    } else {
-      searchUsers(page, pageSize, searchKeyword, searchGroup).then();
-    }
-  };
-
-  const closeAddUser = () => {
-    setShowAddUser(false);
-  };
-
-  const closeEditUser = () => {
-    setShowEditUser(false);
-    setEditingUser({
-      id: undefined,
-    });
-  };
-
-  const refresh = async () => {
-    setActivePage(1);
-    if (searchKeyword === '') {
-      await loadUsers(activePage, pageSize);
-    } else {
-      await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
-    }
-  };
-
-  const fetchGroups = async () => {
-    try {
-      let res = await API.get(`/api/group/`);
-      // add 'all' option
-      // res.data.data.unshift('all');
-      if (res === undefined) {
-        return;
-      }
-      setGroupOptions(
-        res.data.data.map((group) => ({
-          label: group,
-          value: group,
-        })),
-      );
-    } catch (error) {
-      showError(error.message);
-    }
-  };
-
-  const handlePageSizeChange = async (size) => {
-    localStorage.setItem('page-size', size + '');
-    setPageSize(size);
-    setActivePage(1);
-    loadUsers(activePage, size)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  };
-
-  return (
-    <>
-      <AddUser
-        refresh={refresh}
-        visible={showAddUser}
-        handleClose={closeAddUser}
-      ></AddUser>
-      <EditUser
-        refresh={refresh}
-        visible={showEditUser}
-        handleClose={closeEditUser}
-        editingUser={editingUser}
-      ></EditUser>
-      <Form
-        onSubmit={() => {
-          searchUsers(activePage, pageSize, searchKeyword, searchGroup);
-        }}
-        labelPosition='left'
-      >
-        <div style={{ display: 'flex' }}>
-          <Space>
-            <Tooltip
-              content={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
-            >
-              <Form.Input
-                label={t('搜索关键字')}
-                icon='search'
-                field='keyword'
-                iconPosition='left'
-                placeholder={t('搜索关键字')}
-                value={searchKeyword}
-                loading={searching}
-                onChange={(value) => handleKeywordChange(value)}
-              />
-            </Tooltip>
-
-            <Form.Select
-              field='group'
-              label={t('分组')}
-              optionList={groupOptions}
-              onChange={(value) => {
-                setSearchGroup(value);
-                searchUsers(activePage, pageSize, searchKeyword, value);
-              }}
-            />
-            <Button
-              label={t('查询')}
-              type='primary'
-              htmlType='submit'
-              className='btn-margin-right'
-            >
-              {t('查询')}
-            </Button>
-            <Button
-              theme='light'
-              type='primary'
-              onClick={() => {
-                setShowAddUser(true);
-              }}
-            >
-              {t('添加用户')}
-            </Button>
-          </Space>
-        </div>
-      </Form>
-
-      <Table
-        columns={columns}
-        dataSource={users}
-        pagination={{
-          formatPageText: (page) =>
-            t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-              start: page.currentStart,
-              end: page.currentEnd,
-              total: users.length,
-            }),
-          currentPage: activePage,
-          pageSize: pageSize,
-          total: userCount,
-          pageSizeOpts: [10, 20, 50, 100],
-          showSizeChanger: true,
-          onPageSizeChange: (size) => {
-            handlePageSizeChange(size);
-          },
-          onPageChange: handlePageChange,
-        }}
-        loading={loading}
-      />
-    </>
-  );
-};
-
-export default UsersTable;

+ 536 - 0
web/src/components/auth/LoginForm.js

@@ -0,0 +1,536 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { UserContext } from '../../context/User/index.js';
+import {
+  API,
+  getLogo,
+  showError,
+  showInfo,
+  showSuccess,
+  updateAPI,
+  getSystemName,
+  setUserData,
+  onGitHubOAuthClicked,
+  onOIDCClicked,
+  onLinuxDOOAuthClicked
+} from '../../helpers/index.js';
+import Turnstile from 'react-turnstile';
+import {
+  Button,
+  Card,
+  Divider,
+  Form,
+  Icon,
+  Modal,
+} from '@douyinfe/semi-ui';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import Text from '@douyinfe/semi-ui/lib/es/typography/text';
+import TelegramLoginButton from 'react-telegram-login';
+
+import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
+import OIDCIcon from '../common/logo/OIDCIcon.js';
+import WeChatIcon from '../common/logo/WeChatIcon.js';
+import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
+import { useTranslation } from 'react-i18next';
+import Background from '/example.png';
+
+const LoginForm = () => {
+  const [inputs, setInputs] = useState({
+    username: '',
+    password: '',
+    wechat_verification_code: '',
+  });
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [submitted, setSubmitted] = useState(false);
+  const { username, password } = inputs;
+  const [userState, userDispatch] = useContext(UserContext);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  let navigate = useNavigate();
+  const [status, setStatus] = useState({});
+  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
+  const [showEmailLogin, setShowEmailLogin] = useState(false);
+  const [wechatLoading, setWechatLoading] = useState(false);
+  const [githubLoading, setGithubLoading] = useState(false);
+  const [oidcLoading, setOidcLoading] = useState(false);
+  const [linuxdoLoading, setLinuxdoLoading] = useState(false);
+  const [emailLoginLoading, setEmailLoginLoading] = useState(false);
+  const [loginLoading, setLoginLoading] = useState(false);
+  const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
+  const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
+  const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
+  const { t } = useTranslation();
+
+  const logo = getLogo();
+  const systemName = getSystemName();
+
+  let affCode = new URLSearchParams(window.location.search).get('aff');
+  if (affCode) {
+    localStorage.setItem('aff', affCode);
+  }
+
+  useEffect(() => {
+    if (searchParams.get('expired')) {
+      showError(t('未登录或登录已过期,请重新登录'));
+    }
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      setStatus(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+  }, []);
+
+  const onWeChatLoginClicked = () => {
+    setWechatLoading(true);
+    setShowWeChatLoginModal(true);
+    setWechatLoading(false);
+  };
+
+  const onSubmitWeChatVerificationCode = async () => {
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setWechatCodeSubmitLoading(true);
+    try {
+      const res = await API.get(
+        `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
+      );
+      const { success, message, data } = res.data;
+      if (success) {
+        userDispatch({ type: 'login', payload: data });
+        localStorage.setItem('user', JSON.stringify(data));
+        setUserData(data);
+        updateAPI();
+        navigate('/');
+        showSuccess('登录成功!');
+        setShowWeChatLoginModal(false);
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError('登录失败,请重试');
+    } finally {
+      setWechatCodeSubmitLoading(false);
+    }
+  };
+
+  function handleChange(name, value) {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  }
+
+  async function handleSubmit(e) {
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setSubmitted(true);
+    setLoginLoading(true);
+    try {
+      if (username && password) {
+        const res = await API.post(
+          `/api/user/login?turnstile=${turnstileToken}`,
+          {
+            username,
+            password,
+          },
+        );
+        const { success, message, data } = res.data;
+        if (success) {
+          userDispatch({ type: 'login', payload: data });
+          setUserData(data);
+          updateAPI();
+          showSuccess('登录成功!');
+          if (username === 'root' && password === '123456') {
+            Modal.error({
+              title: '您正在使用默认密码!',
+              content: '请立刻修改默认密码!',
+              centered: true,
+            });
+          }
+          navigate('/console');
+        } else {
+          showError(message);
+        }
+      } else {
+        showError('请输入用户名和密码!');
+      }
+    } catch (error) {
+      showError('登录失败,请重试');
+    } finally {
+      setLoginLoading(false);
+    }
+  }
+
+  // 添加Telegram登录处理函数
+  const onTelegramLoginClicked = async (response) => {
+    const fields = [
+      'id',
+      'first_name',
+      'last_name',
+      'username',
+      'photo_url',
+      'auth_date',
+      'hash',
+      'lang',
+    ];
+    const params = {};
+    fields.forEach((field) => {
+      if (response[field]) {
+        params[field] = response[field];
+      }
+    });
+    try {
+      const res = await API.get(`/api/oauth/telegram/login`, { params });
+      const { success, message, data } = res.data;
+      if (success) {
+        userDispatch({ type: 'login', payload: data });
+        localStorage.setItem('user', JSON.stringify(data));
+        showSuccess('登录成功!');
+        setUserData(data);
+        updateAPI();
+        navigate('/');
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError('登录失败,请重试');
+    }
+  };
+
+  // 包装的GitHub登录点击处理
+  const handleGitHubClick = () => {
+    setGithubLoading(true);
+    try {
+      onGitHubOAuthClicked(status.github_client_id);
+    } finally {
+      // 由于重定向,这里不会执行到,但为了完整性添加
+      setTimeout(() => setGithubLoading(false), 3000);
+    }
+  };
+
+  // 包装的OIDC登录点击处理
+  const handleOIDCClick = () => {
+    setOidcLoading(true);
+    try {
+      onOIDCClicked(
+        status.oidc_authorization_endpoint,
+        status.oidc_client_id
+      );
+    } finally {
+      // 由于重定向,这里不会执行到,但为了完整性添加
+      setTimeout(() => setOidcLoading(false), 3000);
+    }
+  };
+
+  // 包装的LinuxDO登录点击处理
+  const handleLinuxDOClick = () => {
+    setLinuxdoLoading(true);
+    try {
+      onLinuxDOOAuthClicked(status.linuxdo_client_id);
+    } finally {
+      // 由于重定向,这里不会执行到,但为了完整性添加
+      setTimeout(() => setLinuxdoLoading(false), 3000);
+    }
+  };
+
+  // 包装的邮箱登录选项点击处理
+  const handleEmailLoginClick = () => {
+    setEmailLoginLoading(true);
+    setShowEmailLogin(true);
+    setEmailLoginLoading(false);
+  };
+
+  // 包装的重置密码点击处理
+  const handleResetPasswordClick = () => {
+    setResetPasswordLoading(true);
+    navigate('/reset');
+    setResetPasswordLoading(false);
+  };
+
+  // 包装的其他登录选项点击处理
+  const handleOtherLoginOptionsClick = () => {
+    setOtherLoginOptionsLoading(true);
+    setShowEmailLogin(false);
+    setOtherLoginOptionsLoading(false);
+  };
+
+  const renderOAuthOptions = () => {
+    return (
+      <div className="flex flex-col items-center">
+        <div className="w-full max-w-md">
+          <div className="flex items-center justify-center mb-6 gap-2">
+            <img src={logo} alt="Logo" className="h-10 rounded-full" />
+            <Title heading={3} className='!text-white'>{systemName}</Title>
+          </div>
+
+          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+            <div className="flex justify-center pt-6 pb-2">
+              <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
+            </div>
+            <div className="px-2 py-8">
+              <div className="space-y-3">
+                {status.wechat_login && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
+                    size="large"
+                    onClick={onWeChatLoginClicked}
+                    loading={wechatLoading}
+                  >
+                    <span className="ml-3">{t('使用 微信 继续')}</span>
+                  </Button>
+                )}
+
+                {status.github_oauth && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<IconGithubLogo size="large" />}
+                    size="large"
+                    onClick={handleGitHubClick}
+                    loading={githubLoading}
+                  >
+                    <span className="ml-3">{t('使用 GitHub 继续')}</span>
+                  </Button>
+                )}
+
+                {status.oidc_enabled && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<OIDCIcon style={{ color: '#1877F2' }} />}
+                    size="large"
+                    onClick={handleOIDCClick}
+                    loading={oidcLoading}
+                  >
+                    <span className="ml-3">{t('使用 OIDC 继续')}</span>
+                  </Button>
+                )}
+
+                {status.linuxdo_oauth && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
+                    size="large"
+                    onClick={handleLinuxDOClick}
+                    loading={linuxdoLoading}
+                  >
+                    <span className="ml-3">{t('使用 LinuxDO 继续')}</span>
+                  </Button>
+                )}
+
+                {status.telegram_oauth && (
+                  <div className="flex justify-center my-2">
+                    <TelegramLoginButton
+                      dataOnauth={onTelegramLoginClicked}
+                      botName={status.telegram_bot_name}
+                    />
+                  </div>
+                )}
+
+                <Divider margin='12px' align='center'>
+                  {t('或')}
+                </Divider>
+
+                <Button
+                  theme="solid"
+                  type="primary"
+                  className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
+                  icon={<IconMail size="large" />}
+                  size="large"
+                  onClick={handleEmailLoginClick}
+                  loading={emailLoginLoading}
+                >
+                  <span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
+                </Button>
+              </div>
+
+              <div className="mt-6 text-center text-sm">
+                <Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
+              </div>
+            </div>
+          </Card>
+        </div>
+      </div>
+    );
+  };
+
+  const renderEmailLoginForm = () => {
+    return (
+      <div className="flex flex-col items-center">
+        <div className="w-full max-w-md">
+          <div className="flex items-center justify-center mb-6 gap-2">
+            <img src={logo} alt="Logo" className="h-10 rounded-full" />
+            <Title heading={3}>{systemName}</Title>
+          </div>
+
+          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+            <div className="flex justify-center pt-6 pb-2">
+              <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
+            </div>
+            <div className="px-2 py-8">
+              <Form className="space-y-3">
+                <Form.Input
+                  field="username"
+                  label={t('用户名或邮箱')}
+                  placeholder={t('请输入您的用户名或邮箱地址')}
+                  name="username"
+                  size="large"
+                  className="!rounded-md"
+                  onChange={(value) => handleChange('username', value)}
+                  prefix={<IconMail />}
+                />
+
+                <Form.Input
+                  field="password"
+                  label={t('密码')}
+                  placeholder={t('请输入您的密码')}
+                  name="password"
+                  mode="password"
+                  size="large"
+                  className="!rounded-md"
+                  onChange={(value) => handleChange('password', value)}
+                  prefix={<IconLock />}
+                />
+
+                <div className="space-y-2 pt-2">
+                  <Button
+                    theme="solid"
+                    className="w-full !rounded-full"
+                    type="primary"
+                    htmlType="submit"
+                    size="large"
+                    onClick={handleSubmit}
+                    loading={loginLoading}
+                  >
+                    {t('继续')}
+                  </Button>
+
+                  <Button
+                    theme="borderless"
+                    type='tertiary'
+                    className="w-full !rounded-full"
+                    size="large"
+                    onClick={handleResetPasswordClick}
+                    loading={resetPasswordLoading}
+                  >
+                    {t('忘记密码?')}
+                  </Button>
+                </div>
+              </Form>
+
+              {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
+                <>
+                  <Divider margin='12px' align='center'>
+                    {t('或')}
+                  </Divider>
+
+                  <div className="mt-4 text-center">
+                    <Button
+                      theme="outline"
+                      type="tertiary"
+                      className="w-full !rounded-full"
+                      size="large"
+                      onClick={handleOtherLoginOptionsClick}
+                      loading={otherLoginOptionsLoading}
+                    >
+                      {t('其他登录选项')}
+                    </Button>
+                  </div>
+                </>
+              )}
+
+              <div className="mt-6 text-center text-sm">
+                <Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
+              </div>
+            </div>
+          </Card>
+        </div>
+      </div>
+    );
+  };
+
+  // 微信登录模态框
+  const renderWeChatLoginModal = () => {
+    return (
+      <Modal
+        title={t('微信扫码登录')}
+        visible={showWeChatLoginModal}
+        maskClosable={true}
+        onOk={onSubmitWeChatVerificationCode}
+        onCancel={() => setShowWeChatLoginModal(false)}
+        okText={t('登录')}
+        size="small"
+        centered={true}
+        okButtonProps={{
+          loading: wechatCodeSubmitLoading,
+        }}
+      >
+        <div className="flex flex-col items-center">
+          <img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
+        </div>
+
+        <div className="text-center mb-4">
+          <p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
+        </div>
+
+        <Form size="large">
+          <Form.Input
+            field="wechat_verification_code"
+            placeholder={t('验证码')}
+            label={t('验证码')}
+            value={inputs.wechat_verification_code}
+            onChange={(value) => handleChange('wechat_verification_code', value)}
+          />
+        </Form>
+      </Modal>
+    );
+  };
+
+  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="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)
+          ? renderEmailLoginForm()
+          : renderOAuthOptions()}
+        {renderWeChatLoginModal()}
+
+        {turnstileEnabled && (
+          <div className="flex justify-center mt-6">
+            <Turnstile
+              sitekey={turnstileSiteKey}
+              onVerify={(token) => {
+                setTurnstileToken(token);
+              }}
+            />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default LoginForm;

+ 12 - 9
web/src/components/OAuth2Callback.js → web/src/components/auth/OAuth2Callback.js

@@ -1,9 +1,8 @@
 import React, { useContext, useEffect, useState } from 'react';
-import { Dimmer, Loader, Segment } from 'semantic-ui-react';
+import { Spin, Typography, Space } from '@douyinfe/semi-ui';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { API, showError, showSuccess, updateAPI } from '../helpers';
-import { UserContext } from '../context/User';
-import { setUserData } from '../helpers/data.js';
+import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
+import { UserContext } from '../../context/User';
 
 const OAuth2Callback = (props) => {
   const [searchParams, setSearchParams] = useSearchParams();
@@ -52,11 +51,15 @@ const OAuth2Callback = (props) => {
   }, []);
 
   return (
-    <Segment style={{ minHeight: '300px' }}>
-      <Dimmer active inverted>
-        <Loader size='large'>{prompt}</Loader>
-      </Dimmer>
-    </Segment>
+    <div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
+      <Space vertical align="center">
+        <Spin size="large" spinning={processing}>
+          <div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
+            <Typography.Text type="secondary">{prompt}</Typography.Text>
+          </div>
+        </Spin>
+      </Space>
+    </div>
   );
 };
 

+ 155 - 0
web/src/components/auth/PasswordResetConfirm.js

@@ -0,0 +1,155 @@
+import React, { useEffect, useState } from 'react';
+import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
+import { useSearchParams, Link } from 'react-router-dom';
+import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
+import { IconMail, IconLock } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import Background from '/example.png';
+
+const { Text, Title } = Typography;
+
+const PasswordResetConfirm = () => {
+  const { t } = useTranslation();
+  const [inputs, setInputs] = useState({
+    email: '',
+    token: '',
+  });
+  const { email, token } = inputs;
+
+  const [loading, setLoading] = useState(false);
+  const [disableButton, setDisableButton] = useState(false);
+  const [countdown, setCountdown] = useState(30);
+  const [newPassword, setNewPassword] = useState('');
+
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const logo = getLogo();
+  const systemName = getSystemName();
+
+  useEffect(() => {
+    let token = searchParams.get('token');
+    let email = searchParams.get('email');
+    setInputs({
+      token,
+      email,
+    });
+  }, []);
+
+  useEffect(() => {
+    let countdownInterval = null;
+    if (disableButton && countdown > 0) {
+      countdownInterval = setInterval(() => {
+        setCountdown(countdown - 1);
+      }, 1000);
+    } else if (countdown === 0) {
+      setDisableButton(false);
+      setCountdown(30);
+    }
+    return () => clearInterval(countdownInterval);
+  }, [disableButton, countdown]);
+
+  async function handleSubmit(e) {
+    if (!email || !token) return;
+    setDisableButton(true);
+    setLoading(true);
+    const res = await API.post(`/api/user/reset`, {
+      email,
+      token,
+    });
+    const { success, message } = res.data;
+    if (success) {
+      let password = res.data.data;
+      setNewPassword(password);
+      await copy(password);
+      showNotice(`${t('密码已重置并已复制到剪贴板')}: ${password}`);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  }
+
+  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="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="w-full max-w-md">
+            <div className="flex items-center justify-center mb-6 gap-2">
+              <img src={logo} alt="Logo" className="h-10 rounded-full" />
+              <Title heading={3} className='!text-white'>{systemName}</Title>
+            </div>
+
+            <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+              <div className="flex justify-center pt-6 pb-2">
+                <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
+              </div>
+              <div className="px-2 py-8">
+                <Form className="space-y-3">
+                  <Form.Input
+                    field="email"
+                    label={t('邮箱')}
+                    name="email"
+                    size="large"
+                    className="!rounded-md"
+                    value={email}
+                    readOnly
+                    prefix={<IconMail />}
+                  />
+
+                  {newPassword && (
+                    <Form.Input
+                      field="newPassword"
+                      label={t('新密码')}
+                      name="newPassword"
+                      size="large"
+                      className="!rounded-md"
+                      value={newPassword}
+                      readOnly
+                      prefix={<IconLock />}
+                      onClick={(e) => {
+                        e.target.select();
+                        navigator.clipboard.writeText(newPassword);
+                        showNotice(`${t('密码已复制到剪贴板')}: ${newPassword}`);
+                      }}
+                    />
+                  )}
+
+                  <div className="space-y-2 pt-2">
+                    <Button
+                      theme="solid"
+                      className="w-full !rounded-full"
+                      type="primary"
+                      htmlType="submit"
+                      size="large"
+                      onClick={handleSubmit}
+                      loading={loading}
+                      disabled={disableButton || newPassword}
+                    >
+                      {newPassword ? t('密码重置完成') : t('提交')}
+                    </Button>
+                  </div>
+                </Form>
+
+                <div className="mt-6 text-center text-sm">
+                  <Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text>
+                </div>
+              </div>
+            </Card>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default PasswordResetConfirm;

+ 156 - 0
web/src/components/auth/PasswordResetForm.js

@@ -0,0 +1,156 @@
+import React, { useEffect, useState } from 'react';
+import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
+import Turnstile from 'react-turnstile';
+import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
+import { IconMail } from '@douyinfe/semi-icons';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import Background from '/example.png';
+
+const { Text, Title } = Typography;
+
+const PasswordResetForm = () => {
+  const { t } = useTranslation();
+  const [inputs, setInputs] = useState({
+    email: '',
+  });
+  const { email } = inputs;
+
+  const [loading, setLoading] = useState(false);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  const [disableButton, setDisableButton] = useState(false);
+  const [countdown, setCountdown] = useState(30);
+
+  const logo = getLogo();
+  const systemName = getSystemName();
+
+  useEffect(() => {
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+  }, []);
+
+  useEffect(() => {
+    let countdownInterval = null;
+    if (disableButton && countdown > 0) {
+      countdownInterval = setInterval(() => {
+        setCountdown(countdown - 1);
+      }, 1000);
+    } else if (countdown === 0) {
+      setDisableButton(false);
+      setCountdown(30);
+    }
+    return () => clearInterval(countdownInterval);
+  }, [disableButton, countdown]);
+
+  function handleChange(value) {
+    setInputs((inputs) => ({ ...inputs, email: value }));
+  }
+
+  async function handleSubmit(e) {
+    if (!email) return;
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
+      return;
+    }
+    setDisableButton(true);
+    setLoading(true);
+    const res = await API.get(
+      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('重置邮件发送成功,请检查邮箱!'));
+      setInputs({ ...inputs, email: '' });
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  }
+
+  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="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="w-full max-w-md">
+            <div className="flex items-center justify-center mb-6 gap-2">
+              <img src={logo} alt="Logo" className="h-10 rounded-full" />
+              <Title heading={3} className='!text-white'>{systemName}</Title>
+            </div>
+
+            <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+              <div className="flex justify-center pt-6 pb-2">
+                <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
+              </div>
+              <div className="px-2 py-8">
+                <Form className="space-y-3">
+                  <Form.Input
+                    field="email"
+                    label={t('邮箱')}
+                    placeholder={t('请输入您的邮箱地址')}
+                    name="email"
+                    size="large"
+                    className="!rounded-md"
+                    value={email}
+                    onChange={handleChange}
+                    prefix={<IconMail />}
+                  />
+
+                  <div className="space-y-2 pt-2">
+                    <Button
+                      theme="solid"
+                      className="w-full !rounded-full"
+                      type="primary"
+                      htmlType="submit"
+                      size="large"
+                      onClick={handleSubmit}
+                      loading={loading}
+                      disabled={disableButton}
+                    >
+                      {disableButton ? `${t('重试')} (${countdown})` : t('提交')}
+                    </Button>
+                  </div>
+                </Form>
+
+                <div className="mt-6 text-center text-sm">
+                  <Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
+                </div>
+              </div>
+            </Card>
+
+            {turnstileEnabled && (
+              <div className="flex justify-center mt-6">
+                <Turnstile
+                  sitekey={turnstileSiteKey}
+                  onVerify={(token) => {
+                    setTurnstileToken(token);
+                  }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default PasswordResetForm;

+ 576 - 0
web/src/components/auth/RegisterForm.js

@@ -0,0 +1,576 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import {
+  API,
+  getLogo,
+  showError,
+  showInfo,
+  showSuccess,
+  updateAPI,
+  getSystemName,
+  setUserData
+} from '../../helpers/index.js';
+import Turnstile from 'react-turnstile';
+import {
+  Button,
+  Card,
+  Divider,
+  Form,
+  Icon,
+  Modal,
+} from '@douyinfe/semi-ui';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import Text from '@douyinfe/semi-ui/lib/es/typography/text';
+import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons';
+import {
+  onGitHubOAuthClicked,
+  onLinuxDOOAuthClicked,
+  onOIDCClicked,
+} from '../../helpers/index.js';
+import OIDCIcon from '../common/logo/OIDCIcon.js';
+import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
+import WeChatIcon from '../common/logo/WeChatIcon.js';
+import TelegramLoginButton from 'react-telegram-login/src';
+import { UserContext } from '../../context/User/index.js';
+import { useTranslation } from 'react-i18next';
+import Background from '/example.png';
+
+const RegisterForm = () => {
+  const { t } = useTranslation();
+  const [inputs, setInputs] = useState({
+    username: '',
+    password: '',
+    password2: '',
+    email: '',
+    verification_code: '',
+    wechat_verification_code: '',
+  });
+  const { username, password, password2 } = inputs;
+  const [showEmailVerification, setShowEmailVerification] = useState(false);
+  const [userState, userDispatch] = useContext(UserContext);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
+  const [showEmailRegister, setShowEmailRegister] = useState(false);
+  const [status, setStatus] = useState({});
+  const [wechatLoading, setWechatLoading] = useState(false);
+  const [githubLoading, setGithubLoading] = useState(false);
+  const [oidcLoading, setOidcLoading] = useState(false);
+  const [linuxdoLoading, setLinuxdoLoading] = useState(false);
+  const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
+  const [registerLoading, setRegisterLoading] = useState(false);
+  const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
+  const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
+  const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
+  let navigate = useNavigate();
+
+  const logo = getLogo();
+  const systemName = getSystemName();
+
+  let affCode = new URLSearchParams(window.location.search).get('aff');
+  if (affCode) {
+    localStorage.setItem('aff', affCode);
+  }
+
+  useEffect(() => {
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      setStatus(status);
+      setShowEmailVerification(status.email_verification);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+  }, []);
+
+  const onWeChatLoginClicked = () => {
+    setWechatLoading(true);
+    setShowWeChatLoginModal(true);
+    setWechatLoading(false);
+  };
+
+  const onSubmitWeChatVerificationCode = async () => {
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setWechatCodeSubmitLoading(true);
+    try {
+      const res = await API.get(
+        `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
+      );
+      const { success, message, data } = res.data;
+      if (success) {
+        userDispatch({ type: 'login', payload: data });
+        localStorage.setItem('user', JSON.stringify(data));
+        setUserData(data);
+        updateAPI();
+        navigate('/');
+        showSuccess('登录成功!');
+        setShowWeChatLoginModal(false);
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError('登录失败,请重试');
+    } finally {
+      setWechatCodeSubmitLoading(false);
+    }
+  };
+
+  function handleChange(name, value) {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  }
+
+  async function handleSubmit(e) {
+    if (password.length < 8) {
+      showInfo('密码长度不得小于 8 位!');
+      return;
+    }
+    if (password !== password2) {
+      showInfo('两次输入的密码不一致');
+      return;
+    }
+    if (username && password) {
+      if (turnstileEnabled && turnstileToken === '') {
+        showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+        return;
+      }
+      setRegisterLoading(true);
+      try {
+        if (!affCode) {
+          affCode = localStorage.getItem('aff');
+        }
+        inputs.aff_code = affCode;
+        const res = await API.post(
+          `/api/user/register?turnstile=${turnstileToken}`,
+          inputs,
+        );
+        const { success, message } = res.data;
+        if (success) {
+          navigate('/login');
+          showSuccess('注册成功!');
+        } else {
+          showError(message);
+        }
+      } catch (error) {
+        showError('注册失败,请重试');
+      } finally {
+        setRegisterLoading(false);
+      }
+    }
+  }
+
+  const sendVerificationCode = async () => {
+    if (inputs.email === '') return;
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setVerificationCodeLoading(true);
+    try {
+      const res = await API.get(
+        `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
+      );
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess('验证码发送成功,请检查你的邮箱!');
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError('发送验证码失败,请重试');
+    } finally {
+      setVerificationCodeLoading(false);
+    }
+  };
+
+  const handleGitHubClick = () => {
+    setGithubLoading(true);
+    try {
+      onGitHubOAuthClicked(status.github_client_id);
+    } finally {
+      setTimeout(() => setGithubLoading(false), 3000);
+    }
+  };
+
+  const handleOIDCClick = () => {
+    setOidcLoading(true);
+    try {
+      onOIDCClicked(
+        status.oidc_authorization_endpoint,
+        status.oidc_client_id
+      );
+    } finally {
+      setTimeout(() => setOidcLoading(false), 3000);
+    }
+  };
+
+  const handleLinuxDOClick = () => {
+    setLinuxdoLoading(true);
+    try {
+      onLinuxDOOAuthClicked(status.linuxdo_client_id);
+    } finally {
+      setTimeout(() => setLinuxdoLoading(false), 3000);
+    }
+  };
+
+  const handleEmailRegisterClick = () => {
+    setEmailRegisterLoading(true);
+    setShowEmailRegister(true);
+    setEmailRegisterLoading(false);
+  };
+
+  const handleOtherRegisterOptionsClick = () => {
+    setOtherRegisterOptionsLoading(true);
+    setShowEmailRegister(false);
+    setOtherRegisterOptionsLoading(false);
+  };
+
+  const onTelegramLoginClicked = async (response) => {
+    const fields = [
+      'id',
+      'first_name',
+      'last_name',
+      'username',
+      'photo_url',
+      'auth_date',
+      'hash',
+      'lang',
+    ];
+    const params = {};
+    fields.forEach((field) => {
+      if (response[field]) {
+        params[field] = response[field];
+      }
+    });
+    try {
+      const res = await API.get(`/api/oauth/telegram/login`, { params });
+      const { success, message, data } = res.data;
+      if (success) {
+        userDispatch({ type: 'login', payload: data });
+        localStorage.setItem('user', JSON.stringify(data));
+        showSuccess('登录成功!');
+        setUserData(data);
+        updateAPI();
+        navigate('/');
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError('登录失败,请重试');
+    }
+  };
+
+  const renderOAuthOptions = () => {
+    return (
+      <div className="flex flex-col items-center">
+        <div className="w-full max-w-md">
+          <div className="flex items-center justify-center mb-6 gap-2">
+            <img src={logo} alt="Logo" className="h-10 rounded-full" />
+            <Title heading={3} className='!text-white'>{systemName}</Title>
+          </div>
+
+          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+            <div className="flex justify-center pt-6 pb-2">
+              <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
+            </div>
+            <div className="px-2 py-8">
+              <div className="space-y-3">
+                {status.wechat_login && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
+                    size="large"
+                    onClick={onWeChatLoginClicked}
+                    loading={wechatLoading}
+                  >
+                    <span className="ml-3">{t('使用 微信 继续')}</span>
+                  </Button>
+                )}
+
+                {status.github_oauth && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<IconGithubLogo size="large" />}
+                    size="large"
+                    onClick={handleGitHubClick}
+                    loading={githubLoading}
+                  >
+                    <span className="ml-3">{t('使用 GitHub 继续')}</span>
+                  </Button>
+                )}
+
+                {status.oidc_enabled && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<OIDCIcon style={{ color: '#1877F2' }} />}
+                    size="large"
+                    onClick={handleOIDCClick}
+                    loading={oidcLoading}
+                  >
+                    <span className="ml-3">{t('使用 OIDC 继续')}</span>
+                  </Button>
+                )}
+
+                {status.linuxdo_oauth && (
+                  <Button
+                    theme='outline'
+                    className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
+                    type="tertiary"
+                    icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
+                    size="large"
+                    onClick={handleLinuxDOClick}
+                    loading={linuxdoLoading}
+                  >
+                    <span className="ml-3">{t('使用 LinuxDO 继续')}</span>
+                  </Button>
+                )}
+
+                {status.telegram_oauth && (
+                  <div className="flex justify-center my-2">
+                    <TelegramLoginButton
+                      dataOnauth={onTelegramLoginClicked}
+                      botName={status.telegram_bot_name}
+                    />
+                  </div>
+                )}
+
+                <Divider margin='12px' align='center'>
+                  {t('或')}
+                </Divider>
+
+                <Button
+                  theme="solid"
+                  type="primary"
+                  className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
+                  icon={<IconMail size="large" />}
+                  size="large"
+                  onClick={handleEmailRegisterClick}
+                  loading={emailRegisterLoading}
+                >
+                  <span className="ml-3">{t('使用 用户名 注册')}</span>
+                </Button>
+              </div>
+
+              <div className="mt-6 text-center text-sm">
+                <Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
+              </div>
+            </div>
+          </Card>
+        </div>
+      </div>
+    );
+  };
+
+  const renderEmailRegisterForm = () => {
+    return (
+      <div className="flex flex-col items-center">
+        <div className="w-full max-w-md">
+          <div className="flex items-center justify-center mb-6 gap-2">
+            <img src={logo} alt="Logo" className="h-10 rounded-full" />
+            <Title heading={3} className='!text-white'>{systemName}</Title>
+          </div>
+
+          <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
+            <div className="flex justify-center pt-6 pb-2">
+              <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
+            </div>
+            <div className="px-2 py-8">
+              <Form className="space-y-3">
+                <Form.Input
+                  field="username"
+                  label={t('用户名')}
+                  placeholder={t('请输入用户名')}
+                  name="username"
+                  size="large"
+                  className="!rounded-md"
+                  onChange={(value) => handleChange('username', value)}
+                  prefix={<IconUser />}
+                />
+
+                <Form.Input
+                  field="password"
+                  label={t('密码')}
+                  placeholder={t('输入密码,最短 8 位,最长 20 位')}
+                  name="password"
+                  mode="password"
+                  size="large"
+                  className="!rounded-md"
+                  onChange={(value) => handleChange('password', value)}
+                  prefix={<IconLock />}
+                />
+
+                <Form.Input
+                  field="password2"
+                  label={t('确认密码')}
+                  placeholder={t('确认密码')}
+                  name="password2"
+                  mode="password"
+                  size="large"
+                  className="!rounded-md"
+                  onChange={(value) => handleChange('password2', value)}
+                  prefix={<IconLock />}
+                />
+
+                {showEmailVerification && (
+                  <>
+                    <Form.Input
+                      field="email"
+                      label={t('邮箱')}
+                      placeholder={t('输入邮箱地址')}
+                      name="email"
+                      type="email"
+                      size="large"
+                      className="!rounded-md"
+                      onChange={(value) => handleChange('email', value)}
+                      prefix={<IconMail />}
+                      suffix={
+                        <Button
+                          onClick={sendVerificationCode}
+                          loading={verificationCodeLoading}
+                          size="small"
+                          className="!rounded-md mr-2"
+                        >
+                          {t('获取验证码')}
+                        </Button>
+                      }
+                    />
+                    <Form.Input
+                      field="verification_code"
+                      label={t('验证码')}
+                      placeholder={t('输入验证码')}
+                      name="verification_code"
+                      size="large"
+                      className="!rounded-md"
+                      onChange={(value) => handleChange('verification_code', value)}
+                      prefix={<IconKey />}
+                    />
+                  </>
+                )}
+
+                <div className="space-y-2 pt-2">
+                  <Button
+                    theme="solid"
+                    className="w-full !rounded-full"
+                    type="primary"
+                    htmlType="submit"
+                    size="large"
+                    onClick={handleSubmit}
+                    loading={registerLoading}
+                  >
+                    {t('注册')}
+                  </Button>
+                </div>
+              </Form>
+
+              {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
+                <>
+                  <Divider margin='12px' align='center'>
+                    {t('或')}
+                  </Divider>
+
+                  <div className="mt-4 text-center">
+                    <Button
+                      theme="outline"
+                      type="tertiary"
+                      className="w-full !rounded-full"
+                      size="large"
+                      onClick={handleOtherRegisterOptionsClick}
+                      loading={otherRegisterOptionsLoading}
+                    >
+                      {t('其他注册选项')}
+                    </Button>
+                  </div>
+                </>
+              )}
+
+              <div className="mt-6 text-center text-sm">
+                <Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
+              </div>
+            </div>
+          </Card>
+        </div>
+      </div>
+    );
+  };
+
+  const renderWeChatLoginModal = () => {
+    return (
+      <Modal
+        title={t('微信扫码登录')}
+        visible={showWeChatLoginModal}
+        maskClosable={true}
+        onOk={onSubmitWeChatVerificationCode}
+        onCancel={() => setShowWeChatLoginModal(false)}
+        okText={t('登录')}
+        size="small"
+        centered={true}
+        okButtonProps={{
+          loading: wechatCodeSubmitLoading,
+        }}
+      >
+        <div className="flex flex-col items-center">
+          <img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
+        </div>
+
+        <div className="text-center mb-4">
+          <p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
+        </div>
+
+        <Form size="large">
+          <Form.Input
+            field="wechat_verification_code"
+            placeholder={t('验证码')}
+            label={t('验证码')}
+            value={inputs.wechat_verification_code}
+            onChange={(value) => handleChange('wechat_verification_code', value)}
+          />
+        </Form>
+      </Modal>
+    );
+  };
+
+  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="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)
+          ? renderEmailRegisterForm()
+          : renderOAuthOptions()}
+        {renderWeChatLoginModal()}
+
+        {turnstileEnabled && (
+          <div className="flex justify-center mt-6">
+            <Turnstile
+              sitekey={turnstileSiteKey}
+              onVerify={(token) => {
+                setTurnstileToken(token);
+              }}
+            />
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default RegisterForm;

+ 24 - 0
web/src/components/common/Loading.js

@@ -0,0 +1,24 @@
+import React from 'react';
+import { Spin } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+
+const Loading = ({ prompt: name = '', size = 'large' }) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
+      <div className="flex flex-col items-center">
+        <Spin
+          size={size}
+          spinning={true}
+          tip={null}
+        />
+        <span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
+          {name ? t('加载{{name}}中...', { name }) : t('加载中...')}
+        </span>
+      </div>
+    </div>
+  );
+};
+
+export default Loading;

+ 0 - 0
web/src/components/LinuxDoIcon.js → web/src/components/common/logo/LinuxDoIcon.js


+ 2 - 2
web/src/components/OIDCIcon.js → web/src/components/common/logo/OIDCIcon.js

@@ -11,8 +11,8 @@ const OIDCIcon = (props) => {
         version='1.1'
         xmlns='http://www.w3.org/2000/svg'
         p-id='10969'
-        width='1em'
-        height='1em'
+        width='20'
+        height='20'
       >
         <path
           d='M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z'

+ 2 - 2
web/src/components/WeChatIcon.js → web/src/components/common/logo/WeChatIcon.js

@@ -11,8 +11,8 @@ const WeChatIcon = () => {
         version='1.1'
         xmlns='http://www.w3.org/2000/svg'
         p-id='5091'
-        width='16'
-        height='16'
+        width='20'
+        height='20'
       >
         <path
           d='M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z'

+ 513 - 0
web/src/components/common/markdown/MarkdownRenderer.js

@@ -0,0 +1,513 @@
+import ReactMarkdown from 'react-markdown';
+import 'katex/dist/katex.min.css';
+import 'highlight.js/styles/github.css';
+import './markdown.css';
+import RemarkMath from 'remark-math';
+import RemarkBreaks from 'remark-breaks';
+import RehypeKatex from 'rehype-katex';
+import RemarkGfm from 'remark-gfm';
+import RehypeHighlight from 'rehype-highlight';
+import { useRef, useState, useEffect, useMemo } from 'react';
+import mermaid from 'mermaid';
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import clsx from 'clsx';
+import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
+import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
+import { IconCopy } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+
+mermaid.initialize({
+  startOnLoad: false,
+  theme: 'default',
+  securityLevel: 'loose',
+});
+
+export function Mermaid(props) {
+  const ref = useRef(null);
+  const [hasError, setHasError] = useState(false);
+
+  useEffect(() => {
+    if (props.code && ref.current) {
+      mermaid
+        .run({
+          nodes: [ref.current],
+          suppressErrors: true,
+        })
+        .catch((e) => {
+          setHasError(true);
+          console.error('[Mermaid] ', e.message);
+        });
+    }
+  }, [props.code]);
+
+  function viewSvgInNewWindow() {
+    const svg = ref.current?.querySelector('svg');
+    if (!svg) return;
+    const text = new XMLSerializer().serializeToString(svg);
+    const blob = new Blob([text], { type: 'image/svg+xml' });
+    const url = URL.createObjectURL(blob);
+    window.open(url, '_blank');
+  }
+
+  if (hasError) {
+    return null;
+  }
+
+  return (
+    <div
+      className={clsx('mermaid-container')}
+      style={{
+        cursor: 'pointer',
+        overflow: 'auto',
+        padding: '12px',
+        border: '1px solid var(--semi-color-border)',
+        borderRadius: '8px',
+        backgroundColor: 'var(--semi-color-bg-1)',
+        margin: '12px 0',
+      }}
+      ref={ref}
+      onClick={() => viewSvgInNewWindow()}
+    >
+      {props.code}
+    </div>
+  );
+}
+
+export function PreCode(props) {
+  const ref = useRef(null);
+  const [mermaidCode, setMermaidCode] = useState('');
+  const [htmlCode, setHtmlCode] = useState('');
+  const { t } = useTranslation();
+
+  const renderArtifacts = useDebouncedCallback(() => {
+    if (!ref.current) return;
+    const mermaidDom = ref.current.querySelector('code.language-mermaid');
+    if (mermaidDom) {
+      setMermaidCode(mermaidDom.innerText);
+    }
+    const htmlDom = ref.current.querySelector('code.language-html');
+    const refText = ref.current.querySelector('code')?.innerText;
+    if (htmlDom) {
+      setHtmlCode(htmlDom.innerText);
+    } else if (
+      refText?.startsWith('<!DOCTYPE') ||
+      refText?.startsWith('<svg') ||
+      refText?.startsWith('<?xml')
+    ) {
+      setHtmlCode(refText);
+    }
+  }, 600);
+
+  // 处理代码块的换行
+  useEffect(() => {
+    if (ref.current) {
+      const codeElements = ref.current.querySelectorAll('code');
+      const wrapLanguages = [
+        '',
+        'md',
+        'markdown',
+        'text',
+        'txt',
+        'plaintext',
+        'tex',
+        'latex',
+      ];
+      codeElements.forEach((codeElement) => {
+        let languageClass = codeElement.className.match(/language-(\w+)/);
+        let name = languageClass ? languageClass[1] : '';
+        if (wrapLanguages.includes(name)) {
+          codeElement.style.whiteSpace = 'pre-wrap';
+        }
+      });
+      setTimeout(renderArtifacts, 1);
+    }
+  }, []);
+
+  return (
+    <>
+      <pre
+        ref={ref}
+        style={{
+          position: 'relative',
+          backgroundColor: 'var(--semi-color-fill-0)',
+          border: '1px solid var(--semi-color-border)',
+          borderRadius: '6px',
+          padding: '12px',
+          margin: '12px 0',
+          overflow: 'auto',
+          fontSize: '14px',
+          lineHeight: '1.4',
+        }}
+      >
+        <div
+          className="copy-code-button"
+          style={{
+            position: 'absolute',
+            top: '8px',
+            right: '8px',
+            display: 'flex',
+            gap: '4px',
+            zIndex: 10,
+            opacity: 0,
+            transition: 'opacity 0.2s ease',
+          }}
+        >
+          <Tooltip content={t('复制代码')}>
+            <Button
+              size="small"
+              theme="borderless"
+              icon={<IconCopy />}
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+                if (ref.current) {
+                  const code = ref.current.querySelector('code')?.innerText ?? '';
+                  copy(code).then((success) => {
+                    if (success) {
+                      Toast.success(t('代码已复制到剪贴板'));
+                    } else {
+                      Toast.error(t('复制失败,请手动复制'));
+                    }
+                  });
+                }
+              }}
+              style={{
+                padding: '4px',
+                backgroundColor: 'var(--semi-color-bg-2)',
+                borderRadius: '4px',
+                cursor: 'pointer',
+                border: '1px solid var(--semi-color-border)',
+                boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
+              }}
+            />
+          </Tooltip>
+        </div>
+        {props.children}
+      </pre>
+      {mermaidCode.length > 0 && (
+        <Mermaid code={mermaidCode} key={mermaidCode} />
+      )}
+      {htmlCode.length > 0 && (
+        <div
+          style={{
+            border: '1px solid var(--semi-color-border)',
+            borderRadius: '8px',
+            padding: '16px',
+            margin: '12px 0',
+            backgroundColor: 'var(--semi-color-bg-1)',
+          }}
+        >
+          <div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
+            HTML预览:
+          </div>
+          <div dangerouslySetInnerHTML={{ __html: htmlCode }} />
+        </div>
+      )}
+    </>
+  );
+}
+
+function CustomCode(props) {
+  const ref = useRef(null);
+  const [collapsed, setCollapsed] = useState(true);
+  const [showToggle, setShowToggle] = useState(false);
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    if (ref.current) {
+      const codeHeight = ref.current.scrollHeight;
+      setShowToggle(codeHeight > 400);
+      ref.current.scrollTop = ref.current.scrollHeight;
+    }
+  }, [props.children]);
+
+  const toggleCollapsed = () => {
+    setCollapsed((collapsed) => !collapsed);
+  };
+
+  const renderShowMoreButton = () => {
+    if (showToggle && collapsed) {
+      return (
+        <div
+          style={{
+            position: 'absolute',
+            bottom: '8px',
+            right: '8px',
+            left: '8px',
+            display: 'flex',
+            justifyContent: 'center',
+          }}
+        >
+          <Button size="small" onClick={toggleCollapsed} theme="solid">
+            {t('显示更多')}
+          </Button>
+        </div>
+      );
+    }
+    return null;
+  };
+
+  return (
+    <div style={{ position: 'relative' }}>
+      <code
+        className={clsx(props?.className)}
+        ref={ref}
+        style={{
+          maxHeight: collapsed ? '400px' : 'none',
+          overflowY: 'hidden',
+          display: 'block',
+          padding: '8px 12px',
+          backgroundColor: 'var(--semi-color-fill-0)',
+          borderRadius: '4px',
+          fontSize: '13px',
+          lineHeight: '1.4',
+        }}
+      >
+        {props.children}
+      </code>
+      {renderShowMoreButton()}
+    </div>
+  );
+}
+
+function escapeBrackets(text) {
+  const pattern =
+    /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
+  return text.replace(
+    pattern,
+    (match, codeBlock, squareBracket, roundBracket) => {
+      if (codeBlock) {
+        return codeBlock;
+      } else if (squareBracket) {
+        return `$$${squareBracket}$$`;
+      } else if (roundBracket) {
+        return `$${roundBracket}$`;
+      }
+      return match;
+    },
+  );
+}
+
+function tryWrapHtmlCode(text) {
+  // 尝试包装HTML代码
+  if (text.includes('```')) {
+    return text;
+  }
+  return text
+    .replace(
+      /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
+      (match, quoteStart, lang, newLine, doctype) => {
+        return !quoteStart ? '\n```html\n' + doctype : match;
+      },
+    )
+    .replace(
+      /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
+      (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
+        return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match;
+      },
+    );
+}
+
+function _MarkdownContent(props) {
+  const {
+    content,
+    className,
+    animated = false,
+    previousContentLength = 0,
+  } = props;
+
+  const escapedContent = useMemo(() => {
+    return tryWrapHtmlCode(escapeBrackets(content));
+  }, [content]);
+
+  // 判断是否为用户消息
+  const isUserMessage = className && className.includes('user-message');
+
+  const rehypePluginsBase = useMemo(() => {
+    const base = [
+      RehypeKatex,
+      [
+        RehypeHighlight,
+        {
+          detect: false,
+          ignoreMissing: true,
+        },
+      ],
+    ];
+    if (animated) {
+      base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]);
+    }
+    return base;
+  }, [animated, previousContentLength]);
+
+  return (
+    <ReactMarkdown
+      remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
+      rehypePlugins={rehypePluginsBase}
+      components={{
+        pre: PreCode,
+        code: CustomCode,
+        p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
+        a: (aProps) => {
+          const href = aProps.href || '';
+          if (/\.(aac|mp3|opus|wav)$/.test(href)) {
+            return (
+              <figure style={{ margin: '12px 0' }}>
+                <audio controls src={href} style={{ width: '100%' }}></audio>
+              </figure>
+            );
+          }
+          if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
+            return (
+              <video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
+                <source src={href} />
+              </video>
+            );
+          }
+          const isInternal = /^\/#/i.test(href);
+          const target = isInternal ? '_self' : aProps.target ?? '_blank';
+          return (
+            <a
+              {...aProps}
+              target={target}
+              style={{
+                color: isUserMessage ? '#87CEEB' : 'var(--semi-color-primary)',
+                textDecoration: 'none',
+              }}
+              onMouseEnter={(e) => {
+                e.target.style.textDecoration = 'underline';
+              }}
+              onMouseLeave={(e) => {
+                e.target.style.textDecoration = 'none';
+              }}
+            />
+          );
+        },
+        h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
+        h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
+        h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
+        h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
+        h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
+        h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
+        blockquote: (props) => (
+          <blockquote
+            {...props}
+            style={{
+              borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
+              paddingLeft: '16px',
+              margin: '12px 0',
+              backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
+              padding: '8px 16px',
+              borderRadius: '0 4px 4px 0',
+              fontStyle: 'italic',
+              color: isUserMessage ? 'white' : 'inherit',
+            }}
+          />
+        ),
+        ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
+        ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
+        li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
+        table: (props) => (
+          <div style={{ overflow: 'auto', margin: '12px 0' }}>
+            <table
+              {...props}
+              style={{
+                width: '100%',
+                borderCollapse: 'collapse',
+                border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
+                borderRadius: '6px',
+                overflow: 'hidden',
+              }}
+            />
+          </div>
+        ),
+        th: (props) => (
+          <th
+            {...props}
+            style={{
+              padding: '8px 12px',
+              backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
+              border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
+              fontWeight: 'bold',
+              textAlign: 'left',
+              color: isUserMessage ? 'white' : 'inherit',
+            }}
+          />
+        ),
+        td: (props) => (
+          <td
+            {...props}
+            style={{
+              padding: '8px 12px',
+              border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
+              color: isUserMessage ? 'white' : 'inherit',
+            }}
+          />
+        ),
+      }}
+    >
+      {escapedContent}
+    </ReactMarkdown>
+  );
+}
+
+export const MarkdownContent = React.memo(_MarkdownContent);
+
+export function MarkdownRenderer(props) {
+  const {
+    content,
+    loading,
+    fontSize = 14,
+    fontFamily = 'inherit',
+    className,
+    style,
+    animated = false,
+    previousContentLength = 0,
+    ...otherProps
+  } = props;
+
+  return (
+    <div
+      className={clsx('markdown-body', className)}
+      style={{
+        fontSize: `${fontSize}px`,
+        fontFamily: fontFamily,
+        lineHeight: '1.6',
+        color: 'var(--semi-color-text-0)',
+        ...style,
+      }}
+      dir="auto"
+      {...otherProps}
+    >
+      {loading ? (
+        <div style={{
+          display: 'flex',
+          alignItems: 'center',
+          gap: '8px',
+          padding: '16px',
+          color: 'var(--semi-color-text-2)',
+        }}>
+          <div style={{
+            width: '16px',
+            height: '16px',
+            border: '2px solid var(--semi-color-border)',
+            borderTop: '2px solid var(--semi-color-primary)',
+            borderRadius: '50%',
+            animation: 'spin 1s linear infinite',
+          }} />
+          正在渲染...
+        </div>
+      ) : (
+        <MarkdownContent
+          content={content}
+          className={className}
+          animated={animated}
+          previousContentLength={previousContentLength}
+        />
+      )}
+    </div>
+  );
+}
+
+export default MarkdownRenderer; 

+ 444 - 0
web/src/components/common/markdown/markdown.css

@@ -0,0 +1,444 @@
+/* 基础markdown样式 */
+.markdown-body {
+  font-family: inherit;
+  line-height: 1.6;
+  color: var(--semi-color-text-0);
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+  word-break: break-word;
+}
+
+/* 用户消息样式 - 白色字体适配蓝色背景 */
+.user-message {
+  color: white !important;
+}
+
+.user-message .markdown-body {
+  color: white !important;
+}
+
+.user-message h1,
+.user-message h2,
+.user-message h3,
+.user-message h4,
+.user-message h5,
+.user-message h6 {
+  color: white !important;
+}
+
+.user-message p {
+  color: white !important;
+}
+
+.user-message span {
+  color: white !important;
+}
+
+.user-message div {
+  color: white !important;
+}
+
+.user-message li {
+  color: white !important;
+}
+
+.user-message td,
+.user-message th {
+  color: white !important;
+}
+
+.user-message blockquote {
+  color: white !important;
+  border-left-color: rgba(255, 255, 255, 0.5) !important;
+  background-color: rgba(255, 255, 255, 0.1) !important;
+}
+
+.user-message code:not(pre code) {
+  color: #000 !important;
+  background-color: rgba(255, 255, 255, 0.9) !important;
+}
+
+.user-message a {
+  color: #87CEEB !important;
+  /* 浅蓝色链接 */
+}
+
+.user-message a:hover {
+  color: #B0E0E6 !important;
+  /* hover时更浅的蓝色 */
+}
+
+/* 表格在用户消息中的样式 */
+.user-message table {
+  border-color: rgba(255, 255, 255, 0.3) !important;
+}
+
+.user-message th {
+  background-color: rgba(255, 255, 255, 0.2) !important;
+  border-color: rgba(255, 255, 255, 0.3) !important;
+}
+
+.user-message td {
+  border-color: rgba(255, 255, 255, 0.3) !important;
+}
+
+/* 加载动画 */
+@keyframes spin {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+/* 代码高亮主题 - 适配Semi Design */
+.hljs {
+  display: block;
+  overflow-x: auto;
+  padding: 0;
+  background: transparent;
+  color: var(--semi-color-text-0);
+}
+
+.hljs-comment,
+.hljs-quote {
+  color: var(--semi-color-text-2);
+  font-style: italic;
+}
+
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-subst {
+  color: var(--semi-color-primary);
+  font-weight: bold;
+}
+
+.hljs-number,
+.hljs-literal,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-tag .hljs-attr {
+  color: var(--semi-color-warning);
+}
+
+.hljs-string,
+.hljs-doctag {
+  color: var(--semi-color-success);
+}
+
+.hljs-title,
+.hljs-section,
+.hljs-selector-id {
+  color: var(--semi-color-primary);
+  font-weight: bold;
+}
+
+.hljs-subst {
+  font-weight: normal;
+}
+
+.hljs-type,
+.hljs-class .hljs-title {
+  color: var(--semi-color-info);
+  font-weight: bold;
+}
+
+.hljs-tag,
+.hljs-name,
+.hljs-attribute {
+  color: var(--semi-color-primary);
+  font-weight: normal;
+}
+
+.hljs-regexp,
+.hljs-link {
+  color: var(--semi-color-tertiary);
+}
+
+.hljs-symbol,
+.hljs-bullet {
+  color: var(--semi-color-warning);
+}
+
+.hljs-built_in,
+.hljs-builtin-name {
+  color: var(--semi-color-info);
+}
+
+.hljs-meta {
+  color: var(--semi-color-text-2);
+}
+
+.hljs-deletion {
+  background: var(--semi-color-danger-light-default);
+}
+
+.hljs-addition {
+  background: var(--semi-color-success-light-default);
+}
+
+.hljs-emphasis {
+  font-style: italic;
+}
+
+.hljs-strong {
+  font-weight: bold;
+}
+
+/* Mermaid容器样式 */
+.mermaid-container {
+  transition: all 0.2s ease;
+}
+
+.mermaid-container:hover {
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  transform: translateY(-1px);
+}
+
+/* 代码块样式增强 */
+pre {
+  position: relative;
+  font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+  transition: all 0.2s ease;
+}
+
+pre:hover {
+  border-color: var(--semi-color-primary) !important;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+pre:hover .copy-code-button {
+  opacity: 1 !important;
+}
+
+.copy-code-button {
+  opacity: 0;
+  transition: opacity 0.2s ease;
+  z-index: 10;
+  pointer-events: auto;
+}
+
+.copy-code-button:hover {
+  opacity: 1 !important;
+}
+
+.copy-code-button button {
+  pointer-events: auto !important;
+  cursor: pointer !important;
+}
+
+/* 确保按钮可点击 */
+.copy-code-button .semi-button {
+  pointer-events: auto !important;
+  cursor: pointer !important;
+  transition: all 0.2s ease;
+}
+
+.copy-code-button .semi-button:hover {
+  background-color: var(--semi-color-fill-1) !important;
+  border-color: var(--semi-color-primary) !important;
+  transform: scale(1.05);
+}
+
+/* 表格响应式 */
+@media (max-width: 768px) {
+  .markdown-body table {
+    font-size: 12px;
+  }
+
+  .markdown-body th,
+  .markdown-body td {
+    padding: 6px 8px;
+  }
+}
+
+/* 数学公式样式 */
+.katex {
+  font-size: 1em;
+}
+
+.katex-display {
+  margin: 1em 0;
+  text-align: center;
+}
+
+/* 链接hover效果 */
+.markdown-body a {
+  transition: all 0.2s ease;
+}
+
+/* 引用块样式增强 */
+.markdown-body blockquote {
+  position: relative;
+}
+
+.markdown-body blockquote::before {
+  content: '"';
+  position: absolute;
+  left: -8px;
+  top: -8px;
+  font-size: 24px;
+  color: var(--semi-color-primary);
+  opacity: 0.3;
+}
+
+/* 列表样式增强 */
+.markdown-body ul li::marker {
+  color: var(--semi-color-primary);
+}
+
+.markdown-body ol li::marker {
+  color: var(--semi-color-primary);
+  font-weight: bold;
+}
+
+/* 分隔线样式 */
+.markdown-body hr {
+  border: none;
+  height: 1px;
+  background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
+  margin: 24px 0;
+}
+
+/* 图片样式 */
+.markdown-body img {
+  max-width: 100%;
+  height: auto;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  margin: 12px 0;
+}
+
+/* 内联代码样式 */
+.markdown-body code:not(pre code) {
+  background-color: var(--semi-color-fill-1);
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 0.9em;
+  color: var(--semi-color-primary);
+  border: 1px solid var(--semi-color-border);
+}
+
+/* 标题锚点样式 */
+.markdown-body h1:hover,
+.markdown-body h2:hover,
+.markdown-body h3:hover,
+.markdown-body h4:hover,
+.markdown-body h5:hover,
+.markdown-body h6:hover {
+  position: relative;
+}
+
+/* 任务列表样式 */
+.markdown-body input[type="checkbox"] {
+  margin-right: 8px;
+  transform: scale(1.1);
+}
+
+.markdown-body li.task-list-item {
+  list-style: none;
+  margin-left: -20px;
+}
+
+/* 键盘按键样式 */
+.markdown-body kbd {
+  background-color: var(--semi-color-fill-0);
+  border: 1px solid var(--semi-color-border);
+  border-radius: 3px;
+  box-shadow: 0 1px 0 var(--semi-color-border);
+  color: var(--semi-color-text-0);
+  display: inline-block;
+  font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+  font-size: 0.85em;
+  font-weight: 700;
+  line-height: 1;
+  padding: 2px 4px;
+  white-space: nowrap;
+}
+
+/* 详情折叠样式 */
+.markdown-body details {
+  border: 1px solid var(--semi-color-border);
+  border-radius: 6px;
+  padding: 12px;
+  margin: 12px 0;
+}
+
+.markdown-body summary {
+  cursor: pointer;
+  font-weight: bold;
+  color: var(--semi-color-primary);
+  margin-bottom: 8px;
+}
+
+.markdown-body summary:hover {
+  color: var(--semi-color-primary-hover);
+}
+
+/* 脚注样式 */
+.markdown-body .footnote-ref {
+  color: var(--semi-color-primary);
+  text-decoration: none;
+  font-weight: bold;
+}
+
+.markdown-body .footnote-ref:hover {
+  text-decoration: underline;
+}
+
+/* 警告块样式 */
+.markdown-body .warning {
+  background-color: var(--semi-color-warning-light-default);
+  border-left: 4px solid var(--semi-color-warning);
+  padding: 12px 16px;
+  margin: 12px 0;
+  border-radius: 0 6px 6px 0;
+}
+
+.markdown-body .info {
+  background-color: var(--semi-color-info-light-default);
+  border-left: 4px solid var(--semi-color-info);
+  padding: 12px 16px;
+  margin: 12px 0;
+  border-radius: 0 6px 6px 0;
+}
+
+.markdown-body .success {
+  background-color: var(--semi-color-success-light-default);
+  border-left: 4px solid var(--semi-color-success);
+  padding: 12px 16px;
+  margin: 12px 0;
+  border-radius: 0 6px 6px 0;
+}
+
+.markdown-body .danger {
+  background-color: var(--semi-color-danger-light-default);
+  border-left: 4px solid var(--semi-color-danger);
+  padding: 12px 16px;
+  margin: 12px 0;
+  border-radius: 0 6px 6px 0;
+}
+
+@keyframes fade-in {
+  0% {
+    opacity: 0;
+    transform: translateY(6px) scale(0.98);
+    filter: blur(3px);
+  }
+  60% {
+    opacity: 0.85;
+    filter: blur(0.5px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0) scale(1);
+    filter: blur(0);
+  }
+}
+
+.animate-fade-in {
+  animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
+  will-change: opacity, transform;
+}

+ 0 - 28
web/src/components/custom/TextInput.js

@@ -1,28 +0,0 @@
-import { Input, Typography } from '@douyinfe/semi-ui';
-import React from 'react';
-
-const TextInput = ({
-  label,
-  name,
-  value,
-  onChange,
-  placeholder,
-  type = 'text',
-}) => {
-  return (
-    <>
-      <div style={{ marginTop: 10 }}>
-        <Typography.Text strong>{label}</Typography.Text>
-      </div>
-      <Input
-        name={name}
-        placeholder={placeholder}
-        onChange={(value) => onChange(value)}
-        value={value}
-        autoComplete='new-password'
-      />
-    </>
-  );
-};
-
-export default TextInput;

+ 0 - 21
web/src/components/custom/TextNumberInput.js

@@ -1,21 +0,0 @@
-import { Input, InputNumber, Typography } from '@douyinfe/semi-ui';
-import React from 'react';
-
-const TextNumberInput = ({ label, name, value, onChange, placeholder }) => {
-  return (
-    <>
-      <div style={{ marginTop: 10 }}>
-        <Typography.Text strong>{label}</Typography.Text>
-      </div>
-      <InputNumber
-        name={name}
-        placeholder={placeholder}
-        onChange={(value) => onChange(value)}
-        value={value}
-        autoComplete='new-password'
-      />
-    </>
-  );
-};
-
-export default TextNumberInput;

+ 0 - 68
web/src/components/fetchTokenKeys.js

@@ -1,68 +0,0 @@
-// src/hooks/useTokenKeys.js
-import { useEffect, useState } from 'react';
-import { API, showError } from '../helpers';
-
-async function fetchTokenKeys() {
-  try {
-    const response = await API.get('/api/token/?p=0&size=100');
-    const { success, data } = response.data;
-    if (success) {
-      const activeTokens = data.filter((token) => token.status === 1);
-      return activeTokens.map((token) => token.key);
-    } else {
-      throw new Error('Failed to fetch token keys');
-    }
-  } catch (error) {
-    console.error('Error fetching token keys:', error);
-    return [];
-  }
-}
-
-function getServerAddress() {
-  let status = localStorage.getItem('status');
-  let serverAddress = '';
-
-  if (status) {
-    try {
-      status = JSON.parse(status);
-      serverAddress = status.server_address || '';
-    } catch (error) {
-      console.error('Failed to parse status from localStorage:', error);
-    }
-  }
-
-  if (!serverAddress) {
-    serverAddress = window.location.origin;
-  }
-
-  return serverAddress;
-}
-
-export function useTokenKeys(id) {
-  const [keys, setKeys] = useState([]);
-  // const [chatLink, setChatLink] = useState('');
-  const [serverAddress, setServerAddress] = useState('');
-  const [isLoading, setIsLoading] = useState(true);
-
-  useEffect(() => {
-    const loadAllData = async () => {
-      const fetchedKeys = await fetchTokenKeys();
-      if (fetchedKeys.length === 0) {
-        showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
-        setTimeout(() => {
-          window.location.href = '/token';
-        }, 1500); // 延迟 1.5 秒后跳转
-      }
-      setKeys(fetchedKeys);
-      setIsLoading(false);
-      // setChatLink(link);
-
-      const address = getServerAddress();
-      setServerAddress(address);
-    };
-
-    loadAllData();
-  }, []);
-
-  return { keys, serverAddress, isLoading };
-}

+ 114 - 0
web/src/components/layout/Footer.js

@@ -0,0 +1,114 @@
+import React, { useEffect, useState, useMemo, useContext } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Typography } from '@douyinfe/semi-ui';
+import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
+import { StatusContext } from '../../context/Status';
+
+const FooterBar = () => {
+  const { t } = useTranslation();
+  const [footer, setFooter] = useState(getFooterHTML());
+  const systemName = getSystemName();
+  const logo = getLogo();
+  const [statusState] = useContext(StatusContext);
+  const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
+
+  const loadFooter = () => {
+    let footer_html = localStorage.getItem('footer_html');
+    if (footer_html) {
+      setFooter(footer_html);
+    }
+  };
+
+  const currentYear = new Date().getFullYear();
+
+  const customFooter = useMemo(() => (
+    <footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
+      <div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
+      <div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
+
+      {isDemoSiteMode && (
+        <div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
+          <div className="flex-shrink-0">
+            <img
+              src={logo}
+              alt={systemName}
+              className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
+            />
+          </div>
+
+          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
+            <div className="text-left">
+              <p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
+              <div className="flex flex-col gap-4">
+                <a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
+                <a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
+                <a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('功能特性')}</a>
+              </div>
+            </div>
+
+            <div className="text-left">
+              <p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
+              <div className="flex flex-col gap-4">
+                <a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
+                <a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
+                <a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('API 文档')}</a>
+              </div>
+            </div>
+
+            <div className="text-left">
+              <p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
+              <div className="flex flex-col gap-4">
+                <a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">One API</a>
+                <a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
+                <a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
+                <a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">neko-api-key-tool</a>
+              </div>
+            </div>
+
+            <div className="text-left">
+              <p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
+              <div className="flex flex-col gap-4">
+                <a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
+                {/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">VoAPI</a> */}
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
+        <div className="flex flex-wrap items-center gap-2">
+          <Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
+        </div>
+
+        {isDemoSiteMode && (
+          <div className="text-sm">
+            <span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
+            <span className="!text-semi-color-primary">Douyin FE</span>
+            <span className="!text-semi-color-text-1"> & </span>
+            <a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</a>
+          </div>
+        )}
+      </div>
+    </footer>
+  ), [logo, systemName, t, currentYear, isDemoSiteMode]);
+
+  useEffect(() => {
+    loadFooter();
+  }, []);
+
+  return (
+    <div className="w-full">
+      {footer ? (
+        <div
+          className="custom-footer"
+          dangerouslySetInnerHTML={{ __html: footer }}
+        ></div>
+      ) : (
+        customFooter
+      )}
+    </div>
+  );
+};
+
+export default FooterBar;

+ 536 - 0
web/src/components/layout/HeaderBar.js

@@ -0,0 +1,536 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate, useLocation } from 'react-router-dom';
+import { UserContext } from '../../context/User/index.js';
+import { useSetTheme, useTheme } from '../../context/Theme/index.js';
+import { useTranslation } from 'react-i18next';
+import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
+import fireworks from 'react-fireworks';
+import { CN, GB } from 'country-flag-icons/react/3x2';
+import NoticeModal from './NoticeModal.js';
+
+import {
+  IconClose,
+  IconMenu,
+  IconLanguage,
+  IconChevronDown,
+  IconSun,
+  IconMoon,
+  IconExit,
+  IconUserSetting,
+  IconCreditCard,
+  IconKey,
+  IconBell,
+} from '@douyinfe/semi-icons';
+import {
+  Avatar,
+  Button,
+  Dropdown,
+  Tag,
+  Typography,
+  Skeleton,
+} from '@douyinfe/semi-ui';
+import { StatusContext } from '../../context/Status/index.js';
+import { useStyle, styleActions } from '../../context/Style/index.js';
+
+const HeaderBar = () => {
+  const { t, i18n } = useTranslation();
+  const [userState, userDispatch] = useContext(UserContext);
+  const [statusState, statusDispatch] = useContext(StatusContext);
+  const { state: styleState, dispatch: styleDispatch } = useStyle();
+  const [isLoading, setIsLoading] = useState(true);
+  let navigate = useNavigate();
+  const [currentLang, setCurrentLang] = useState(i18n.language);
+  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+  const location = useLocation();
+  const [noticeVisible, setNoticeVisible] = useState(false);
+
+  const systemName = getSystemName();
+  const logo = getLogo();
+  const currentDate = new Date();
+  const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
+
+  const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
+  const docsLink = statusState?.status?.docs_link || '';
+  const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
+
+  const theme = useTheme();
+  const setTheme = useSetTheme();
+
+  const mainNavLinks = [
+    {
+      text: t('首页'),
+      itemKey: 'home',
+      to: '/',
+    },
+    {
+      text: t('控制台'),
+      itemKey: 'console',
+      to: '/console',
+    },
+    {
+      text: t('定价'),
+      itemKey: 'pricing',
+      to: '/pricing',
+    },
+    ...(docsLink
+      ? [
+        {
+          text: t('文档'),
+          itemKey: 'docs',
+          isExternal: true,
+          externalLink: docsLink,
+        },
+      ]
+      : []),
+    {
+      text: t('关于'),
+      itemKey: 'about',
+      to: '/about',
+    },
+  ];
+
+  async function logout() {
+    await API.get('/api/user/logout');
+    showSuccess(t('注销成功!'));
+    userDispatch({ type: 'logout' });
+    localStorage.removeItem('user');
+    navigate('/login');
+    setMobileMenuOpen(false);
+  }
+
+  const handleNewYearClick = () => {
+    fireworks.init('root', {});
+    fireworks.start();
+    setTimeout(() => {
+      fireworks.stop();
+    }, 3000);
+  };
+
+  useEffect(() => {
+    if (theme === 'dark') {
+      document.body.setAttribute('theme-mode', 'dark');
+      document.documentElement.classList.add('dark');
+    } else {
+      document.body.removeAttribute('theme-mode');
+      document.documentElement.classList.remove('dark');
+    }
+
+    const iframe = document.querySelector('iframe');
+    if (iframe) {
+      iframe.contentWindow.postMessage({ themeMode: theme }, '*');
+    }
+
+  }, [theme, isNewYear]);
+
+  useEffect(() => {
+    const handleLanguageChanged = (lng) => {
+      setCurrentLang(lng);
+      const iframe = document.querySelector('iframe');
+      if (iframe) {
+        iframe.contentWindow.postMessage({ lang: lng }, '*');
+      }
+    };
+
+    i18n.on('languageChanged', handleLanguageChanged);
+    return () => {
+      i18n.off('languageChanged', handleLanguageChanged);
+    };
+  }, [i18n]);
+
+  useEffect(() => {
+    const timer = setTimeout(() => {
+      setIsLoading(false);
+    }, 500);
+    return () => clearTimeout(timer);
+  }, []);
+
+  const handleLanguageChange = (lang) => {
+    i18n.changeLanguage(lang);
+    setMobileMenuOpen(false);
+  };
+
+  const handleNavLinkClick = (itemKey) => {
+    if (itemKey === 'home') {
+      styleDispatch(styleActions.setSider(false));
+    }
+    setMobileMenuOpen(false);
+  };
+
+  const renderNavLinks = (isMobileView = false, isLoading = false) => {
+    if (isLoading) {
+      const skeletonLinkClasses = isMobileView
+        ? 'flex items-center gap-1 p-3 w-full rounded-md'
+        : 'flex items-center gap-1 p-2 rounded-md';
+      return Array(4)
+        .fill(null)
+        .map((_, index) => (
+          <div key={index} className={skeletonLinkClasses}>
+            <Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
+          </div>
+        ));
+    }
+
+    return mainNavLinks.map((link) => {
+      const commonLinkClasses = isMobileView
+        ? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold'
+        : 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold';
+
+      const linkContent = (
+        <span>{link.text}</span>
+      );
+
+      if (link.isExternal) {
+        return (
+          <a
+            key={link.itemKey}
+            href={link.externalLink}
+            target='_blank'
+            rel='noopener noreferrer'
+            className={commonLinkClasses}
+            onClick={() => handleNavLinkClick(link.itemKey)}
+          >
+            {linkContent}
+          </a>
+        );
+      }
+
+      let targetPath = link.to;
+      if (link.itemKey === 'console' && !userState.user) {
+        targetPath = '/login';
+      }
+
+      return (
+        <Link
+          key={link.itemKey}
+          to={targetPath}
+          className={commonLinkClasses}
+          onClick={() => handleNavLinkClick(link.itemKey)}
+        >
+          {linkContent}
+        </Link>
+      );
+    });
+  };
+
+  const renderUserArea = () => {
+    if (isLoading) {
+      return (
+        <div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
+          <Skeleton.Avatar size="extra-small" className="shadow-sm" />
+          <div className="ml-1.5 mr-1">
+            <Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
+          </div>
+        </div>
+      );
+    }
+
+    if (userState.user) {
+      return (
+        <Dropdown
+          position="bottomRight"
+          render={
+            <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
+              <Dropdown.Item
+                onClick={() => {
+                  navigate('/console/personal');
+                  setMobileMenuOpen(false);
+                }}
+                className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
+              >
+                <div className="flex items-center gap-2">
+                  <IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
+                  <span>{t('个人设置')}</span>
+                </div>
+              </Dropdown.Item>
+              <Dropdown.Item
+                onClick={() => {
+                  navigate('/console/token');
+                  setMobileMenuOpen(false);
+                }}
+                className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
+              >
+                <div className="flex items-center gap-2">
+                  <IconKey size="small" className="text-gray-500 dark:text-gray-400" />
+                  <span>{t('API令牌')}</span>
+                </div>
+              </Dropdown.Item>
+              <Dropdown.Item
+                onClick={() => {
+                  navigate('/console/topup');
+                  setMobileMenuOpen(false);
+                }}
+                className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
+              >
+                <div className="flex items-center gap-2">
+                  <IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
+                  <span>{t('钱包')}</span>
+                </div>
+              </Dropdown.Item>
+              <Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
+                <div className="flex items-center gap-2">
+                  <IconExit size="small" className="text-gray-500 dark:text-gray-400" />
+                  <span>{t('退出')}</span>
+                </div>
+              </Dropdown.Item>
+            </Dropdown.Menu>
+          }
+        >
+          <Button
+            theme="borderless"
+            type="tertiary"
+            className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
+          >
+            <Avatar
+              size="extra-small"
+              color={stringToColor(userState.user.username)}
+              className="mr-1"
+            >
+              {userState.user.username[0].toUpperCase()}
+            </Avatar>
+            <span className="hidden md:inline">
+              <Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
+                {userState.user.username}
+              </Typography.Text>
+            </span>
+            <IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
+          </Button>
+        </Dropdown>
+      );
+    } else {
+      const showRegisterButton = !isSelfUseMode;
+
+      const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
+
+      const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
+      let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
+
+      let registerButtonClasses = `${commonSizingAndLayoutClass}`;
+
+      const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
+      const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
+
+      if (showRegisterButton) {
+        if (styleState.isMobile) {
+          loginButtonClasses += " !rounded-full";
+        } else {
+          loginButtonClasses += " !rounded-l-full !rounded-r-none";
+        }
+        registerButtonClasses += " !rounded-r-full !rounded-l-none";
+      } else {
+        loginButtonClasses += " !rounded-full";
+      }
+
+      return (
+        <div className="flex items-center">
+          <Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
+            <Button
+              theme="borderless"
+              type="tertiary"
+              className={loginButtonClasses}
+            >
+              <span className={loginButtonTextSpanClass}>
+                {t('登录')}
+              </span>
+            </Button>
+          </Link>
+          {showRegisterButton && (
+            <div className="hidden md:block">
+              <Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
+                <Button
+                  theme="solid"
+                  type="primary"
+                  className={registerButtonClasses}
+                >
+                  <span className={registerButtonTextSpanClass}>
+                    {t('注册')}
+                  </span>
+                </Button>
+              </Link>
+            </div>
+          )}
+        </div>
+      );
+    }
+  };
+
+  // 检查当前路由是否以/console开头
+  const isConsoleRoute = location.pathname.startsWith('/console');
+
+  return (
+    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
+      <NoticeModal
+        visible={noticeVisible}
+        onClose={() => setNoticeVisible(false)}
+        isMobile={styleState.isMobile}
+      />
+      <div className="w-full px-4">
+        <div className="flex items-center justify-between h-16">
+          <div className="flex items-center">
+            <div className="md:hidden">
+              <Button
+                icon={
+                  isConsoleRoute
+                    ? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
+                    : (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
+                }
+                aria-label={
+                  isConsoleRoute
+                    ? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
+                    : (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
+                }
+                onClick={() => {
+                  if (isConsoleRoute) {
+                    // 控制侧边栏的显示/隐藏,无论是否移动设备
+                    styleDispatch(styleActions.toggleSider());
+                  } else {
+                    // 控制HeaderBar自己的移动菜单
+                    setMobileMenuOpen(!mobileMenuOpen);
+                  }
+                }}
+                theme="borderless"
+                type="tertiary"
+                className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
+              />
+            </div>
+            <Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
+              {isLoading ? (
+                <Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
+              ) : (
+                <img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
+              )}
+              <div className="hidden md:flex items-center gap-2">
+                <div className="flex items-center gap-2">
+                  {isLoading ? (
+                    <Skeleton.Title style={{ width: 120, height: 24 }} />
+                  ) : (
+                    <Typography.Title heading={4} className="!text-lg !font-semibold !mb-0 
+                                                          bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
+                                                          bg-clip-text text-transparent">
+                      {systemName}
+                    </Typography.Title>
+                  )}
+                  {(isSelfUseMode || isDemoSiteMode) && !isLoading && (
+                    <Tag
+                      color={isSelfUseMode ? 'purple' : 'blue'}
+                      className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
+                      size="small"
+                      shape='circle'
+                    >
+                      {isSelfUseMode ? t('自用模式') : t('演示站点')}
+                    </Tag>
+                  )}
+                </div>
+              </div>
+            </Link>
+            {(isSelfUseMode || isDemoSiteMode) && !isLoading && (
+              <div className="md:hidden">
+                <Tag
+                  color={isSelfUseMode ? 'purple' : 'blue'}
+                  className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
+                  size="small"
+                  shape='circle'
+                >
+                  {isSelfUseMode ? t('自用模式') : t('演示站点')}
+                </Tag>
+              </div>
+            )}
+
+            <nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
+              {renderNavLinks(false, isLoading)}
+            </nav>
+          </div>
+
+          <div className="flex items-center gap-2 md:gap-3">
+            {isNewYear && (
+              <Dropdown
+                position="bottomRight"
+                render={
+                  <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
+                    <Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
+                      Happy New Year!!! 🎉
+                    </Dropdown.Item>
+                  </Dropdown.Menu>
+                }
+              >
+                <Button
+                  theme="borderless"
+                  type="tertiary"
+                  icon={<span className="text-xl">🎉</span>}
+                  aria-label="New Year"
+                  className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
+                />
+              </Dropdown>
+            )}
+
+            <Button
+              icon={<IconBell className="text-lg" />}
+              aria-label={t('系统公告')}
+              onClick={() => setNoticeVisible(true)}
+              theme="borderless"
+              type="tertiary"
+              className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
+            />
+
+            <Button
+              icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
+              aria-label={t('切换主题')}
+              onClick={() => setTheme(theme === 'dark' ? false : true)}
+              theme="borderless"
+              type="tertiary"
+              className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
+            />
+
+            <Dropdown
+              position="bottomRight"
+              render={
+                <Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
+                  <Dropdown.Item
+                    onClick={() => handleLanguageChange('zh')}
+                    className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
+                  >
+                    <CN title="中文" className="!w-5 !h-auto" />
+                    <span>中文</span>
+                  </Dropdown.Item>
+                  <Dropdown.Item
+                    onClick={() => handleLanguageChange('en')}
+                    className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
+                  >
+                    <GB title="English" className="!w-5 !h-auto" />
+                    <span>English</span>
+                  </Dropdown.Item>
+                </Dropdown.Menu>
+              }
+            >
+              <Button
+                icon={<IconLanguage className="text-lg" />}
+                aria-label={t('切换语言')}
+                theme="borderless"
+                type="tertiary"
+                className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
+              />
+            </Dropdown>
+
+            {renderUserArea()}
+          </div>
+        </div>
+      </div>
+
+      <div className="md:hidden">
+        <div
+          className={`
+            absolute top-16 left-0 right-0 bg-semi-color-bg-0 
+            shadow-lg p-3
+            transform transition-all duration-300 ease-in-out
+            ${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
+          `}
+        >
+          <nav className="flex flex-col gap-1">
+            {renderNavLinks(true, isLoading)}
+          </nav>
+        </div>
+      </div>
+    </header>
+  );
+};
+
+export default HeaderBar;

+ 94 - 0
web/src/components/layout/NoticeModal.js

@@ -0,0 +1,94 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Modal, Empty } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { API, showError } from '../../helpers';
+import { marked } from 'marked';
+import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
+
+const NoticeModal = ({ visible, onClose, isMobile }) => {
+  const { t } = useTranslation();
+  const [noticeContent, setNoticeContent] = useState('');
+  const [loading, setLoading] = useState(false);
+
+  const handleCloseTodayNotice = () => {
+    const today = new Date().toDateString();
+    localStorage.setItem('notice_close_date', today);
+    onClose();
+  };
+
+  const displayNotice = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/notice');
+      const { success, message, data } = res.data;
+      if (success) {
+        if (data !== '') {
+          const htmlNotice = marked.parse(data);
+          setNoticeContent(htmlNotice);
+        } else {
+          setNoticeContent('');
+        }
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError(error.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (visible) {
+      displayNotice();
+    }
+  }, [visible]);
+
+  const renderContent = () => {
+    if (loading) {
+      return <div className="py-12"><Empty description={t('加载中...')} /></div>;
+    }
+
+    if (!noticeContent) {
+      return (
+        <div className="py-12">
+          <Empty
+            image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
+            darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
+            description={t('暂无公告')}
+          />
+        </div>
+      );
+    }
+
+    return (
+      <div
+        dangerouslySetInnerHTML={{ __html: noticeContent }}
+        className="max-h-[60vh] overflow-y-auto pr-2"
+        style={{
+          scrollbarWidth: 'thin',
+          scrollbarColor: 'var(--semi-color-tertiary) transparent'
+        }}
+      />
+    );
+  };
+
+  return (
+    <Modal
+      title={t('系统公告')}
+      visible={visible}
+      onCancel={onClose}
+      footer={(
+        <div className="flex justify-end">
+          <Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
+          <Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
+        </div>
+      )}
+      size={isMobile ? 'full-width' : 'large'}
+    >
+      {renderContent()}
+    </Modal>
+  );
+};
+
+export default NoticeModal; 

+ 31 - 32
web/src/components/PageLayout.js → web/src/components/layout/PageLayout.js

@@ -1,23 +1,30 @@
 import HeaderBar from './HeaderBar.js';
 import { Layout } from '@douyinfe/semi-ui';
 import SiderBar from './SiderBar.js';
-import App from '../App.js';
+import App from '../../App.js';
 import FooterBar from './Footer.js';
 import { ToastContainer } from 'react-toastify';
 import React, { useContext, useEffect } from 'react';
-import { StyleContext } from '../context/Style/index.js';
+import { useStyle } from '../../context/Style/index.js';
 import { useTranslation } from 'react-i18next';
-import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
-import { setStatusData } from '../helpers/data.js';
-import { UserContext } from '../context/User/index.js';
-import { StatusContext } from '../context/Status/index.js';
+import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
+import { UserContext } from '../../context/User/index.js';
+import { StatusContext } from '../../context/Status/index.js';
+import { useLocation } from 'react-router-dom';
 const { Sider, Content, Header, Footer } = Layout;
 
 const PageLayout = () => {
   const [userState, userDispatch] = useContext(UserContext);
   const [statusState, statusDispatch] = useContext(StatusContext);
-  const [styleState, styleDispatch] = useContext(StyleContext);
+  const { state: styleState } = useStyle();
   const { i18n } = useTranslation();
+  const location = useLocation();
+
+  const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat');
+
+  const shouldInnerPadding = location.pathname.includes('/console') &&
+    !location.pathname.startsWith('/console/chat') &&
+    location.pathname !== '/console/playground';
 
   const loadUser = () => {
     let user = localStorage.getItem('user');
@@ -61,15 +68,8 @@ const PageLayout = () => {
     if (savedLang) {
       i18n.changeLanguage(savedLang);
     }
-
-    // 默认显示侧边栏
-    styleDispatch({ type: 'SET_SIDER', payload: true });
   }, [i18n]);
 
-  // 获取侧边栏折叠状态
-  const isSidebarCollapsed =
-    localStorage.getItem('default_collapse_sidebar') === 'true';
-
   return (
     <Layout
       style={{
@@ -84,19 +84,18 @@ const PageLayout = () => {
           padding: 0,
           height: 'auto',
           lineHeight: 'normal',
-          position: styleState.isMobile ? 'sticky' : 'fixed',
+          position: 'fixed',
           width: '100%',
           top: 0,
           zIndex: 100,
-          boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)',
         }}
       >
         <HeaderBar />
       </Header>
       <Layout
         style={{
-          marginTop: styleState.isMobile ? '0' : '56px',
-          height: styleState.isMobile ? 'auto' : 'calc(100vh - 56px)',
+          marginTop: '64px',
+          height: 'calc(100vh - 64px)',
           overflow: styleState.isMobile ? 'visible' : 'auto',
           display: 'flex',
           flexDirection: 'column',
@@ -107,13 +106,11 @@ const PageLayout = () => {
             style={{
               position: 'fixed',
               left: 0,
-              top: '56px',
+              top: '64px',
               zIndex: 99,
-              background: 'var(--semi-color-bg-1)',
-              boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
               border: 'none',
               paddingRight: '0',
-              height: 'calc(100vh - 56px)',
+              height: 'calc(100vh - 64px)',
             }}
           >
             <SiderBar />
@@ -126,7 +123,7 @@ const PageLayout = () => {
               : styleState.showSider
                 ? styleState.siderCollapsed
                   ? '60px'
-                  : '200px'
+                  : '180px'
                 : '0',
             transition: 'margin-left 0.3s ease',
             flex: '1 1 auto',
@@ -139,21 +136,23 @@ const PageLayout = () => {
               flex: '1 0 auto',
               overflowY: styleState.isMobile ? 'visible' : 'auto',
               WebkitOverflowScrolling: 'touch',
-              padding: styleState.shouldInnerPadding ? '24px' : '0',
+              padding: shouldInnerPadding ? '24px' : '0',
               position: 'relative',
               marginTop: styleState.isMobile ? '2px' : '0',
             }}
           >
             <App />
           </Content>
-          <Layout.Footer
-            style={{
-              flex: '0 0 auto',
-              width: '100%',
-            }}
-          >
-            <FooterBar />
-          </Layout.Footer>
+          {!shouldHideFooter && (
+            <Layout.Footer
+              style={{
+                flex: '0 0 auto',
+                width: '100%',
+              }}
+            >
+              <FooterBar />
+            </Layout.Footer>
+          )}
         </Layout>
       </Layout>
       <ToastContainer />

+ 1 - 1
web/src/components/SetupCheck.js → web/src/components/layout/SetupCheck.js

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect } from 'react';
 import { Navigate, useLocation } from 'react-router-dom';
-import { StatusContext } from '../context/Status';
+import { StatusContext } from '../../context/Status';
 
 const SetupCheck = ({ children }) => {
   const [statusState] = useContext(StatusContext);

+ 448 - 0
web/src/components/layout/SiderBar.js

@@ -0,0 +1,448 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
+import { ChevronLeft } from 'lucide-react';
+import { useStyle, styleActions } from '../../context/Style/index.js';
+import {
+  isAdmin,
+  isRoot,
+  showError
+} from '../../helpers/index.js';
+
+import {
+  Nav,
+  Divider,
+  Tooltip,
+} from '@douyinfe/semi-ui';
+
+const routerMap = {
+  home: '/',
+  channel: '/console/channel',
+  token: '/console/token',
+  redemption: '/console/redemption',
+  topup: '/console/topup',
+  user: '/console/user',
+  log: '/console/log',
+  midjourney: '/console/midjourney',
+  setting: '/console/setting',
+  about: '/about',
+  detail: '/console',
+  pricing: '/pricing',
+  task: '/console/task',
+  playground: '/console/playground',
+  personal: '/console/personal',
+};
+
+const SiderBar = () => {
+  const { t } = useTranslation();
+  const { state: styleState, dispatch: styleDispatch } = useStyle();
+
+  const [selectedKeys, setSelectedKeys] = useState(['home']);
+  const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
+  const [chatItems, setChatItems] = useState([]);
+  const [openedKeys, setOpenedKeys] = useState([]);
+  const location = useLocation();
+  const [routerMapState, setRouterMapState] = useState(routerMap);
+
+  const workspaceItems = useMemo(
+    () => [
+      {
+        text: t('数据看板'),
+        itemKey: 'detail',
+        to: '/detail',
+        className:
+          localStorage.getItem('enable_data_export') === 'true'
+            ? ''
+            : 'tableHiddle',
+      },
+      {
+        text: t('API令牌'),
+        itemKey: 'token',
+        to: '/token',
+      },
+      {
+        text: t('使用日志'),
+        itemKey: 'log',
+        to: '/log',
+      },
+      {
+        text: t('绘图日志'),
+        itemKey: 'midjourney',
+        to: '/midjourney',
+        className:
+          localStorage.getItem('enable_drawing') === 'true'
+            ? ''
+            : 'tableHiddle',
+      },
+      {
+        text: t('任务日志'),
+        itemKey: 'task',
+        to: '/task',
+        className:
+          localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
+      },
+    ],
+    [
+      localStorage.getItem('enable_data_export'),
+      localStorage.getItem('enable_drawing'),
+      localStorage.getItem('enable_task'),
+      t,
+    ],
+  );
+
+  const financeItems = useMemo(
+    () => [
+      {
+        text: t('钱包'),
+        itemKey: 'topup',
+        to: '/topup',
+      },
+      {
+        text: t('个人设置'),
+        itemKey: 'personal',
+        to: '/personal',
+      },
+    ],
+    [t],
+  );
+
+  const adminItems = useMemo(
+    () => [
+      {
+        text: t('渠道'),
+        itemKey: 'channel',
+        to: '/channel',
+        className: isAdmin() ? '' : 'tableHiddle',
+      },
+      {
+        text: t('兑换码'),
+        itemKey: 'redemption',
+        to: '/redemption',
+        className: isAdmin() ? '' : 'tableHiddle',
+      },
+      {
+        text: t('用户管理'),
+        itemKey: 'user',
+        to: '/user',
+        className: isAdmin() ? '' : 'tableHiddle',
+      },
+      {
+        text: t('系统设置'),
+        itemKey: 'setting',
+        to: '/setting',
+        className: isRoot() ? '' : 'tableHiddle',
+      },
+    ],
+    [isAdmin(), isRoot(), t],
+  );
+
+  const chatMenuItems = useMemo(
+    () => [
+      {
+        text: t('操练场'),
+        itemKey: 'playground',
+        to: '/playground',
+      },
+      {
+        text: t('聊天'),
+        itemKey: 'chat',
+        items: chatItems,
+      },
+    ],
+    [chatItems, t],
+  );
+
+  // 更新路由映射,添加聊天路由
+  const updateRouterMapWithChats = (chats) => {
+    const newRouterMap = { ...routerMap };
+
+    if (Array.isArray(chats) && chats.length > 0) {
+      for (let i = 0; i < chats.length; i++) {
+        newRouterMap['chat' + i] = '/console/chat/' + i;
+      }
+    }
+
+    setRouterMapState(newRouterMap);
+    return newRouterMap;
+  };
+
+  // 加载聊天项
+  useEffect(() => {
+    let chats = localStorage.getItem('chats');
+    if (chats) {
+      try {
+        chats = JSON.parse(chats);
+        if (Array.isArray(chats)) {
+          let chatItems = [];
+          for (let i = 0; i < chats.length; i++) {
+            let chat = {};
+            for (let key in chats[i]) {
+              chat.text = key;
+              chat.itemKey = 'chat' + i;
+              chat.to = '/console/chat/' + i;
+            }
+            chatItems.push(chat);
+          }
+          setChatItems(chatItems);
+          updateRouterMapWithChats(chats);
+        }
+      } catch (e) {
+        console.error(e);
+        showError('聊天数据解析失败');
+      }
+    }
+  }, []);
+
+  // 根据当前路径设置选中的菜单项
+  useEffect(() => {
+    const currentPath = location.pathname;
+    let matchingKey = Object.keys(routerMapState).find(
+      (key) => routerMapState[key] === currentPath,
+    );
+
+    // 处理聊天路由
+    if (!matchingKey && currentPath.startsWith('/console/chat/')) {
+      const chatIndex = currentPath.split('/').pop();
+      if (!isNaN(chatIndex)) {
+        matchingKey = 'chat' + chatIndex;
+      } else {
+        matchingKey = 'chat';
+      }
+    }
+
+    // 如果找到匹配的键,更新选中的键
+    if (matchingKey) {
+      setSelectedKeys([matchingKey]);
+    }
+  }, [location.pathname, routerMapState]);
+
+  // 同步折叠状态
+  useEffect(() => {
+    setIsCollapsed(styleState.siderCollapsed);
+  }, [styleState.siderCollapsed]);
+
+  // 获取菜单项对应的颜色
+  const getItemColor = (itemKey) => {
+    switch (itemKey) {
+      case 'detail': return sidebarIconColors.dashboard;
+      case 'playground': return sidebarIconColors.terminal;
+      case 'chat': return sidebarIconColors.message;
+      case 'token': return sidebarIconColors.key;
+      case 'log': return sidebarIconColors.chart;
+      case 'midjourney': return sidebarIconColors.image;
+      case 'task': return sidebarIconColors.check;
+      case 'topup': return sidebarIconColors.credit;
+      case 'channel': return sidebarIconColors.layers;
+      case 'redemption': return sidebarIconColors.gift;
+      case 'user':
+      case 'personal': return sidebarIconColors.user;
+      case 'setting': return sidebarIconColors.settings;
+      default:
+        // 处理聊天项
+        if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
+        return 'currentColor';
+    }
+  };
+
+  // 渲染自定义菜单项
+  const renderNavItem = (item) => {
+    // 跳过隐藏的项目
+    if (item.className === 'tableHiddle') return null;
+
+    const isSelected = selectedKeys.includes(item.itemKey);
+    const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
+
+    return (
+      <Nav.Item
+        key={item.itemKey}
+        itemKey={item.itemKey}
+        text={
+          <div className="flex items-center">
+            <span className="truncate font-medium text-sm" style={{ color: textColor }}>
+              {item.text}
+            </span>
+          </div>
+        }
+        icon={
+          <div className="sidebar-icon-container flex-shrink-0">
+            {getLucideIcon(item.itemKey, isSelected)}
+          </div>
+        }
+        className={item.className}
+      />
+    );
+  };
+
+  // 渲染子菜单项
+  const renderSubItem = (item) => {
+    if (item.items && item.items.length > 0) {
+      const isSelected = selectedKeys.includes(item.itemKey);
+      const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
+
+      return (
+        <Nav.Sub
+          key={item.itemKey}
+          itemKey={item.itemKey}
+          text={
+            <div className="flex items-center">
+              <span className="truncate font-medium text-sm" style={{ color: textColor }}>
+                {item.text}
+              </span>
+            </div>
+          }
+          icon={
+            <div className="sidebar-icon-container flex-shrink-0">
+              {getLucideIcon(item.itemKey, isSelected)}
+            </div>
+          }
+        >
+          {item.items.map((subItem) => {
+            const isSubSelected = selectedKeys.includes(subItem.itemKey);
+            const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
+
+            return (
+              <Nav.Item
+                key={subItem.itemKey}
+                itemKey={subItem.itemKey}
+                text={
+                  <span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
+                    {subItem.text}
+                  </span>
+                }
+              />
+            );
+          })}
+        </Nav.Sub>
+      );
+    } else {
+      return renderNavItem(item);
+    }
+  };
+
+  return (
+    <div
+      className="sidebar-container"
+      style={{ width: isCollapsed ? '60px' : '180px' }}
+    >
+      <Nav
+        className="sidebar-nav custom-sidebar-nav"
+        defaultIsCollapsed={styleState.siderCollapsed}
+        isCollapsed={isCollapsed}
+        onCollapseChange={(collapsed) => {
+          setIsCollapsed(collapsed);
+          styleDispatch(styleActions.setSiderCollapsed(collapsed));
+
+          // 确保在收起侧边栏时有选中的项目
+          if (selectedKeys.length === 0) {
+            const currentPath = location.pathname;
+            const matchingKey = Object.keys(routerMapState).find(
+              (key) => routerMapState[key] === currentPath,
+            );
+
+            if (matchingKey) {
+              setSelectedKeys([matchingKey]);
+            } else if (currentPath.startsWith('/console/chat/')) {
+              setSelectedKeys(['chat']);
+            } else {
+              setSelectedKeys(['detail']); // 默认选中首页
+            }
+          }
+        }}
+        selectedKeys={selectedKeys}
+        itemStyle="sidebar-nav-item"
+        hoverStyle="sidebar-nav-item:hover"
+        selectedStyle="sidebar-nav-item-selected"
+        renderWrapper={({ itemElement, props }) => {
+          const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
+
+          // 如果没有路由,直接返回元素
+          if (!to) return itemElement;
+
+          return (
+            <Link
+              style={{ textDecoration: 'none' }}
+              to={to}
+            >
+              {itemElement}
+            </Link>
+          );
+        }}
+        onSelect={(key) => {
+          // 如果点击的是已经展开的子菜单的父项,则收起子菜单
+          if (openedKeys.includes(key.itemKey)) {
+            setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
+          }
+
+          setSelectedKeys([key.itemKey]);
+        }}
+        openKeys={openedKeys}
+        onOpenChange={(data) => {
+          setOpenedKeys(data.openKeys);
+        }}
+      >
+        {/* 聊天区域 */}
+        <div className="sidebar-section">
+          {!isCollapsed && (
+            <div className="sidebar-group-label">{t('聊天')}</div>
+          )}
+          {chatMenuItems.map((item) => renderSubItem(item))}
+        </div>
+
+        {/* 控制台区域 */}
+        <Divider className="sidebar-divider" />
+        <div>
+          {!isCollapsed && (
+            <div className="sidebar-group-label">{t('控制台')}</div>
+          )}
+          {workspaceItems.map((item) => renderNavItem(item))}
+        </div>
+
+        {/* 管理员区域 - 只在管理员时显示 */}
+        {isAdmin() && (
+          <>
+            <Divider className="sidebar-divider" />
+            <div>
+              {!isCollapsed && (
+                <div className="sidebar-group-label">{t('管理员')}</div>
+              )}
+              {adminItems.map((item) => renderNavItem(item))}
+            </div>
+          </>
+        )}
+
+        {/* 个人中心区域 */}
+        <Divider className="sidebar-divider" />
+        <div>
+          {!isCollapsed && (
+            <div className="sidebar-group-label">{t('个人中心')}</div>
+          )}
+          {financeItems.map((item) => renderNavItem(item))}
+        </div>
+      </Nav>
+
+      {/* 底部折叠按钮 */}
+      <div
+        className="sidebar-collapse-button"
+        onClick={() => {
+          const newCollapsed = !isCollapsed;
+          setIsCollapsed(newCollapsed);
+          styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
+        }}
+      >
+        <Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
+          <div className="sidebar-collapse-button-inner">
+            <span
+              className="sidebar-collapse-icon-container"
+              style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
+            >
+              <ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
+            </span>
+          </div>
+        </Tooltip>
+      </div>
+    </div>
+  );
+};
+
+export default SiderBar;

+ 113 - 0
web/src/components/playground/ChatArea.js

@@ -0,0 +1,113 @@
+import React from 'react';
+import {
+  Card,
+  Chat,
+  Typography,
+  Button,
+} from '@douyinfe/semi-ui';
+import {
+  MessageSquare,
+  Eye,
+  EyeOff,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import CustomInputRender from './CustomInputRender';
+
+const ChatArea = ({
+  chatRef,
+  message,
+  inputs,
+  styleState,
+  showDebugPanel,
+  roleInfo,
+  onMessageSend,
+  onMessageCopy,
+  onMessageReset,
+  onMessageDelete,
+  onStopGenerator,
+  onClearMessages,
+  onToggleDebugPanel,
+  renderCustomChatContent,
+  renderChatBoxAction,
+}) => {
+  const { t } = useTranslation();
+
+  const renderInputArea = React.useCallback((props) => {
+    return <CustomInputRender {...props} />;
+  }, []);
+
+  return (
+    <Card
+      className="h-full"
+      bordered={false}
+      bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
+    >
+      {/* 聊天头部 */}
+      {styleState.isMobile ? (
+        <div className="pt-4"></div>
+      ) : (
+        <div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
+                <MessageSquare size={20} className="text-white" />
+              </div>
+              <div>
+                <Typography.Title heading={5} className="!text-white mb-0">
+                  {t('AI 对话')}
+                </Typography.Title>
+                <Typography.Text className="!text-white/80 text-sm hidden sm:inline">
+                  {inputs.model || t('选择模型开始对话')}
+                </Typography.Text>
+              </div>
+            </div>
+            <div className="flex items-center gap-2">
+              <Button
+                icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
+                onClick={onToggleDebugPanel}
+                theme="borderless"
+                type="primary"
+                size="small"
+                className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
+              >
+                {showDebugPanel ? t('隐藏调试') : t('显示调试')}
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* 聊天内容区域 */}
+      <div className="flex-1 overflow-hidden">
+        <Chat
+          ref={chatRef}
+          chatBoxRenderConfig={{
+            renderChatBoxContent: renderCustomChatContent,
+            renderChatBoxAction: renderChatBoxAction,
+            renderChatBoxTitle: () => null,
+          }}
+          renderInputArea={renderInputArea}
+          roleConfig={roleInfo}
+          style={{
+            height: '100%',
+            maxWidth: '100%',
+            overflow: 'hidden'
+          }}
+          chats={message}
+          onMessageSend={onMessageSend}
+          onMessageCopy={onMessageCopy}
+          onMessageReset={onMessageReset}
+          onMessageDelete={onMessageDelete}
+          showClearContext
+          showStopGenerate
+          onStopGenerator={onStopGenerator}
+          onClear={onClearMessages}
+          className="h-full"
+          placeholder={t('请输入您的问题...')}
+        />
+      </div>
+    </Card>
+  );
+};
+
+export default ChatArea; 

+ 313 - 0
web/src/components/playground/CodeViewer.js

@@ -0,0 +1,313 @@
+import React, { useState, useMemo, useCallback } from 'react';
+import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
+import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { copy } from '../../helpers';
+
+const PERFORMANCE_CONFIG = {
+  MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
+  PREVIEW_LENGTH: 5000, // 预览长度
+  VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
+};
+
+const codeThemeStyles = {
+  container: {
+    backgroundColor: '#1e1e1e',
+    color: '#d4d4d4',
+    fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
+    fontSize: '13px',
+    lineHeight: '1.4',
+    borderRadius: '8px',
+    border: '1px solid #3c3c3c',
+    position: 'relative',
+    overflow: 'hidden',
+    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
+  },
+  content: {
+    height: '100%',
+    overflowY: 'auto',
+    overflowX: 'auto',
+    padding: '16px',
+    margin: 0,
+    whiteSpace: 'pre',
+    wordBreak: 'normal',
+    background: '#1e1e1e',
+  },
+  actionButton: {
+    position: 'absolute',
+    zIndex: 10,
+    backgroundColor: 'rgba(45, 45, 45, 0.9)',
+    border: '1px solid rgba(255, 255, 255, 0.1)',
+    color: '#d4d4d4',
+    borderRadius: '6px',
+    transition: 'all 0.2s ease',
+  },
+  actionButtonHover: {
+    backgroundColor: 'rgba(60, 60, 60, 0.95)',
+    borderColor: 'rgba(255, 255, 255, 0.2)',
+    transform: 'scale(1.05)',
+  },
+  noContent: {
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    height: '100%',
+    color: '#666',
+    fontSize: '14px',
+    fontStyle: 'italic',
+    backgroundColor: 'var(--semi-color-fill-0)',
+    borderRadius: '8px',
+  },
+  performanceWarning: {
+    padding: '8px 12px',
+    backgroundColor: 'rgba(255, 193, 7, 0.1)',
+    border: '1px solid rgba(255, 193, 7, 0.3)',
+    borderRadius: '6px',
+    color: '#ffc107',
+    fontSize: '12px',
+    marginBottom: '8px',
+    display: 'flex',
+    alignItems: 'center',
+    gap: '8px',
+  },
+};
+
+const highlightJson = (str) => {
+  return str.replace(
+    /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
+    (match) => {
+      let color = '#b5cea8';
+      if (/^"/.test(match)) {
+        color = /:$/.test(match) ? '#9cdcfe' : '#ce9178';
+      } else if (/true|false|null/.test(match)) {
+        color = '#569cd6';
+      }
+      return `<span style="color: ${color}">${match}</span>`;
+    }
+  );
+};
+
+const isJsonLike = (content, language) => {
+  if (language === 'json') return true;
+  const trimmed = content.trim();
+  return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
+    (trimmed.startsWith('[') && trimmed.endsWith(']'));
+};
+
+const formatContent = (content) => {
+  if (!content) return '';
+
+  if (typeof content === 'object') {
+    try {
+      return JSON.stringify(content, null, 2);
+    } catch (e) {
+      return String(content);
+    }
+  }
+
+  if (typeof content === 'string') {
+    try {
+      const parsed = JSON.parse(content);
+      return JSON.stringify(parsed, null, 2);
+    } catch (e) {
+      return content;
+    }
+  }
+
+  return String(content);
+};
+
+const CodeViewer = ({ content, title, language = 'json' }) => {
+  const { t } = useTranslation();
+  const [copied, setCopied] = useState(false);
+  const [isHoveringCopy, setIsHoveringCopy] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [isProcessing, setIsProcessing] = useState(false);
+
+  const formattedContent = useMemo(() => formatContent(content), [content]);
+
+  const contentMetrics = useMemo(() => {
+    const length = formattedContent.length;
+    const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
+    const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
+    return { length, isLarge, isVeryLarge };
+  }, [formattedContent.length]);
+
+  const displayContent = useMemo(() => {
+    if (!contentMetrics.isLarge || isExpanded) {
+      return formattedContent;
+    }
+    return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
+      '\n\n// ... 内容被截断以提升性能 ...';
+  }, [formattedContent, contentMetrics.isLarge, isExpanded]);
+
+  const highlightedContent = useMemo(() => {
+    if (contentMetrics.isVeryLarge && !isExpanded) {
+      return displayContent;
+    }
+
+    if (isJsonLike(displayContent, language)) {
+      return highlightJson(displayContent);
+    }
+
+    return displayContent;
+  }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
+
+  const handleCopy = useCallback(async () => {
+    try {
+      const textToCopy = typeof content === 'object' && content !== null
+        ? JSON.stringify(content, null, 2)
+        : content;
+
+      const success = await copy(textToCopy);
+      setCopied(true);
+      Toast.success(t('已复制到剪贴板'));
+      setTimeout(() => setCopied(false), 2000);
+
+      if (!success) {
+        throw new Error('Copy operation failed');
+      }
+    } catch (err) {
+      Toast.error(t('复制失败'));
+      console.error('Copy failed:', err);
+    }
+  }, [content, t]);
+
+  const handleToggleExpand = useCallback(() => {
+    if (contentMetrics.isVeryLarge && !isExpanded) {
+      setIsProcessing(true);
+      setTimeout(() => {
+        setIsExpanded(true);
+        setIsProcessing(false);
+      }, 100);
+    } else {
+      setIsExpanded(!isExpanded);
+    }
+  }, [isExpanded, contentMetrics.isVeryLarge]);
+
+  if (!content) {
+    const placeholderText = {
+      preview: t('正在构造请求体预览...'),
+      request: t('暂无请求数据'),
+      response: t('暂无响应数据')
+    }[title] || t('暂无数据');
+
+    return (
+      <div style={codeThemeStyles.noContent}>
+        <span>{placeholderText}</span>
+      </div>
+    );
+  }
+
+  const warningTop = contentMetrics.isLarge ? '52px' : '12px';
+  const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
+
+  return (
+    <div style={codeThemeStyles.container} className="h-full">
+      {/* 性能警告 */}
+      {contentMetrics.isLarge && (
+        <div style={codeThemeStyles.performanceWarning}>
+          <span>⚡</span>
+          <span>
+            {contentMetrics.isVeryLarge
+              ? t('内容较大,已启用性能优化模式')
+              : t('内容较大,部分功能可能受限')}
+          </span>
+        </div>
+      )}
+
+      {/* 复制按钮 */}
+      <div
+        style={{
+          ...codeThemeStyles.actionButton,
+          ...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
+          top: warningTop,
+          right: '12px',
+        }}
+        onMouseEnter={() => setIsHoveringCopy(true)}
+        onMouseLeave={() => setIsHoveringCopy(false)}
+      >
+        <Tooltip content={copied ? t('已复制') : t('复制代码')}>
+          <Button
+            icon={<Copy size={14} />}
+            onClick={handleCopy}
+            size="small"
+            theme="borderless"
+            style={{
+              backgroundColor: 'transparent',
+              border: 'none',
+              color: copied ? '#4ade80' : '#d4d4d4',
+              padding: '6px',
+            }}
+          />
+        </Tooltip>
+      </div>
+
+      {/* 代码内容 */}
+      <div
+        style={{
+          ...codeThemeStyles.content,
+          paddingTop: contentPadding,
+        }}
+        className="model-settings-scroll"
+      >
+        {isProcessing ? (
+          <div style={{
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            height: '200px',
+            color: '#888'
+          }}>
+            <div style={{
+              width: '20px',
+              height: '20px',
+              border: '2px solid #444',
+              borderTop: '2px solid #888',
+              borderRadius: '50%',
+              animation: 'spin 1s linear infinite',
+              marginRight: '8px'
+            }} />
+            {t('正在处理大内容...')}
+          </div>
+        ) : (
+          <div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
+        )}
+      </div>
+
+      {/* 展开/收起按钮 */}
+      {contentMetrics.isLarge && !isProcessing && (
+        <div style={{
+          ...codeThemeStyles.actionButton,
+          bottom: '12px',
+          left: '50%',
+          transform: 'translateX(-50%)',
+        }}>
+          <Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
+            <Button
+              icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+              onClick={handleToggleExpand}
+              size="small"
+              theme="borderless"
+              style={{
+                backgroundColor: 'transparent',
+                border: 'none',
+                color: '#d4d4d4',
+                padding: '6px 12px',
+              }}
+            >
+              {isExpanded ? t('收起') : t('展开')}
+              {!isExpanded && (
+                <span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
+                  (+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
+                </span>
+              )}
+            </Button>
+          </Tooltip>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default CodeViewer; 

+ 260 - 0
web/src/components/playground/ConfigManager.js

@@ -0,0 +1,260 @@
+import React, { useRef } from 'react';
+import {
+  Button,
+  Typography,
+  Toast,
+  Modal,
+  Dropdown,
+} from '@douyinfe/semi-ui';
+import {
+  Download,
+  Upload,
+  RotateCcw,
+  Settings2,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
+
+const ConfigManager = ({
+  currentConfig,
+  onConfigImport,
+  onConfigReset,
+  styleState,
+  messages,
+}) => {
+  const { t } = useTranslation();
+  const fileInputRef = useRef(null);
+
+  const handleExport = () => {
+    try {
+      // 在导出前先保存当前配置,确保导出的是最新内容
+      const configWithTimestamp = {
+        ...currentConfig,
+        timestamp: new Date().toISOString(),
+      };
+      localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
+
+      exportConfig(currentConfig, messages);
+      Toast.success({
+        content: t('配置已导出到下载文件夹'),
+        duration: 3,
+      });
+    } catch (error) {
+      Toast.error({
+        content: t('导出配置失败: ') + error.message,
+        duration: 3,
+      });
+    }
+  };
+
+  const handleImportClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleFileChange = async (event) => {
+    const file = event.target.files[0];
+    if (!file) return;
+
+    try {
+      const importedConfig = await importConfig(file);
+
+      Modal.confirm({
+        title: t('确认导入配置'),
+        content: t('导入的配置将覆盖当前设置,是否继续?'),
+        okText: t('确定导入'),
+        cancelText: t('取消'),
+        onOk: () => {
+          onConfigImport(importedConfig);
+          Toast.success({
+            content: t('配置导入成功'),
+            duration: 3,
+          });
+        },
+      });
+    } catch (error) {
+      Toast.error({
+        content: t('导入配置失败: ') + error.message,
+        duration: 3,
+      });
+    } finally {
+      // 重置文件输入,允许重复选择同一文件
+      event.target.value = '';
+    }
+  };
+
+  const handleReset = () => {
+    Modal.confirm({
+      title: t('重置配置'),
+      content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
+      okText: t('确定重置'),
+      cancelText: t('取消'),
+      okButtonProps: {
+        type: 'danger',
+      },
+      onOk: () => {
+        // 询问是否同时重置消息
+        Modal.confirm({
+          title: t('重置选项'),
+          content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
+          okText: t('同时重置消息'),
+          cancelText: t('仅重置配置'),
+          okButtonProps: {
+            type: 'danger',
+          },
+          onOk: () => {
+            clearConfig();
+            onConfigReset({ resetMessages: true });
+            Toast.success({
+              content: t('配置和消息已全部重置'),
+              duration: 3,
+            });
+          },
+          onCancel: () => {
+            clearConfig();
+            onConfigReset({ resetMessages: false });
+            Toast.success({
+              content: t('配置已重置,对话消息已保留'),
+              duration: 3,
+            });
+          },
+        });
+      },
+    });
+  };
+
+  const getConfigStatus = () => {
+    if (hasStoredConfig()) {
+      const timestamp = getConfigTimestamp();
+      if (timestamp) {
+        const date = new Date(timestamp);
+        return t('上次保存: ') + date.toLocaleString();
+      }
+      return t('已有保存的配置');
+    }
+    return t('暂无保存的配置');
+  };
+
+  const dropdownItems = [
+    {
+      node: 'item',
+      name: 'export',
+      onClick: handleExport,
+      children: (
+        <div className="flex items-center gap-2">
+          <Download size={14} />
+          {t('导出配置')}
+        </div>
+      ),
+    },
+    {
+      node: 'item',
+      name: 'import',
+      onClick: handleImportClick,
+      children: (
+        <div className="flex items-center gap-2">
+          <Upload size={14} />
+          {t('导入配置')}
+        </div>
+      ),
+    },
+    {
+      node: 'divider',
+    },
+    {
+      node: 'item',
+      name: 'reset',
+      onClick: handleReset,
+      children: (
+        <div className="flex items-center gap-2 text-red-600">
+          <RotateCcw size={14} />
+          {t('重置配置')}
+        </div>
+      ),
+    },
+  ];
+
+  if (styleState.isMobile) {
+    // 移动端显示简化的下拉菜单
+    return (
+      <>
+        <Dropdown
+          trigger="click"
+          position="bottomLeft"
+          showTick
+          menu={dropdownItems}
+        >
+          <Button
+            icon={<Settings2 size={14} />}
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
+          />
+        </Dropdown>
+
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept=".json"
+          onChange={handleFileChange}
+          style={{ display: 'none' }}
+        />
+      </>
+    );
+  }
+
+  // 桌面端显示紧凑的按钮组
+  return (
+    <div className="space-y-3">
+      {/* 配置状态信息和重置按钮 */}
+      <div className="flex items-center justify-between">
+        <Typography.Text className="text-xs text-gray-500">
+          {getConfigStatus()}
+        </Typography.Text>
+        <Button
+          icon={<RotateCcw size={12} />}
+          size="small"
+          theme="borderless"
+          type="danger"
+          onClick={handleReset}
+          className="!rounded-full !text-xs !px-2"
+        />
+      </div>
+
+      {/* 导出和导入按钮 */}
+      <div className="flex gap-2">
+        <Button
+          icon={<Download size={12} />}
+          size="small"
+          theme="solid"
+          type="primary"
+          onClick={handleExport}
+          className="!rounded-lg flex-1 !text-xs !h-7"
+        >
+          {t('导出')}
+        </Button>
+
+        <Button
+          icon={<Upload size={12} />}
+          size="small"
+          theme="outline"
+          type="primary"
+          onClick={handleImportClick}
+          className="!rounded-lg flex-1 !text-xs !h-7"
+        >
+          {t('导入')}
+        </Button>
+      </div>
+
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".json"
+        onChange={handleFileChange}
+        style={{ display: 'none' }}
+      />
+    </div>
+  );
+};
+
+export default ConfigManager; 

+ 58 - 0
web/src/components/playground/CustomInputRender.js

@@ -0,0 +1,58 @@
+import React from 'react';
+
+const CustomInputRender = (props) => {
+  const { detailProps } = props;
+  const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
+
+  // 清空按钮
+  const styledClearNode = clearContextNode
+    ? React.cloneElement(clearContextNode, {
+      className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
+      style: {
+        ...clearContextNode.props.style,
+        width: '32px',
+        height: '32px',
+        minWidth: '32px',
+        padding: 0,
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+      }
+    })
+    : null;
+
+  // 发送按钮
+  const styledSendNode = React.cloneElement(sendNode, {
+    className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`,
+    style: {
+      ...sendNode.props.style,
+      width: '32px',
+      height: '32px',
+      minWidth: '32px',
+      padding: 0,
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+    }
+  });
+
+  return (
+    <div className="p-2 sm:p-4">
+      <div
+        className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
+        style={{ border: '1px solid var(--semi-color-border)' }}
+        onClick={onClick}
+      >
+        {/* 清空对话按钮 - 左边 */}
+        {styledClearNode}
+        <div className="flex-1">
+          {inputNode}
+        </div>
+        {/* 发送按钮 - 右边 */}
+        {styledSendNode}
+      </div>
+    </div>
+  );
+};
+
+export default CustomInputRender; 

+ 190 - 0
web/src/components/playground/CustomRequestEditor.js

@@ -0,0 +1,190 @@
+import React, { useState, useEffect } from 'react';
+import {
+  TextArea,
+  Typography,
+  Button,
+  Switch,
+  Banner,
+} from '@douyinfe/semi-ui';
+import {
+  Code,
+  Edit,
+  Check,
+  X,
+  AlertTriangle,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+const CustomRequestEditor = ({
+  customRequestMode,
+  customRequestBody,
+  onCustomRequestModeChange,
+  onCustomRequestBodyChange,
+  defaultPayload,
+}) => {
+  const { t } = useTranslation();
+  const [isValid, setIsValid] = useState(true);
+  const [errorMessage, setErrorMessage] = useState('');
+  const [localValue, setLocalValue] = useState(customRequestBody || '');
+
+  // 当切换到自定义模式时,用默认payload初始化
+  useEffect(() => {
+    if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
+      const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
+      setLocalValue(defaultJson);
+      onCustomRequestBodyChange(defaultJson);
+    }
+  }, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
+
+  // 同步外部传入的customRequestBody到本地状态
+  useEffect(() => {
+    if (customRequestBody !== localValue) {
+      setLocalValue(customRequestBody || '');
+      validateJson(customRequestBody || '');
+    }
+  }, [customRequestBody]);
+
+  // 验证JSON格式
+  const validateJson = (value) => {
+    if (!value.trim()) {
+      setIsValid(true);
+      setErrorMessage('');
+      return true;
+    }
+
+    try {
+      JSON.parse(value);
+      setIsValid(true);
+      setErrorMessage('');
+      return true;
+    } catch (error) {
+      setIsValid(false);
+      setErrorMessage(`JSON格式错误: ${error.message}`);
+      return false;
+    }
+  };
+
+  const handleValueChange = (value) => {
+    setLocalValue(value);
+    validateJson(value);
+    // 始终保存用户输入,让预览逻辑处理JSON解析错误
+    onCustomRequestBodyChange(value);
+  };
+
+  const handleModeToggle = (enabled) => {
+    onCustomRequestModeChange(enabled);
+    if (enabled && defaultPayload) {
+      const defaultJson = JSON.stringify(defaultPayload, null, 2);
+      setLocalValue(defaultJson);
+      onCustomRequestBodyChange(defaultJson);
+    }
+  };
+
+  const formatJson = () => {
+    try {
+      const parsed = JSON.parse(localValue);
+      const formatted = JSON.stringify(parsed, null, 2);
+      setLocalValue(formatted);
+      onCustomRequestBodyChange(formatted);
+      setIsValid(true);
+      setErrorMessage('');
+    } catch (error) {
+      // 如果格式化失败,保持原样
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* 自定义模式开关 */}
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <Code size={16} className="text-gray-500" />
+          <Typography.Text strong className="text-sm">
+            自定义请求体模式
+          </Typography.Text>
+        </div>
+        <Switch
+          checked={customRequestMode}
+          onChange={handleModeToggle}
+          checkedText="开"
+          uncheckedText="关"
+          size="small"
+        />
+      </div>
+
+      {customRequestMode && (
+        <>
+          {/* 提示信息 */}
+          <Banner
+            type="warning"
+            description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。"
+            icon={<AlertTriangle size={16} />}
+            className="!rounded-lg"
+            closable={false}
+          />
+
+          {/* JSON编辑器 */}
+          <div>
+            <div className="flex items-center justify-between mb-2">
+              <Typography.Text strong className="text-sm">
+                请求体 JSON
+              </Typography.Text>
+              <div className="flex items-center gap-2">
+                {isValid ? (
+                  <div className="flex items-center gap-1 text-green-600">
+                    <Check size={14} />
+                    <Typography.Text className="text-xs">
+                      格式正确
+                    </Typography.Text>
+                  </div>
+                ) : (
+                  <div className="flex items-center gap-1 text-red-600">
+                    <X size={14} />
+                    <Typography.Text className="text-xs">
+                      格式错误
+                    </Typography.Text>
+                  </div>
+                )}
+                <Button
+                  theme="borderless"
+                  type="tertiary"
+                  size="small"
+                  icon={<Edit size={14} />}
+                  onClick={formatJson}
+                  disabled={!isValid}
+                  className="!rounded-lg"
+                >
+                  格式化
+                </Button>
+              </div>
+            </div>
+
+            <TextArea
+              value={localValue}
+              onChange={handleValueChange}
+              placeholder='{"model": "gpt-4o", "messages": [...], ...}'
+              autosize={{ minRows: 8, maxRows: 20 }}
+              className={`custom-request-textarea !rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
+              style={{
+                fontFamily: 'Consolas, Monaco, "Courier New", monospace',
+                lineHeight: '1.5',
+              }}
+            />
+
+            {!isValid && errorMessage && (
+              <Typography.Text type="danger" className="text-xs mt-1 block">
+                {errorMessage}
+              </Typography.Text>
+            )}
+
+            <Typography.Text className="text-xs text-gray-500 mt-2 block">
+              请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
+            </Typography.Text>
+          </div>
+        </>
+      )}
+    </div>
+  );
+};
+
+export default CustomRequestEditor; 

+ 193 - 0
web/src/components/playground/DebugPanel.js

@@ -0,0 +1,193 @@
+import React, { useState, useEffect } from 'react';
+import {
+  Card,
+  Typography,
+  Tabs,
+  TabPane,
+  Button,
+  Dropdown,
+} from '@douyinfe/semi-ui';
+import {
+  Code,
+  Zap,
+  Clock,
+  X,
+  Eye,
+  Send,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import CodeViewer from './CodeViewer';
+
+const DebugPanel = ({
+  debugData,
+  activeDebugTab,
+  onActiveDebugTabChange,
+  styleState,
+  onCloseDebugPanel,
+  customRequestMode,
+}) => {
+  const { t } = useTranslation();
+
+  const [activeKey, setActiveKey] = useState(activeDebugTab);
+
+  useEffect(() => {
+    setActiveKey(activeDebugTab);
+  }, [activeDebugTab]);
+
+  const handleTabChange = (key) => {
+    setActiveKey(key);
+    onActiveDebugTabChange(key);
+  };
+
+  const renderArrow = (items, pos, handleArrowClick, defaultNode) => {
+    const style = {
+      width: 32,
+      height: 32,
+      margin: '0 12px',
+      display: 'flex',
+      justifyContent: 'center',
+      alignItems: 'center',
+      borderRadius: '100%',
+      background: 'rgba(var(--semi-grey-1), 1)',
+      color: 'var(--semi-color-text)',
+      cursor: 'pointer',
+    };
+
+    return (
+      <Dropdown
+        render={
+          <Dropdown.Menu>
+            {items.map(item => {
+              return (
+                <Dropdown.Item
+                  key={item.itemKey}
+                  onClick={() => handleTabChange(item.itemKey)}
+                >
+                  {item.tab}
+                </Dropdown.Item>
+              );
+            })}
+          </Dropdown.Menu>
+        }
+      >
+        {pos === 'start' ? (
+          <div style={style} onClick={handleArrowClick}>
+            ←
+          </div>
+        ) : (
+          <div style={style} onClick={handleArrowClick}>
+            →
+          </div>
+        )}
+      </Dropdown>
+    );
+  };
+
+  return (
+    <Card
+      className="h-full flex flex-col"
+      bordered={false}
+      bodyStyle={{
+        padding: styleState.isMobile ? '16px' : '24px',
+        height: '100%',
+        display: 'flex',
+        flexDirection: 'column'
+      }}
+    >
+      <div className="flex items-center justify-between mb-6 flex-shrink-0">
+        <div className="flex items-center">
+          <div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
+            <Code size={20} className="text-white" />
+          </div>
+          <Typography.Title heading={5} className="mb-0">
+            {t('调试信息')}
+          </Typography.Title>
+        </div>
+
+        {styleState.isMobile && onCloseDebugPanel && (
+          <Button
+            icon={<X size={16} />}
+            onClick={onCloseDebugPanel}
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            className="!rounded-lg"
+          />
+        )}
+      </div>
+
+      <div className="flex-1 overflow-hidden debug-panel">
+        <Tabs
+          renderArrow={renderArrow}
+          type="card"
+          collapsible
+          className="h-full"
+          style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
+          activeKey={activeKey}
+          onChange={handleTabChange}
+        >
+          <TabPane tab={
+            <div className="flex items-center gap-2">
+              <Eye size={16} />
+              {t('预览请求体')}
+              {customRequestMode && (
+                <span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
+                  自定义
+                </span>
+              )}
+            </div>
+          } itemKey="preview">
+            <CodeViewer
+              content={debugData.previewRequest}
+              title="preview"
+              language="json"
+            />
+          </TabPane>
+
+          <TabPane tab={
+            <div className="flex items-center gap-2">
+              <Send size={16} />
+              {t('实际请求体')}
+            </div>
+          } itemKey="request">
+            <CodeViewer
+              content={debugData.request}
+              title="request"
+              language="json"
+            />
+          </TabPane>
+
+          <TabPane tab={
+            <div className="flex items-center gap-2">
+              <Zap size={16} />
+              {t('响应')}
+            </div>
+          } itemKey="response">
+            <CodeViewer
+              content={debugData.response}
+              title="response"
+              language="json"
+            />
+          </TabPane>
+        </Tabs>
+      </div>
+
+      <div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
+        {(debugData.timestamp || debugData.previewTimestamp) && (
+          <div className="flex items-center gap-2">
+            <Clock size={14} className="text-gray-500" />
+            <Typography.Text className="text-xs text-gray-500">
+              {activeKey === 'preview' && debugData.previewTimestamp
+                ? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
+                : debugData.timestamp
+                  ? `${t('最后请求')}: ${new Date(debugData.timestamp).toLocaleString()}`
+                  : ''}
+            </Typography.Text>
+          </div>
+        )}
+      </div>
+    </Card>
+  );
+};
+
+export default DebugPanel; 

+ 71 - 0
web/src/components/playground/FloatingButtons.js

@@ -0,0 +1,71 @@
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+import {
+  Settings,
+  Eye,
+  EyeOff,
+} from 'lucide-react';
+
+const FloatingButtons = ({
+  styleState,
+  showSettings,
+  showDebugPanel,
+  onToggleSettings,
+  onToggleDebugPanel,
+}) => {
+  if (!styleState.isMobile) return null;
+
+  return (
+    <>
+      {/* 设置按钮 */}
+      {!showSettings && (
+        <Button
+          icon={<Settings size={18} />}
+          style={{
+            position: 'fixed',
+            right: 16,
+            bottom: 90,
+            zIndex: 1000,
+            width: 36,
+            height: 36,
+            borderRadius: '50%',
+            padding: 0,
+            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
+            background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
+          }}
+          onClick={onToggleSettings}
+          theme='solid'
+          type='primary'
+          className="lg:hidden"
+        />
+      )}
+
+      {/* 调试按钮 */}
+      {!showSettings && (
+        <Button
+          icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
+          onClick={onToggleDebugPanel}
+          theme="solid"
+          type={showDebugPanel ? "danger" : "primary"}
+          style={{
+            position: 'fixed',
+            right: 16,
+            bottom: 140,
+            zIndex: 1000,
+            width: 36,
+            height: 36,
+            borderRadius: '50%',
+            padding: 0,
+            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
+            background: showDebugPanel
+              ? 'linear-gradient(to right, #e11d48, #be123c)'
+              : 'linear-gradient(to right, #4f46e5, #6366f1)',
+          }}
+          className="lg:hidden !rounded-full !p-0"
+        />
+      )}
+    </>
+  );
+};
+
+export default FloatingButtons; 

+ 113 - 0
web/src/components/playground/ImageUrlInput.js

@@ -0,0 +1,113 @@
+import React from 'react';
+import {
+  Input,
+  Typography,
+  Button,
+  Switch,
+} from '@douyinfe/semi-ui';
+import { IconFile } from '@douyinfe/semi-icons';
+import {
+  FileText,
+  Plus,
+  X,
+  Image,
+} from 'lucide-react';
+
+const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
+  const handleAddImageUrl = () => {
+    const newUrls = [...imageUrls, ''];
+    onImageUrlsChange(newUrls);
+  };
+
+  const handleUpdateImageUrl = (index, value) => {
+    const newUrls = [...imageUrls];
+    newUrls[index] = value;
+    onImageUrlsChange(newUrls);
+  };
+
+  const handleRemoveImageUrl = (index) => {
+    const newUrls = imageUrls.filter((_, i) => i !== index);
+    onImageUrlsChange(newUrls);
+  };
+
+  return (
+    <div className={disabled ? 'opacity-50' : ''}>
+      <div className="flex items-center justify-between mb-2">
+        <div className="flex items-center gap-2">
+          <Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
+          <Typography.Text strong className="text-sm">
+            图片地址
+          </Typography.Text>
+          {disabled && (
+            <Typography.Text className="text-xs text-orange-600">
+              (已在自定义模式中忽略)
+            </Typography.Text>
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          <Switch
+            checked={imageEnabled}
+            onChange={onImageEnabledChange}
+            checkedText="启用"
+            uncheckedText="停用"
+            size="small"
+            className="flex-shrink-0"
+            disabled={disabled}
+          />
+          <Button
+            icon={<Plus size={14} />}
+            size="small"
+            theme="solid"
+            type="primary"
+            onClick={handleAddImageUrl}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={!imageEnabled || disabled}
+          />
+        </div>
+      </div>
+
+      {!imageEnabled ? (
+        <Typography.Text className="text-xs text-gray-500 mb-2 block">
+          {disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
+        </Typography.Text>
+      ) : imageUrls.length === 0 ? (
+        <Typography.Text className="text-xs text-gray-500 mb-2 block">
+          {disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
+        </Typography.Text>
+      ) : (
+        <Typography.Text className="text-xs text-gray-500 mb-2 block">
+          已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
+        </Typography.Text>
+      )}
+
+      <div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
+        {imageUrls.map((url, index) => (
+          <div key={index} className="flex items-center gap-2">
+            <div className="flex-1">
+              <Input
+                placeholder={`https://example.com/image${index + 1}.jpg`}
+                value={url}
+                onChange={(value) => handleUpdateImageUrl(index, value)}
+                className="!rounded-lg"
+                size="small"
+                prefix={<IconFile size='small' />}
+                disabled={!imageEnabled || disabled}
+              />
+            </div>
+            <Button
+              icon={<X size={12} />}
+              size="small"
+              theme="borderless"
+              type="danger"
+              onClick={() => handleRemoveImageUrl(index)}
+              className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
+              disabled={!imageEnabled || disabled}
+            />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+export default ImageUrlInput; 

+ 121 - 0
web/src/components/playground/MessageActions.js

@@ -0,0 +1,121 @@
+import React from 'react';
+import {
+  Button,
+  Tooltip,
+} from '@douyinfe/semi-ui';
+import {
+  RefreshCw,
+  Copy,
+  Trash2,
+  UserCheck,
+  Edit,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+const MessageActions = ({
+  message,
+  styleState,
+  onMessageReset,
+  onMessageCopy,
+  onMessageDelete,
+  onRoleToggle,
+  onMessageEdit,
+  isAnyMessageGenerating = false,
+  isEditing = false
+}) => {
+  const { t } = useTranslation();
+
+  const isLoading = message.status === 'loading' || message.status === 'incomplete';
+  const shouldDisableActions = isAnyMessageGenerating || isEditing;
+  const canToggleRole = message.role === 'assistant' || message.role === 'system';
+  const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
+
+  return (
+    <div className="flex items-center gap-0.5">
+      {!isLoading && (
+        <Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => !shouldDisableActions && onMessageReset(message)}
+            disabled={shouldDisableActions}
+            className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('重试')}
+          />
+        </Tooltip>
+      )}
+
+      {message.content && (
+        <Tooltip content={t('复制')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<Copy size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => onMessageCopy(message)}
+            className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('复制')}
+          />
+        </Tooltip>
+      )}
+
+      {canEdit && (
+        <Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<Edit size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => !shouldDisableActions && onMessageEdit(message)}
+            disabled={shouldDisableActions}
+            className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-yellow-600 hover:!bg-yellow-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('编辑')}
+          />
+        </Tooltip>
+      )}
+
+      {canToggleRole && !isLoading && (
+        <Tooltip
+          content={
+            shouldDisableActions
+              ? t('操作暂时被禁用')
+              : message.role === 'assistant'
+                ? t('切换为System角色')
+                : t('切换为Assistant角色')
+          }
+          position="top"
+        >
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
+            disabled={shouldDisableActions}
+            className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
+          />
+        </Tooltip>
+      )}
+
+      {!isLoading && (
+        <Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
+          <Button
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
+            onClick={() => !shouldDisableActions && onMessageDelete(message)}
+            disabled={shouldDisableActions}
+            className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
+            aria-label={t('删除')}
+          />
+        </Tooltip>
+      )}
+    </div>
+  );
+};
+
+export default MessageActions; 

+ 313 - 0
web/src/components/playground/MessageContent.js

@@ -0,0 +1,313 @@
+import React, { useRef, useEffect } from 'react';
+import {
+  Typography,
+  TextArea,
+  Button,
+} from '@douyinfe/semi-ui';
+import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
+import ThinkingContent from './ThinkingContent';
+import {
+  Loader2,
+  Check,
+  X,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+const MessageContent = ({
+  message,
+  className,
+  styleState,
+  onToggleReasoningExpansion,
+  isEditing = false,
+  onEditSave,
+  onEditCancel,
+  editValue,
+  onEditValueChange
+}) => {
+  const { t } = useTranslation();
+  const previousContentLengthRef = useRef(0);
+  const lastContentRef = useRef('');
+
+  const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
+
+  useEffect(() => {
+    if (!isThinkingStatus) {
+      previousContentLengthRef.current = 0;
+      lastContentRef.current = '';
+    }
+  }, [isThinkingStatus]);
+
+  if (message.status === 'error') {
+    let errorText;
+
+    if (Array.isArray(message.content)) {
+      const textContent = message.content.find(item => item.type === 'text');
+      errorText = textContent && textContent.text && typeof textContent.text === 'string'
+        ? textContent.text
+        : t('请求发生错误');
+    } else if (typeof message.content === 'string') {
+      errorText = message.content;
+    } else {
+      errorText = t('请求发生错误');
+    }
+
+    return (
+      <div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
+        <Typography.Text type="danger" className="text-sm">
+          {errorText}
+        </Typography.Text>
+      </div>
+    );
+  }
+
+  let currentExtractedThinkingContent = null;
+  let currentDisplayableFinalContent = "";
+  let thinkingSource = null;
+
+  const getTextContent = (content) => {
+    if (Array.isArray(content)) {
+      const textItem = content.find(item => item.type === 'text');
+      return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
+    } else if (typeof content === 'string') {
+      return content;
+    }
+    return '';
+  };
+
+  currentDisplayableFinalContent = getTextContent(message.content);
+
+  if (message.role === 'assistant') {
+    let baseContentForDisplay = getTextContent(message.content);
+    let combinedThinkingContent = "";
+
+    if (message.reasoningContent) {
+      combinedThinkingContent = message.reasoningContent;
+      thinkingSource = 'reasoningContent';
+    }
+
+    if (baseContentForDisplay.includes('<think>')) {
+      const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
+      let match;
+      let thoughtsFromPairedTags = [];
+      let replyParts = [];
+      let lastIndex = 0;
+
+      while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
+        replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
+        thoughtsFromPairedTags.push(match[1]);
+        lastIndex = match.index + match[0].length;
+      }
+      replyParts.push(baseContentForDisplay.substring(lastIndex));
+
+      if (thoughtsFromPairedTags.length > 0) {
+        const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
+        if (combinedThinkingContent) {
+          combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
+        } else {
+          combinedThinkingContent = pairedThoughtsStr;
+        }
+        thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
+      }
+
+      baseContentForDisplay = replyParts.join('');
+    }
+
+    if (isThinkingStatus) {
+      const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
+      if (lastOpenThinkIndex !== -1) {
+        const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
+        if (!fragmentAfterLastOpen.includes('</think>')) {
+          const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
+          if (unclosedThought) {
+            if (combinedThinkingContent) {
+              combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
+            } else {
+              combinedThinkingContent = unclosedThought;
+            }
+            thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
+          }
+          baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
+        }
+      }
+    }
+
+    currentExtractedThinkingContent = combinedThinkingContent || null;
+    currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
+  }
+
+  const finalExtractedThinkingContent = currentExtractedThinkingContent;
+  const finalDisplayableFinalContent = currentDisplayableFinalContent;
+
+  if (message.role === 'assistant' &&
+    isThinkingStatus &&
+    !finalExtractedThinkingContent &&
+    (!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
+    return (
+      <div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
+        <div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
+          <Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className={className}>
+      {message.role === 'system' && (
+        <div className="mb-2 sm:mb-4">
+          <div className="flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg" style={{ border: '1px solid var(--semi-color-border)' }}>
+            <div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
+              <Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
+            </div>
+            <Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
+              {t('系统消息')}
+            </Typography.Text>
+          </div>
+        </div>
+      )}
+
+      {message.role === 'assistant' && (
+        <ThinkingContent
+          message={message}
+          finalExtractedThinkingContent={finalExtractedThinkingContent}
+          thinkingSource={thinkingSource}
+          styleState={styleState}
+          onToggleReasoningExpansion={onToggleReasoningExpansion}
+        />
+      )}
+
+      {isEditing ? (
+        <div className="space-y-3">
+          <TextArea
+            value={editValue}
+            onChange={(value) => onEditValueChange(value)}
+            placeholder={t('请输入消息内容...')}
+            autosize={{ minRows: 3, maxRows: 12 }}
+            style={{
+              resize: 'vertical',
+              fontSize: styleState.isMobile ? '14px' : '15px',
+              lineHeight: '1.6',
+            }}
+            className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
+          />
+          <div className="flex items-center gap-2 w-full">
+            <Button
+              size="small"
+              type="danger"
+              theme="light"
+              icon={<X size={14} />}
+              onClick={onEditCancel}
+              className="flex-1"
+            >
+              {t('取消')}
+            </Button>
+            <Button
+              size="small"
+              type="warning"
+              theme="solid"
+              icon={<Check size={14} />}
+              onClick={onEditSave}
+              disabled={!editValue || editValue.trim() === ''}
+              className="flex-1"
+            >
+              {t('保存')}
+            </Button>
+          </div>
+        </div>
+      ) : (
+        (() => {
+          if (Array.isArray(message.content)) {
+            const textContent = message.content.find(item => item.type === 'text');
+            const imageContents = message.content.filter(item => item.type === 'image_url');
+
+            return (
+              <div>
+                {imageContents.length > 0 && (
+                  <div className="mb-3 space-y-2">
+                    {imageContents.map((imgItem, index) => (
+                      <div key={index} className="max-w-sm">
+                        <img
+                          src={imgItem.image_url.url}
+                          alt={`用户上传的图片 ${index + 1}`}
+                          className="rounded-lg max-w-full h-auto shadow-sm border"
+                          style={{ maxHeight: '300px' }}
+                          onError={(e) => {
+                            e.target.style.display = 'none';
+                            e.target.nextSibling.style.display = 'block';
+                          }}
+                        />
+                        <div
+                          className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
+                          style={{ display: 'none' }}
+                        >
+                          图片加载失败: {imgItem.image_url.url}
+                        </div>
+                      </div>
+                    ))}
+                  </div>
+                )}
+
+                {textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
+                  <div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
+                    <MarkdownRenderer
+                      content={textContent.text}
+                      className={message.role === 'user' ? 'user-message' : ''}
+                      animated={false}
+                      previousContentLength={0}
+                    />
+                  </div>
+                )}
+              </div>
+            );
+          }
+
+          if (typeof message.content === 'string') {
+            if (message.role === 'assistant') {
+              if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
+                // 获取上一次的内容长度
+                let prevLength = 0;
+                if (isThinkingStatus && lastContentRef.current) {
+                  // 只有当前内容包含上一次内容时,才使用上一次的长度
+                  if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
+                    prevLength = lastContentRef.current.length;
+                  }
+                }
+
+                // 更新最后内容的引用
+                if (isThinkingStatus) {
+                  lastContentRef.current = finalDisplayableFinalContent;
+                }
+
+                return (
+                  <div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
+                    <MarkdownRenderer
+                      content={finalDisplayableFinalContent}
+                      className=""
+                      animated={isThinkingStatus}
+                      previousContentLength={prevLength}
+                    />
+                  </div>
+                );
+              }
+            } else {
+              return (
+                <div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
+                  <MarkdownRenderer
+                    content={message.content}
+                    className={message.role === 'user' ? 'user-message' : ''}
+                    animated={false}
+                    previousContentLength={0}
+                  />
+                </div>
+              );
+            }
+          }
+
+          return null;
+        })()
+      )}
+    </div>
+  );
+};
+
+export default MessageContent; 

+ 60 - 0
web/src/components/playground/OptimizedComponents.js

@@ -0,0 +1,60 @@
+import React from 'react';
+import MessageContent from './MessageContent';
+import MessageActions from './MessageActions';
+import SettingsPanel from './SettingsPanel';
+import DebugPanel from './DebugPanel';
+
+// 优化的消息内容组件
+export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
+  // 只有这些属性变化时才重新渲染
+  return (
+    prevProps.message.id === nextProps.message.id &&
+    prevProps.message.content === nextProps.message.content &&
+    prevProps.message.status === nextProps.message.status &&
+    prevProps.message.role === nextProps.message.role &&
+    prevProps.message.reasoningContent === nextProps.message.reasoningContent &&
+    prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
+    prevProps.isEditing === nextProps.isEditing &&
+    prevProps.editValue === nextProps.editValue &&
+    prevProps.styleState.isMobile === nextProps.styleState.isMobile
+  );
+});
+
+// 优化的消息操作组件
+export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
+  return (
+    prevProps.message.id === nextProps.message.id &&
+    prevProps.message.role === nextProps.message.role &&
+    prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
+    prevProps.isEditing === nextProps.isEditing &&
+    prevProps.onMessageReset === nextProps.onMessageReset
+  );
+});
+
+// 优化的设置面板组件
+export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
+  return (
+    JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
+    JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
+    JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
+    JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
+    prevProps.customRequestMode === nextProps.customRequestMode &&
+    prevProps.customRequestBody === nextProps.customRequestBody &&
+    prevProps.showDebugPanel === nextProps.showDebugPanel &&
+    prevProps.showSettings === nextProps.showSettings &&
+    JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
+    JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
+  );
+});
+
+// 优化的调试面板组件
+export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
+  return (
+    prevProps.show === nextProps.show &&
+    prevProps.activeTab === nextProps.activeTab &&
+    JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
+    JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
+    prevProps.customRequestMode === nextProps.customRequestMode &&
+    prevProps.showDebugPanel === nextProps.showDebugPanel
+  );
+}); 

+ 241 - 0
web/src/components/playground/ParameterControl.js

@@ -0,0 +1,241 @@
+import React from 'react';
+import {
+  Input,
+  Slider,
+  Typography,
+  Button,
+  Tag,
+} from '@douyinfe/semi-ui';
+import {
+  Hash,
+  Thermometer,
+  Target,
+  Repeat,
+  Ban,
+  Shuffle,
+  Check,
+  X,
+} from 'lucide-react';
+
+const ParameterControl = ({
+  inputs,
+  parameterEnabled,
+  onInputChange,
+  onParameterToggle,
+  disabled = false,
+}) => {
+  return (
+    <>
+      {/* Temperature */}
+      <div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Thermometer size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Temperature
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.temperature}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
+            type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('temperature')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={disabled}
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          控制输出的随机性和创造性
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={0.1}
+          max={1}
+          value={inputs.temperature}
+          onChange={(value) => onInputChange('temperature', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.temperature || disabled}
+        />
+      </div>
+
+      {/* Top P */}
+      <div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Target size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Top P
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.top_p}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
+            type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('top_p')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={disabled}
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          核采样,控制词汇选择的多样性
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={0.1}
+          max={1}
+          value={inputs.top_p}
+          onChange={(value) => onInputChange('top_p', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.top_p || disabled}
+        />
+      </div>
+
+      {/* Frequency Penalty */}
+      <div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Repeat size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Frequency Penalty
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.frequency_penalty}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
+            type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('frequency_penalty')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={disabled}
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          频率惩罚,减少重复词汇的出现
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={-2}
+          max={2}
+          value={inputs.frequency_penalty}
+          onChange={(value) => onInputChange('frequency_penalty', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.frequency_penalty || disabled}
+        />
+      </div>
+
+      {/* Presence Penalty */}
+      <div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Ban size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Presence Penalty
+            </Typography.Text>
+            <Tag size="small" className="!rounded-full">
+              {inputs.presence_penalty}
+            </Tag>
+          </div>
+          <Button
+            theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
+            type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('presence_penalty')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={disabled}
+          />
+        </div>
+        <Typography.Text className="text-xs text-gray-500 mb-2">
+          存在惩罚,鼓励讨论新话题
+        </Typography.Text>
+        <Slider
+          step={0.1}
+          min={-2}
+          max={2}
+          value={inputs.presence_penalty}
+          onChange={(value) => onInputChange('presence_penalty', value)}
+          className="mt-2"
+          disabled={!parameterEnabled.presence_penalty || disabled}
+        />
+      </div>
+
+      {/* MaxTokens */}
+      <div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Hash size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Max Tokens
+            </Typography.Text>
+          </div>
+          <Button
+            theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
+            type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('max_tokens')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={disabled}
+          />
+        </div>
+        <Input
+          placeholder='MaxTokens'
+          name='max_tokens'
+          required
+          autoComplete='new-password'
+          defaultValue={0}
+          value={inputs.max_tokens}
+          onChange={(value) => onInputChange('max_tokens', value)}
+          className="!rounded-lg"
+          disabled={!parameterEnabled.max_tokens || disabled}
+        />
+      </div>
+
+      {/* Seed */}
+      <div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center gap-2">
+            <Shuffle size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              Seed
+            </Typography.Text>
+            <Typography.Text className="text-xs text-gray-400">
+              (可选,用于复现结果)
+            </Typography.Text>
+          </div>
+          <Button
+            theme={parameterEnabled.seed ? 'solid' : 'borderless'}
+            type={parameterEnabled.seed ? 'primary' : 'tertiary'}
+            size="small"
+            icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
+            onClick={() => onParameterToggle('seed')}
+            className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
+            disabled={disabled}
+          />
+        </div>
+        <Input
+          placeholder='随机种子 (留空为随机)'
+          name='seed'
+          autoComplete='new-password'
+          value={inputs.seed || ''}
+          onChange={(value) => onInputChange('seed', value === '' ? null : value)}
+          className="!rounded-lg"
+          disabled={!parameterEnabled.seed || disabled}
+        />
+      </div>
+    </>
+  );
+};
+
+export default ParameterControl; 

+ 234 - 0
web/src/components/playground/SettingsPanel.js

@@ -0,0 +1,234 @@
+import React from 'react';
+import {
+  Card,
+  Select,
+  Typography,
+  Button,
+  Switch,
+} from '@douyinfe/semi-ui';
+import {
+  Sparkles,
+  Users,
+  ToggleLeft,
+  X,
+  Settings,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { renderGroupOption } from '../../helpers';
+import ParameterControl from './ParameterControl';
+import ImageUrlInput from './ImageUrlInput';
+import ConfigManager from './ConfigManager';
+import CustomRequestEditor from './CustomRequestEditor';
+
+const SettingsPanel = ({
+  inputs,
+  parameterEnabled,
+  models,
+  groups,
+  styleState,
+  showDebugPanel,
+  customRequestMode,
+  customRequestBody,
+  onInputChange,
+  onParameterToggle,
+  onCloseSettings,
+  onConfigImport,
+  onConfigReset,
+  onCustomRequestModeChange,
+  onCustomRequestBodyChange,
+  previewPayload,
+  messages,
+}) => {
+  const { t } = useTranslation();
+
+  const currentConfig = {
+    inputs,
+    parameterEnabled,
+    showDebugPanel,
+    customRequestMode,
+    customRequestBody,
+  };
+
+  return (
+    <Card
+      className="h-full flex flex-col"
+      bordered={false}
+      bodyStyle={{
+        padding: styleState.isMobile ? '16px' : '24px',
+        height: '100%',
+        display: 'flex',
+        flexDirection: 'column'
+      }}
+    >
+      {/* 标题区域 - 与调试面板保持一致 */}
+      <div className="flex items-center justify-between mb-6 flex-shrink-0">
+        <div className="flex items-center">
+          <div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3">
+            <Settings size={20} className="text-white" />
+          </div>
+          <Typography.Title heading={5} className="mb-0">
+            {t('模型配置')}
+          </Typography.Title>
+        </div>
+
+        {styleState.isMobile && onCloseSettings && (
+          <Button
+            icon={<X size={16} />}
+            onClick={onCloseSettings}
+            theme="borderless"
+            type="tertiary"
+            size="small"
+            className="!rounded-lg"
+          />
+        )}
+      </div>
+
+      {/* 移动端配置管理 */}
+      {styleState.isMobile && (
+        <div className="mb-4 flex-shrink-0">
+          <ConfigManager
+            currentConfig={currentConfig}
+            onConfigImport={onConfigImport}
+            onConfigReset={onConfigReset}
+            styleState={{ ...styleState, isMobile: false }}
+            messages={messages}
+          />
+        </div>
+      )}
+
+      <div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
+        {/* 自定义请求体编辑器 */}
+        <CustomRequestEditor
+          customRequestMode={customRequestMode}
+          customRequestBody={customRequestBody}
+          onCustomRequestModeChange={onCustomRequestModeChange}
+          onCustomRequestBodyChange={onCustomRequestBodyChange}
+          defaultPayload={previewPayload}
+        />
+
+        {/* 分组选择 */}
+        <div className={customRequestMode ? 'opacity-50' : ''}>
+          <div className="flex items-center gap-2 mb-2">
+            <Users size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              {t('分组')}
+            </Typography.Text>
+            {customRequestMode && (
+              <Typography.Text className="text-xs text-orange-600">
+                (已在自定义模式中忽略)
+              </Typography.Text>
+            )}
+          </div>
+          <Select
+            placeholder={t('请选择分组')}
+            name='group'
+            required
+            selection
+            onChange={(value) => onInputChange('group', value)}
+            value={inputs.group}
+            autoComplete='new-password'
+            optionList={groups}
+            renderOptionItem={renderGroupOption}
+            style={{ width: '100%' }}
+            dropdownStyle={{ width: '100%', maxWidth: '100%' }}
+            className="!rounded-lg"
+            disabled={customRequestMode}
+          />
+        </div>
+
+        {/* 模型选择 */}
+        <div className={customRequestMode ? 'opacity-50' : ''}>
+          <div className="flex items-center gap-2 mb-2">
+            <Sparkles size={16} className="text-gray-500" />
+            <Typography.Text strong className="text-sm">
+              {t('模型')}
+            </Typography.Text>
+            {customRequestMode && (
+              <Typography.Text className="text-xs text-orange-600">
+                (已在自定义模式中忽略)
+              </Typography.Text>
+            )}
+          </div>
+          <Select
+            placeholder={t('请选择模型')}
+            name='model'
+            required
+            selection
+            searchPosition='dropdown'
+            filter
+            onChange={(value) => onInputChange('model', value)}
+            value={inputs.model}
+            autoComplete='new-password'
+            optionList={models}
+            style={{ width: '100%' }}
+            dropdownStyle={{ width: '100%', maxWidth: '100%' }}
+            className="!rounded-lg"
+            disabled={customRequestMode}
+          />
+        </div>
+
+        {/* 图片URL输入 */}
+        <div className={customRequestMode ? 'opacity-50' : ''}>
+          <ImageUrlInput
+            imageUrls={inputs.imageUrls}
+            imageEnabled={inputs.imageEnabled}
+            onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
+            onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
+            disabled={customRequestMode}
+          />
+        </div>
+
+        {/* 参数控制组件 */}
+        <div className={customRequestMode ? 'opacity-50' : ''}>
+          <ParameterControl
+            inputs={inputs}
+            parameterEnabled={parameterEnabled}
+            onInputChange={onInputChange}
+            onParameterToggle={onParameterToggle}
+            disabled={customRequestMode}
+          />
+        </div>
+
+        {/* 流式输出开关 */}
+        <div className={customRequestMode ? 'opacity-50' : ''}>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <ToggleLeft size={16} className="text-gray-500" />
+              <Typography.Text strong className="text-sm">
+                流式输出
+              </Typography.Text>
+              {customRequestMode && (
+                <Typography.Text className="text-xs text-orange-600">
+                  (已在自定义模式中忽略)
+                </Typography.Text>
+              )}
+            </div>
+            <Switch
+              checked={inputs.stream}
+              onChange={(checked) => onInputChange('stream', checked)}
+              checkedText="开"
+              uncheckedText="关"
+              size="small"
+              disabled={customRequestMode}
+            />
+          </div>
+        </div>
+      </div>
+
+      {/* 桌面端的配置管理放在底部 */}
+      {!styleState.isMobile && (
+        <div className="flex-shrink-0 pt-3">
+          <ConfigManager
+            currentConfig={currentConfig}
+            onConfigImport={onConfigImport}
+            onConfigReset={onConfigReset}
+            styleState={styleState}
+            messages={messages}
+          />
+        </div>
+      )}
+    </Card>
+  );
+};
+
+export default SettingsPanel; 

+ 125 - 0
web/src/components/playground/ThinkingContent.js

@@ -0,0 +1,125 @@
+import React, { useEffect, useRef } from 'react';
+import { Typography } from '@douyinfe/semi-ui';
+import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
+import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+const ThinkingContent = ({
+  message,
+  finalExtractedThinkingContent,
+  thinkingSource,
+  styleState,
+  onToggleReasoningExpansion
+}) => {
+  const { t } = useTranslation();
+  const scrollRef = useRef(null);
+  const lastContentRef = useRef('');
+
+  const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
+  const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
+
+  useEffect(() => {
+    if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) {
+      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+    }
+  }, [finalExtractedThinkingContent, message.isReasoningExpanded]);
+
+  useEffect(() => {
+    if (!isThinkingStatus) {
+      lastContentRef.current = '';
+    }
+  }, [isThinkingStatus]);
+
+  if (!finalExtractedThinkingContent) return null;
+
+  let prevLength = 0;
+  if (isThinkingStatus && lastContentRef.current) {
+    if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
+      prevLength = lastContentRef.current.length;
+    }
+  }
+
+  if (isThinkingStatus) {
+    lastContentRef.current = finalExtractedThinkingContent;
+  }
+
+  return (
+    <div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
+      <div
+        className="flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
+        style={{
+          background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
+          position: 'relative'
+        }}
+        onClick={() => onToggleReasoningExpansion(message.id)}
+      >
+        <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 -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
+        </div>
+        <div className="flex items-center gap-2 sm:gap-4 relative">
+          <div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
+            <Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
+          </div>
+          <div className="flex flex-col">
+            <Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
+              {headerText}
+            </Typography.Text>
+            {thinkingSource && (
+              <Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
+                来源: {thinkingSource}
+              </Typography.Text>
+            )}
+          </div>
+        </div>
+        <div className="flex items-center gap-2 sm:gap-3 relative">
+          {isThinkingStatus && !message.isThinkingComplete && (
+            <div className="flex items-center gap-1 sm:gap-2">
+              <Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
+              <Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
+                思考中
+              </Typography.Text>
+            </div>
+          )}
+          {(!isThinkingStatus || message.isThinkingComplete) && (
+            <div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
+              {message.isReasoningExpanded ?
+                <ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
+                <ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
+              }
+            </div>
+          )}
+        </div>
+      </div>
+      <div
+        className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
+          } overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
+      >
+        {message.isReasoningExpanded && (
+          <div className="p-3 sm:p-5 pt-2 sm:pt-4">
+            <div
+              ref={scrollRef}
+              className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
+              style={{
+                maxHeight: '200px',
+                scrollbarWidth: 'thin',
+                scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
+              }}
+            >
+              <div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
+                <MarkdownRenderer
+                  content={finalExtractedThinkingContent}
+                  className=""
+                  animated={isThinkingStatus}
+                  previousContentLength={prevLength}
+                />
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default ThinkingContent; 

+ 203 - 0
web/src/components/playground/configStorage.js

@@ -0,0 +1,203 @@
+import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
+
+const MESSAGES_STORAGE_KEY = 'playground_messages';
+
+/**
+ * 保存配置到 localStorage
+ * @param {Object} config - 要保存的配置对象
+ */
+export const saveConfig = (config) => {
+  try {
+    const configToSave = {
+      ...config,
+      timestamp: new Date().toISOString(),
+    };
+    localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
+  } catch (error) {
+    console.error('保存配置失败:', error);
+  }
+};
+
+/**
+ * 保存消息到 localStorage
+ * @param {Array} messages - 要保存的消息数组
+ */
+export const saveMessages = (messages) => {
+  try {
+    const messagesToSave = {
+      messages,
+      timestamp: new Date().toISOString(),
+    };
+    localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
+  } catch (error) {
+    console.error('保存消息失败:', error);
+  }
+};
+
+/**
+ * 从 localStorage 加载配置
+ * @returns {Object} 配置对象,如果不存在则返回默认配置
+ */
+export const loadConfig = () => {
+  try {
+    const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
+    if (savedConfig) {
+      const parsedConfig = JSON.parse(savedConfig);
+
+      const mergedConfig = {
+        inputs: {
+          ...DEFAULT_CONFIG.inputs,
+          ...parsedConfig.inputs,
+        },
+        parameterEnabled: {
+          ...DEFAULT_CONFIG.parameterEnabled,
+          ...parsedConfig.parameterEnabled,
+        },
+        showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
+        customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
+        customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
+      };
+
+      return mergedConfig;
+    }
+  } catch (error) {
+    console.error('加载配置失败:', error);
+  }
+
+  return DEFAULT_CONFIG;
+};
+
+/**
+ * 从 localStorage 加载消息
+ * @returns {Array} 消息数组,如果不存在则返回 null
+ */
+export const loadMessages = () => {
+  try {
+    const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
+    if (savedMessages) {
+      const parsedMessages = JSON.parse(savedMessages);
+      return parsedMessages.messages || null;
+    }
+  } catch (error) {
+    console.error('加载消息失败:', error);
+  }
+
+  return null;
+};
+
+/**
+ * 清除保存的配置
+ */
+export const clearConfig = () => {
+  try {
+    localStorage.removeItem(STORAGE_KEYS.CONFIG);
+    localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
+  } catch (error) {
+    console.error('清除配置失败:', error);
+  }
+};
+
+/**
+ * 清除保存的消息
+ */
+export const clearMessages = () => {
+  try {
+    localStorage.removeItem(STORAGE_KEYS.MESSAGES);
+  } catch (error) {
+    console.error('清除消息失败:', error);
+  }
+};
+
+/**
+ * 检查是否有保存的配置
+ * @returns {boolean} 是否存在保存的配置
+ */
+export const hasStoredConfig = () => {
+  try {
+    return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
+  } catch (error) {
+    console.error('检查配置失败:', error);
+    return false;
+  }
+};
+
+/**
+ * 获取配置的最后保存时间
+ * @returns {string|null} 最后保存时间的 ISO 字符串
+ */
+export const getConfigTimestamp = () => {
+  try {
+    const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
+    if (savedConfig) {
+      const parsedConfig = JSON.parse(savedConfig);
+      return parsedConfig.timestamp || null;
+    }
+  } catch (error) {
+    console.error('获取配置时间戳失败:', error);
+  }
+  return null;
+};
+
+/**
+ * 导出配置为 JSON 文件(包含消息)
+ * @param {Object} config - 要导出的配置
+ * @param {Array} messages - 要导出的消息
+ */
+export const exportConfig = (config, messages = null) => {
+  try {
+    const configToExport = {
+      ...config,
+      messages: messages || loadMessages(), // 包含消息数据
+      exportTime: new Date().toISOString(),
+      version: '1.0',
+    };
+
+    const dataStr = JSON.stringify(configToExport, null, 2);
+    const dataBlob = new Blob([dataStr], { type: 'application/json' });
+
+    const link = document.createElement('a');
+    link.href = URL.createObjectURL(dataBlob);
+    link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
+    link.click();
+
+    URL.revokeObjectURL(link.href);
+
+  } catch (error) {
+    console.error('导出配置失败:', error);
+  }
+};
+
+/**
+ * 从文件导入配置(包含消息)
+ * @param {File} file - 包含配置的 JSON 文件
+ * @returns {Promise<Object>} 导入的配置对象
+ */
+export const importConfig = (file) => {
+  return new Promise((resolve, reject) => {
+    try {
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        try {
+          const importedConfig = JSON.parse(e.target.result);
+
+          if (importedConfig.inputs && importedConfig.parameterEnabled) {
+            // 如果导入的配置包含消息,也一起导入
+            if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
+              saveMessages(importedConfig.messages);
+            }
+
+            resolve(importedConfig);
+          } else {
+            reject(new Error('配置文件格式无效'));
+          }
+        } catch (parseError) {
+          reject(new Error('解析配置文件失败: ' + parseError.message));
+        }
+      };
+      reader.onerror = () => reject(new Error('读取文件失败'));
+      reader.readAsText(file);
+    } catch (error) {
+      reject(new Error('导入配置失败: ' + error.message));
+    }
+  });
+}; 

+ 20 - 0
web/src/components/playground/index.js

@@ -0,0 +1,20 @@
+export { default as SettingsPanel } from './SettingsPanel';
+export { default as ChatArea } from './ChatArea';
+export { default as DebugPanel } from './DebugPanel';
+export { default as MessageContent } from './MessageContent';
+export { default as MessageActions } from './MessageActions';
+export { default as CustomInputRender } from './CustomInputRender';
+export { default as ParameterControl } from './ParameterControl';
+export { default as ImageUrlInput } from './ImageUrlInput';
+export { default as FloatingButtons } from './FloatingButtons';
+export { default as ConfigManager } from './ConfigManager';
+
+export {
+  saveConfig,
+  loadConfig,
+  clearConfig,
+  hasStoredConfig,
+  getConfigTimestamp,
+  exportConfig,
+  importConfig,
+} from './configStorage'; 

+ 4 - 4
web/src/components/ModelSetting.js → web/src/components/settings/ModelSetting.js

@@ -1,11 +1,11 @@
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 
-import { API, showError, showSuccess } from '../helpers';
+import { API, showError, showSuccess } from '../../helpers';
 import { useTranslation } from 'react-i18next';
-import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js';
-import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js';
-import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js';
+import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
+import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
+import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
 
 const ModelSetting = () => {
   const { t } = useTranslation();

+ 13 - 13
web/src/components/OperationSetting.js → web/src/components/settings/OperationSetting.js

@@ -1,20 +1,20 @@
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
-import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
-import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
-import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
-import SettingsLog from '../pages/Setting/Operation/SettingsLog.js';
-import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js';
-import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
-import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
-import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
-import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
-import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
+import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
+import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
+import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
+import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
+import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
+import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
+import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
+import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
+import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
+import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
 
-import { API, showError, showSuccess } from '../helpers';
-import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
+import { API, showError, showSuccess } from '../../helpers';
+import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
 import { useTranslation } from 'react-i18next';
-import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js';
+import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
 
 const OperationSetting = () => {
   const { t } = useTranslation();

+ 2 - 2
web/src/components/OtherSetting.js → web/src/components/settings/OtherSetting.js

@@ -9,10 +9,10 @@ import {
   Space,
   Card,
 } from '@douyinfe/semi-ui';
-import { API, showError, showSuccess, timestamp2string } from '../helpers';
+import { API, showError, showSuccess, timestamp2string } from '../../helpers';
 import { marked } from 'marked';
 import { useTranslation } from 'react-i18next';
-import { StatusContext } from '../context/Status/index.js';
+import { StatusContext } from '../../context/Status/index.js';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 
 const OtherSetting = () => {

+ 1552 - 0
web/src/components/settings/PersonalSetting.js

@@ -0,0 +1,1552 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+  API,
+  copy,
+  isRoot,
+  isAdmin,
+  showError,
+  showInfo,
+  showSuccess,
+  renderQuota,
+  renderQuotaWithPrompt,
+  stringToColor,
+  onGitHubOAuthClicked,
+  onOIDCClicked,
+  onLinuxDOOAuthClicked,
+  renderModelTag,
+  getModelCategories
+} from '../../helpers';
+import Turnstile from 'react-turnstile';
+import { UserContext } from '../../context/User';
+import {
+  Avatar,
+  Banner,
+  Button,
+  Card,
+  Empty,
+  Image,
+  Input,
+  Layout,
+  Modal,
+  Skeleton,
+  Space,
+  Tag,
+  Typography,
+  Collapsible,
+  Radio,
+  RadioGroup,
+  AutoComplete,
+  Checkbox,
+  Tabs,
+  TabPane,
+} from '@douyinfe/semi-ui';
+import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
+import {
+  IconMail,
+  IconLock,
+  IconShield,
+  IconUser,
+  IconSetting,
+  IconBell,
+  IconGithubLogo,
+  IconKey,
+  IconDelete,
+  IconChevronDown,
+  IconChevronUp,
+} from '@douyinfe/semi-icons';
+import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
+import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
+import TelegramLoginButton from 'react-telegram-login';
+import { useTranslation } from 'react-i18next';
+
+const PersonalSetting = () => {
+  const [userState, userDispatch] = useContext(UserContext);
+  let navigate = useNavigate();
+  const { t } = useTranslation();
+
+  const [inputs, setInputs] = useState({
+    wechat_verification_code: '',
+    email_verification_code: '',
+    email: '',
+    self_account_deletion_confirmation: '',
+    original_password: '',
+    set_new_password: '',
+    set_new_password_confirmation: '',
+  });
+  const [status, setStatus] = useState({});
+  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
+  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
+  const [showEmailBindModal, setShowEmailBindModal] = useState(false);
+  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [disableButton, setDisableButton] = useState(false);
+  const [countdown, setCountdown] = useState(30);
+  const [systemToken, setSystemToken] = useState('');
+  const [models, setModels] = useState([]);
+  const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
+    // Initialize from localStorage if available
+    const savedState = localStorage.getItem('modelsExpanded');
+    return savedState ? JSON.parse(savedState) : false;
+  });
+  const [activeModelCategory, setActiveModelCategory] = useState('all');
+  const MODELS_DISPLAY_COUNT = 25; // 默认显示的模型数量
+  const [notificationSettings, setNotificationSettings] = useState({
+    warningType: 'email',
+    warningThreshold: 100000,
+    webhookUrl: '',
+    webhookSecret: '',
+    notificationEmail: '',
+    acceptUnsetModelRatioModel: false,
+  });
+  const [modelsLoading, setModelsLoading] = useState(true);
+  const [showWebhookDocs, setShowWebhookDocs] = useState(true);
+
+  useEffect(() => {
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      setStatus(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+    getUserData().then((res) => {
+      console.log(userState);
+    });
+    loadModels().then();
+  }, []);
+
+  useEffect(() => {
+    let countdownInterval = null;
+    if (disableButton && countdown > 0) {
+      countdownInterval = setInterval(() => {
+        setCountdown(countdown - 1);
+      }, 1000);
+    } else if (countdown === 0) {
+      setDisableButton(false);
+      setCountdown(30);
+    }
+    return () => clearInterval(countdownInterval); // Clean up on unmount
+  }, [disableButton, countdown]);
+
+  useEffect(() => {
+    if (userState?.user?.setting) {
+      const settings = JSON.parse(userState.user.setting);
+      setNotificationSettings({
+        warningType: settings.notify_type || 'email',
+        warningThreshold: settings.quota_warning_threshold || 500000,
+        webhookUrl: settings.webhook_url || '',
+        webhookSecret: settings.webhook_secret || '',
+        notificationEmail: settings.notification_email || '',
+        acceptUnsetModelRatioModel:
+          settings.accept_unset_model_ratio_model || false,
+      });
+    }
+  }, [userState?.user?.setting]);
+
+  // Save models expanded state to localStorage whenever it changes
+  useEffect(() => {
+    localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded));
+  }, [isModelsExpanded]);
+
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
+
+  const generateAccessToken = async () => {
+    const res = await API.get('/api/user/token');
+    const { success, message, data } = res.data;
+    if (success) {
+      setSystemToken(data);
+      await copy(data);
+      showSuccess(t('令牌已重置并已复制到剪贴板'));
+    } else {
+      showError(message);
+    }
+  };
+
+  const getUserData = async () => {
+    let res = await API.get(`/api/user/self`);
+    const { success, message, data } = res.data;
+    if (success) {
+      userDispatch({ type: 'login', payload: data });
+    } else {
+      showError(message);
+    }
+  };
+
+  const loadModels = async () => {
+    setModelsLoading(true);
+
+    try {
+      let res = await API.get(`/api/user/models`);
+      const { success, message, data } = res.data;
+
+      if (success) {
+        if (data != null) {
+          setModels(data);
+        }
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError(t('加载模型列表失败'));
+    } finally {
+      setModelsLoading(false);
+    }
+  };
+
+  const handleSystemTokenClick = async (e) => {
+    e.target.select();
+    await copy(e.target.value);
+    showSuccess(t('系统令牌已复制到剪切板'));
+  };
+
+  const deleteAccount = async () => {
+    if (inputs.self_account_deletion_confirmation !== userState.user.username) {
+      showError(t('请输入你的账户名以确认删除!'));
+      return;
+    }
+
+    const res = await API.delete('/api/user/self');
+    const { success, message } = res.data;
+
+    if (success) {
+      showSuccess(t('账户已删除!'));
+      await API.get('/api/user/logout');
+      userDispatch({ type: 'logout' });
+      localStorage.removeItem('user');
+      navigate('/login');
+    } else {
+      showError(message);
+    }
+  };
+
+  const bindWeChat = async () => {
+    if (inputs.wechat_verification_code === '') return;
+    const res = await API.get(
+      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('微信账户绑定成功!'));
+      setShowWeChatBindModal(false);
+    } else {
+      showError(message);
+    }
+  };
+
+  const changePassword = async () => {
+    if (inputs.original_password === '') {
+      showError(t('请输入原密码!'));
+      return;
+    }
+    if (inputs.set_new_password === '') {
+      showError(t('请输入新密码!'));
+      return;
+    }
+    if (inputs.original_password === inputs.set_new_password) {
+      showError(t('新密码需要和原密码不一致!'));
+      return;
+    }
+    if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
+      showError(t('两次输入的密码不一致!'));
+      return;
+    }
+    const res = await API.put(`/api/user/self`, {
+      original_password: inputs.original_password,
+      password: inputs.set_new_password,
+    });
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('密码修改成功!'));
+      setShowWeChatBindModal(false);
+    } else {
+      showError(message);
+    }
+    setShowChangePasswordModal(false);
+  };
+
+  const sendVerificationCode = async () => {
+    if (inputs.email === '') {
+      showError(t('请输入邮箱!'));
+      return;
+    }
+    setDisableButton(true);
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
+      return;
+    }
+    setLoading(true);
+    const res = await API.get(
+      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('验证码发送成功,请检查邮箱!'));
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const bindEmail = async () => {
+    if (inputs.email_verification_code === '') {
+      showError(t('请输入邮箱验证码!'));
+      return;
+    }
+    setLoading(true);
+    const res = await API.get(
+      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('邮箱账户绑定成功!'));
+      setShowEmailBindModal(false);
+      userState.user.email = inputs.email;
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const getUsername = () => {
+    if (userState.user) {
+      return userState.user.username;
+    } else {
+      return 'null';
+    }
+  };
+
+  const getAvatarText = () => {
+    const username = getUsername();
+    if (username && username.length > 0) {
+      // 获取前两个字符,支持中文和英文
+      return username.slice(0, 2).toUpperCase();
+    }
+    return 'NA';
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess(t('已复制:') + text);
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
+    }
+  };
+
+  const handleNotificationSettingChange = (type, value) => {
+    setNotificationSettings((prev) => ({
+      ...prev,
+      [type]: value.target ? value.target.value : value, // 处理 Radio 事件对象
+    }));
+  };
+
+  const saveNotificationSettings = async () => {
+    try {
+      const res = await API.put('/api/user/setting', {
+        notify_type: notificationSettings.warningType,
+        quota_warning_threshold: parseFloat(
+          notificationSettings.warningThreshold,
+        ),
+        webhook_url: notificationSettings.webhookUrl,
+        webhook_secret: notificationSettings.webhookSecret,
+        notification_email: notificationSettings.notificationEmail,
+        accept_unset_model_ratio_model:
+          notificationSettings.acceptUnsetModelRatioModel,
+      });
+
+      if (res.data.success) {
+        showSuccess(t('通知设置已更新'));
+        await getUserData();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('更新通知设置失败'));
+    }
+  };
+
+  return (
+    <div className="bg-gray-50">
+      <Layout>
+        <Layout.Content>
+
+          <div className="flex justify-center">
+            <div className="w-full">
+              {/* 主卡片容器 */}
+              <Card className="!rounded-2xl shadow-lg border-0">
+                {/* 顶部用户信息区域 */}
+                <Card
+                  className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
+                  style={{
+                    background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
+                    position: 'relative'
+                  }}
+                  bodyStyle={{ padding: 0 }}
+                >
+                  {/* 装饰性背景元素 */}
+                  <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 -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 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>
+
+                  <div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
+                    <div className="flex justify-between items-start mb-4 sm:mb-6">
+                      <div className="flex items-center flex-1 min-w-0">
+                        <Avatar
+                          size='large'
+                          color={stringToColor(getUsername())}
+                          border={{ motion: true }}
+                          contentMotion={true}
+                          className="mr-3 sm:mr-4 shadow-lg flex-shrink-0"
+                        >
+                          {getAvatarText()}
+                        </Avatar>
+                        <div className="flex-1 min-w-0">
+                          <div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
+                            {getUsername()}
+                          </div>
+                          <div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
+                            {isRoot() ? (
+                              <Tag
+                                color='red'
+                                size='small'
+                                style={{
+                                  backgroundColor: 'rgba(255, 255, 255, 0.95)',
+                                  color: '#dc2626',
+                                  fontWeight: '600'
+                                }}
+                                className="!rounded-full"
+                              >
+                                {t('超级管理员')}
+                              </Tag>
+                            ) : isAdmin() ? (
+                              <Tag
+                                color='orange'
+                                size='small'
+                                style={{
+                                  backgroundColor: 'rgba(255, 255, 255, 0.95)',
+                                  color: '#ea580c',
+                                  fontWeight: '600'
+                                }}
+                                className="!rounded-full"
+                              >
+                                {t('管理员')}
+                              </Tag>
+                            ) : (
+                              <Tag
+                                color='blue'
+                                size='small'
+                                style={{
+                                  backgroundColor: 'rgba(255, 255, 255, 0.95)',
+                                  color: '#2563eb',
+                                  fontWeight: '600'
+                                }}
+                                className="!rounded-full"
+                              >
+                                {t('普通用户')}
+                              </Tag>
+                            )}
+                            <Tag
+                              color='green'
+                              size='small'
+                              className="!rounded-full"
+                              style={{
+                                backgroundColor: 'rgba(255, 255, 255, 0.95)',
+                                color: '#16a34a',
+                                fontWeight: '600'
+                              }}
+                            >
+                              ID: {userState?.user?.id}
+                            </Tag>
+                          </div>
+                        </div>
+                      </div>
+                      <div
+                        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"
+                        style={{
+                          background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
+                        }}
+                      >
+                        <IconUser size="default" style={{ color: 'white' }} />
+                      </div>
+                    </div>
+
+                    <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)' }}>
+                        {t('当前余额')}
+                      </div>
+                      <div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
+                        {renderQuota(userState?.user?.quota)}
+                      </div>
+                    </div>
+
+                    <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="text-center sm:text-left">
+                          <div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
+                            {t('历史消耗')}
+                          </div>
+                          <div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
+                            {renderQuota(userState?.user?.used_quota)}
+                          </div>
+                        </div>
+                        <div className="text-center sm:text-left">
+                          <div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
+                            {t('请求次数')}
+                          </div>
+                          <div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
+                            {userState.user?.request_count || 0}
+                          </div>
+                        </div>
+                        <div className="text-center sm:text-left">
+                          <div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
+                            {t('用户分组')}
+                          </div>
+                          <div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
+                            {userState?.user?.group || t('默认')}
+                          </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>
+                </Card>
+
+                {/* 主内容区域 - 使用Tabs组织不同功能模块 */}
+                <div className="p-4">
+                  <Tabs type='line' defaultActiveKey='models' className="modern-tabs">
+                    {/* 可用模型Tab */}
+                    <TabPane
+                      tab={
+                        <div className="flex items-center">
+                          <Settings size={16} className="mr-2" />
+                          {t('可用模型')}
+                        </div>
+                      }
+                      itemKey='models'
+                    >
+                      <div className="gap-6 py-4">
+                        {/* 可用模型部分 */}
+                        <div className="bg-gray-50 rounded-xl">
+                          <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">
+                              <Settings size={20} className="text-purple-500" />
+                            </div>
+                            <div>
+                              <Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
+                              <div className="text-gray-500 text-sm">{t('点击模型名称可复制')}</div>
+                            </div>
+                          </div>
+
+                          {modelsLoading ? (
+                            // 骨架屏加载状态 - 模拟实际加载后的布局
+                            <div className="space-y-4">
+                              {/* 模拟分类标签 */}
+                              <div className="mb-4" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
+                                <div className="flex overflow-x-auto py-2 gap-2">
+                                  {Array.from({ length: 8 }).map((_, index) => (
+                                    <Skeleton.Button key={`cat-${index}`} style={{
+                                      width: index === 0 ? 130 : 100 + Math.random() * 50,
+                                      height: 36,
+                                      borderRadius: 8
+                                    }} />
+                                  ))}
+                                </div>
+                              </div>
+
+                              {/* 模拟模型标签列表 */}
+                              <div className="flex flex-wrap gap-2">
+                                {Array.from({ length: 20 }).map((_, index) => (
+                                  <Skeleton.Button
+                                    key={`model-${index}`}
+                                    style={{
+                                      width: 100 + Math.random() * 100,
+                                      height: 32,
+                                      borderRadius: 16,
+                                      margin: '4px'
+                                    }}
+                                  />
+                                ))}
+                              </div>
+                            </div>
+                          ) : models.length === 0 ? (
+                            <div className="py-8">
+                              <Empty
+                                image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
+                                darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
+                                description={t('没有可用模型')}
+                                style={{ padding: '24px 0' }}
+                              />
+                            </div>
+                          ) : (
+                            <>
+                              {/* 模型分类标签页 */}
+                              <div className="mb-4">
+                                <Tabs
+                                  type="card"
+                                  activeKey={activeModelCategory}
+                                  onChange={key => setActiveModelCategory(key)}
+                                  className="mt-2"
+                                >
+                                  {Object.entries(getModelCategories(t)).map(([key, category]) => {
+                                    // 计算该分类下的模型数量
+                                    const modelCount = key === 'all'
+                                      ? models.length
+                                      : models.filter(model => category.filter({ model_name: model })).length;
+
+                                    if (modelCount === 0 && key !== 'all') return null;
+
+                                    return (
+                                      <TabPane
+                                        tab={
+                                          <span className="flex items-center gap-2">
+                                            {category.icon && <span className="w-4 h-4">{category.icon}</span>}
+                                            {category.label}
+                                            <Tag
+                                              color={activeModelCategory === key ? 'red' : 'grey'}
+                                              size='small'
+                                              shape='circle'
+                                            >
+                                              {modelCount}
+                                            </Tag>
+                                          </span>
+                                        }
+                                        itemKey={key}
+                                        key={key}
+                                      />
+                                    );
+                                  })}
+                                </Tabs>
+                              </div>
+
+                              <div className="bg-white rounded-lg p-3">
+                                {(() => {
+                                  // 根据当前选中的分类过滤模型
+                                  const categories = getModelCategories(t);
+                                  const filteredModels = activeModelCategory === 'all'
+                                    ? models
+                                    : models.filter(model => categories[activeModelCategory].filter({ model_name: model }));
+
+                                  // 如果过滤后没有模型,显示空状态
+                                  if (filteredModels.length === 0) {
+                                    return (
+                                      <Empty
+                                        image={<IllustrationNoContent style={{ width: 120, height: 120 }} />}
+                                        darkModeImage={<IllustrationNoContentDark style={{ width: 120, height: 120 }} />}
+                                        description={t('该分类下没有可用模型')}
+                                        style={{ padding: '16px 0' }}
+                                      />
+                                    );
+                                  }
+
+                                  if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
+                                    return (
+                                      <Space wrap>
+                                        {filteredModels.map((model) => (
+                                          renderModelTag(model, {
+                                            size: 'large',
+                                            shape: 'circle',
+                                            onClick: () => copyText(model),
+                                          })
+                                        ))}
+                                      </Space>
+                                    );
+                                  } else {
+                                    return (
+                                      <>
+                                        <Collapsible isOpen={isModelsExpanded}>
+                                          <Space wrap>
+                                            {filteredModels.map((model) => (
+                                              renderModelTag(model, {
+                                                size: 'large',
+                                                shape: 'circle',
+                                                onClick: () => copyText(model),
+                                              })
+                                            ))}
+                                            <Tag
+                                              color='grey'
+                                              type='light'
+                                              className="cursor-pointer !rounded-lg"
+                                              onClick={() => setIsModelsExpanded(false)}
+                                              icon={<IconChevronUp />}
+                                            >
+                                              {t('收起')}
+                                            </Tag>
+                                          </Space>
+                                        </Collapsible>
+                                        {!isModelsExpanded && (
+                                          <Space wrap>
+                                            {filteredModels
+                                              .slice(0, MODELS_DISPLAY_COUNT)
+                                              .map((model) => (
+                                                renderModelTag(model, {
+                                                  size: 'large',
+                                                  shape: 'circle',
+                                                  onClick: () => copyText(model),
+                                                })
+                                              ))}
+                                            <Tag
+                                              color='grey'
+                                              type='light'
+                                              className="cursor-pointer !rounded-lg"
+                                              onClick={() => setIsModelsExpanded(true)}
+                                              icon={<IconChevronDown />}
+                                            >
+                                              {t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')}
+                                            </Tag>
+                                          </Space>
+                                        )}
+                                      </>
+                                    );
+                                  }
+                                })()}
+                              </div>
+                            </>
+                          )}
+                        </div>
+                      </div>
+                    </TabPane>
+
+                    {/* 账户绑定Tab */}
+                    <TabPane
+                      tab={
+                        <div className="flex items-center">
+                          <UserPlus size={16} className="mr-2" />
+                          {t('账户绑定')}
+                        </div>
+                      }
+                      itemKey='account'
+                    >
+                      <div className="py-4">
+                        <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
+                          {/* 邮箱绑定 */}
+                          <Card
+                            className="!rounded-xl transition-shadow"
+                            bodyStyle={{ padding: '16px' }}
+                            shadows='hover'
+                          >
+                            <div className="flex items-center justify-between">
+                              <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">
+                                  <IconMail size="default" className="text-red-500" />
+                                </div>
+                                <div className="flex-1 min-w-0">
+                                  <div className="font-medium text-gray-900">{t('邮箱')}</div>
+                                  <div className="text-sm text-gray-500 truncate">
+                                    {userState.user && userState.user.email !== ''
+                                      ? userState.user.email
+                                      : t('未绑定')}
+                                  </div>
+                                </div>
+                              </div>
+                              <Button
+                                type="primary"
+                                theme="outline"
+                                size="small"
+                                onClick={() => setShowEmailBindModal(true)}
+                                className="!rounded-lg"
+                              >
+                                {userState.user && userState.user.email !== ''
+                                  ? t('修改绑定')
+                                  : t('绑定')}
+                              </Button>
+                            </div>
+                          </Card>
+
+                          {/* 微信绑定 */}
+                          <Card
+                            className="!rounded-xl transition-shadow"
+                            bodyStyle={{ padding: '16px' }}
+                            shadows='hover'
+                          >
+                            <div className="flex items-center justify-between">
+                              <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">
+                                  <SiWechat size={20} className="text-green-500" />
+                                </div>
+                                <div className="flex-1 min-w-0">
+                                  <div className="font-medium text-gray-900">{t('微信')}</div>
+                                  <div className="text-sm text-gray-500 truncate">
+                                    {userState.user && userState.user.wechat_id !== ''
+                                      ? t('已绑定')
+                                      : t('未绑定')}
+                                  </div>
+                                </div>
+                              </div>
+                              <Button
+                                type="primary"
+                                theme="outline"
+                                size="small"
+                                disabled={!status.wechat_login}
+                                onClick={() => setShowWeChatBindModal(true)}
+                                className="!rounded-lg"
+                              >
+                                {userState.user && userState.user.wechat_id !== ''
+                                  ? t('修改绑定')
+                                  : status.wechat_login
+                                    ? t('绑定')
+                                    : t('未启用')}
+                              </Button>
+                            </div>
+                          </Card>
+
+                          {/* GitHub绑定 */}
+                          <Card
+                            className="!rounded-xl transition-shadow"
+                            bodyStyle={{ padding: '16px' }}
+                            shadows='hover'
+                          >
+                            <div className="flex items-center justify-between">
+                              <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">
+                                  <IconGithubLogo size="default" className="text-gray-700" />
+                                </div>
+                                <div className="flex-1 min-w-0">
+                                  <div className="font-medium text-gray-900">{t('GitHub')}</div>
+                                  <div className="text-sm text-gray-500 truncate">
+                                    {userState.user && userState.user.github_id !== ''
+                                      ? userState.user.github_id
+                                      : t('未绑定')}
+                                  </div>
+                                </div>
+                              </div>
+                              <Button
+                                type="primary"
+                                theme="outline"
+                                size="small"
+                                onClick={() => onGitHubOAuthClicked(status.github_client_id)}
+                                disabled={
+                                  (userState.user && userState.user.github_id !== '') ||
+                                  !status.github_oauth
+                                }
+                                className="!rounded-lg"
+                              >
+                                {status.github_oauth ? t('绑定') : t('未启用')}
+                              </Button>
+                            </div>
+                          </Card>
+
+                          {/* OIDC绑定 */}
+                          <Card
+                            className="!rounded-xl transition-shadow"
+                            bodyStyle={{ padding: '16px' }}
+                            shadows='hover'
+                          >
+                            <div className="flex items-center justify-between">
+                              <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">
+                                  <IconShield size="default" className="text-indigo-500" />
+                                </div>
+                                <div className="flex-1 min-w-0">
+                                  <div className="font-medium text-gray-900">{t('OIDC')}</div>
+                                  <div className="text-sm text-gray-500 truncate">
+                                    {userState.user && userState.user.oidc_id !== ''
+                                      ? userState.user.oidc_id
+                                      : t('未绑定')}
+                                  </div>
+                                </div>
+                              </div>
+                              <Button
+                                type="primary"
+                                theme="outline"
+                                size="small"
+                                onClick={() => onOIDCClicked(
+                                  status.oidc_authorization_endpoint,
+                                  status.oidc_client_id,
+                                )}
+                                disabled={
+                                  (userState.user && userState.user.oidc_id !== '') ||
+                                  !status.oidc_enabled
+                                }
+                                className="!rounded-lg"
+                              >
+                                {status.oidc_enabled ? t('绑定') : t('未启用')}
+                              </Button>
+                            </div>
+                          </Card>
+
+                          {/* Telegram绑定 */}
+                          <Card
+                            className="!rounded-xl transition-shadow"
+                            bodyStyle={{ padding: '16px' }}
+                            shadows='hover'
+                          >
+                            <div className="flex items-center justify-between">
+                              <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">
+                                  <SiTelegram size={20} className="text-blue-500" />
+                                </div>
+                                <div className="flex-1 min-w-0">
+                                  <div className="font-medium text-gray-900">{t('Telegram')}</div>
+                                  <div className="text-sm text-gray-500 truncate">
+                                    {userState.user && userState.user.telegram_id !== ''
+                                      ? userState.user.telegram_id
+                                      : t('未绑定')}
+                                  </div>
+                                </div>
+                              </div>
+                              <div className="flex-shrink-0">
+                                {status.telegram_oauth ? (
+                                  userState.user.telegram_id !== '' ? (
+                                    <Button disabled={true} size="small" className="!rounded-lg">
+                                      {t('已绑定')}
+                                    </Button>
+                                  ) : (
+                                    <div className="scale-75">
+                                      <TelegramLoginButton
+                                        dataAuthUrl='/api/oauth/telegram/bind'
+                                        botName={status.telegram_bot_name}
+                                      />
+                                    </div>
+                                  )
+                                ) : (
+                                  <Button disabled={true} size="small" className="!rounded-lg">
+                                    {t('未启用')}
+                                  </Button>
+                                )}
+                              </div>
+                            </div>
+                          </Card>
+
+                          {/* LinuxDO绑定 */}
+                          <Card
+                            className="!rounded-xl transition-shadow"
+                            bodyStyle={{ padding: '16px' }}
+                            shadows='hover'
+                          >
+                            <div className="flex items-center justify-between">
+                              <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">
+                                  <SiLinux size={20} className="text-orange-500" />
+                                </div>
+                                <div className="flex-1 min-w-0">
+                                  <div className="font-medium text-gray-900">{t('LinuxDO')}</div>
+                                  <div className="text-sm text-gray-500 truncate">
+                                    {userState.user && userState.user.linux_do_id !== ''
+                                      ? userState.user.linux_do_id
+                                      : t('未绑定')}
+                                  </div>
+                                </div>
+                              </div>
+                              <Button
+                                type="primary"
+                                theme="outline"
+                                size="small"
+                                onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)}
+                                disabled={
+                                  (userState.user && userState.user.linux_do_id !== '') ||
+                                  !status.linuxdo_oauth
+                                }
+                                className="!rounded-lg"
+                              >
+                                {status.linuxdo_oauth ? t('绑定') : t('未启用')}
+                              </Button>
+                            </div>
+                          </Card>
+                        </div>
+                      </div>
+                    </TabPane>
+
+                    {/* 安全设置Tab */}
+                    <TabPane
+                      tab={
+                        <div className="flex items-center">
+                          <ShieldCheck size={16} className="mr-2" />
+                          {t('安全设置')}
+                        </div>
+                      }
+                      itemKey='security'
+                    >
+                      <div className="py-4">
+                        <div className="space-y-6">
+                          <Space vertical className='w-full'>
+                            {/* 系统访问令牌 */}
+                            <Card
+                              className="!rounded-xl w-full"
+                              bodyStyle={{ padding: '20px' }}
+                              shadows='hover'
+                            >
+                              <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="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4 flex-shrink-0">
+                                    <IconKey size="large" className="text-blue-500" />
+                                  </div>
+                                  <div className="flex-1">
+                                    <Typography.Title heading={6} className="mb-1">
+                                      {t('系统访问令牌')}
+                                    </Typography.Title>
+                                    <Typography.Text type="tertiary" className="text-sm">
+                                      {t('用于API调用的身份验证令牌,请妥善保管')}
+                                    </Typography.Text>
+                                    {systemToken && (
+                                      <div className="mt-3">
+                                        <Input
+                                          readOnly
+                                          value={systemToken}
+                                          onClick={handleSystemTokenClick}
+                                          size="large"
+                                          className="!rounded-lg"
+                                          prefix={<IconKey />}
+                                        />
+                                      </div>
+                                    )}
+                                  </div>
+                                </div>
+                                <Button
+                                  type="primary"
+                                  theme="solid"
+                                  onClick={generateAccessToken}
+                                  className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 w-full sm:w-auto"
+                                  icon={<IconKey />}
+                                >
+                                  {systemToken ? t('重新生成') : t('生成令牌')}
+                                </Button>
+                              </div>
+                            </Card>
+
+                            {/* 密码管理 */}
+                            <Card
+                              className="!rounded-xl w-full"
+                              bodyStyle={{ padding: '20px' }}
+                              shadows='hover'
+                            >
+                              <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="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4 flex-shrink-0">
+                                    <IconLock size="large" className="text-orange-500" />
+                                  </div>
+                                  <div>
+                                    <Typography.Title heading={6} className="mb-1">
+                                      {t('密码管理')}
+                                    </Typography.Title>
+                                    <Typography.Text type="tertiary" className="text-sm">
+                                      {t('定期更改密码可以提高账户安全性')}
+                                    </Typography.Text>
+                                  </div>
+                                </div>
+                                <Button
+                                  type="primary"
+                                  theme="solid"
+                                  onClick={() => setShowChangePasswordModal(true)}
+                                  className="!rounded-lg !bg-orange-500 hover:!bg-orange-600 w-full sm:w-auto"
+                                  icon={<IconLock />}
+                                >
+                                  {t('修改密码')}
+                                </Button>
+                              </div>
+                            </Card>
+
+                            {/* 危险区域 */}
+                            <Card
+                              className="!rounded-xl border-red-200 w-full"
+                              bodyStyle={{ padding: '20px' }}
+                              shadows='hover'
+                            >
+                              <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="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mr-4 flex-shrink-0">
+                                    <IconDelete size="large" className="text-red-500" />
+                                  </div>
+                                  <div>
+                                    <Typography.Title heading={6} className="mb-1 text-red-600">
+                                      {t('删除账户')}
+                                    </Typography.Title>
+                                    <Typography.Text type="tertiary" className="text-sm">
+                                      {t('此操作不可逆,所有数据将被永久删除')}
+                                    </Typography.Text>
+                                  </div>
+                                </div>
+                                <Button
+                                  type="danger"
+                                  theme="solid"
+                                  onClick={() => setShowAccountDeleteModal(true)}
+                                  className="!rounded-lg w-full sm:w-auto"
+                                  icon={<IconDelete />}
+                                >
+                                  {t('删除账户')}
+                                </Button>
+                              </div>
+                            </Card>
+                          </Space>
+                        </div>
+                      </div>
+                    </TabPane>
+
+                    {/* 通知设置Tab */}
+                    <TabPane
+                      tab={
+                        <div className="flex items-center">
+                          <Bell size={16} className="mr-2" />
+                          {t('通知设置')}
+                        </div>
+                      }
+                      itemKey='notification'
+                    >
+                      <div className="py-4">
+                        <Tabs type='card' defaultActiveKey='notify' className="!rounded-lg">
+                          <TabPane
+                            tab={t('通知设置')}
+                            itemKey='notify'
+                          >
+                            <div className="space-y-6">
+                              {/* 通知方式选择 */}
+                              <div className="bg-gray-50 rounded-xl">
+                                <Typography.Text strong className="block mb-4 pt-4">{t('通知方式')}</Typography.Text>
+                                <RadioGroup
+                                  value={notificationSettings.warningType}
+                                  onChange={(value) =>
+                                    handleNotificationSettingChange('warningType', value)
+                                  }
+                                  type="pureCard"
+                                >
+                                  <Radio value='email' className="!p-4 !rounded-lg">
+                                    <div className="flex items-center">
+                                      <IconMail className="mr-2 text-blue-500" />
+                                      <div>
+                                        <div className="font-medium">{t('邮件通知')}</div>
+                                        <div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
+                                      </div>
+                                    </div>
+                                  </Radio>
+                                  <Radio value='webhook' className="!p-4 !rounded-lg">
+                                    <div className="flex items-center">
+                                      <Webhook size={16} className="mr-2 text-green-500" />
+                                      <div>
+                                        <div className="font-medium">{t('Webhook通知')}</div>
+                                        <div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
+                                      </div>
+                                    </div>
+                                  </Radio>
+                                </RadioGroup>
+                              </div>
+
+                              {/* Webhook设置 */}
+                              {notificationSettings.warningType === 'webhook' && (
+                                <div className="space-y-4">
+                                  <div className="bg-white rounded-xl">
+                                    <Typography.Text strong className="block mb-3">{t('Webhook地址')}</Typography.Text>
+                                    <Input
+                                      value={notificationSettings.webhookUrl}
+                                      onChange={(val) =>
+                                        handleNotificationSettingChange('webhookUrl', val)
+                                      }
+                                      placeholder={t('请输入Webhook地址,例如: https://example.com/webhook')}
+                                      size="large"
+                                      className="!rounded-lg"
+                                      prefix={<Webhook size={16} className="m-2" />}
+                                    />
+                                    <div className="text-gray-500 text-sm mt-2">
+                                      {t('只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求')}
+                                    </div>
+                                  </div>
+
+                                  <div className="bg-white rounded-xl">
+                                    <Typography.Text strong className="block mb-3">{t('接口凭证(可选)')}</Typography.Text>
+                                    <Input
+                                      value={notificationSettings.webhookSecret}
+                                      onChange={(val) =>
+                                        handleNotificationSettingChange('webhookSecret', val)
+                                      }
+                                      placeholder={t('请输入密钥')}
+                                      size="large"
+                                      className="!rounded-lg"
+                                      prefix={<IconKey />}
+                                    />
+                                    <div className="text-gray-500 text-sm mt-2">
+                                      {t('密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性')}
+                                    </div>
+                                  </div>
+
+                                  <div className="bg-yellow-50 rounded-xl">
+                                    <div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
+                                      <div className="flex items-center">
+                                        <Globe size={16} className="mr-2 text-yellow-600" />
+                                        <Typography.Text strong className="text-yellow-800">
+                                          {t('Webhook请求结构')}
+                                        </Typography.Text>
+                                      </div>
+                                      {showWebhookDocs ? <IconChevronUp /> : <IconChevronDown />}
+                                    </div>
+                                    <Collapsible isOpen={showWebhookDocs}>
+                                      <pre className="mt-4 bg-gray-800 text-gray-100 rounded-lg text-sm overflow-x-auto">
+                                        {`{
+  "type": "quota_exceed",      // 通知类型
+  "title": "标题",             // 通知标题
+  "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
+  "values": ["值1", "值2"],    // 按顺序替换content中的 {{value}} 占位符
+  "timestamp": 1739950503      // 时间戳
+}
+
+示例:
+{
+  "type": "quota_exceed",
+  "title": "额度预警通知",
+  "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
+  "values": ["$0.99"],
+  "timestamp": 1739950503
+}`}
+                                      </pre>
+                                    </Collapsible>
+                                  </div>
+                                </div>
+                              )}
+
+                              {/* 邮件设置 */}
+                              {notificationSettings.warningType === 'email' && (
+                                <div className="bg-white rounded-xl">
+                                  <Typography.Text strong className="block mb-3">{t('通知邮箱')}</Typography.Text>
+                                  <Input
+                                    value={notificationSettings.notificationEmail}
+                                    onChange={(val) =>
+                                      handleNotificationSettingChange('notificationEmail', val)
+                                    }
+                                    placeholder={t('留空则使用账号绑定的邮箱')}
+                                    size="large"
+                                    className="!rounded-lg"
+                                    prefix={<IconMail />}
+                                  />
+                                  <div className="text-gray-500 text-sm mt-2">
+                                    {t('设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱')}
+                                  </div>
+                                </div>
+                              )}
+
+                              {/* 预警阈值 */}
+                              <div className="bg-white rounded-xl">
+                                <Typography.Text strong className="block mb-3">
+                                  {t('额度预警阈值')} {renderQuotaWithPrompt(notificationSettings.warningThreshold)}
+                                </Typography.Text>
+                                <AutoComplete
+                                  value={notificationSettings.warningThreshold}
+                                  onChange={(val) =>
+                                    handleNotificationSettingChange('warningThreshold', val)
+                                  }
+                                  size="large"
+                                  className="!rounded-lg w-full max-w-xs"
+                                  placeholder={t('请输入预警额度')}
+                                  data={[
+                                    { value: 100000, label: '0.2$' },
+                                    { value: 500000, label: '1$' },
+                                    { value: 1000000, label: '5$' },
+                                    { value: 5000000, label: '10$' },
+                                  ]}
+                                  prefix={<IconBell />}
+                                />
+                                <div className="text-gray-500 text-sm mt-2">
+                                  {t('当剩余额度低于此数值时,系统将通过选择的方式发送通知')}
+                                </div>
+                              </div>
+                            </div>
+                          </TabPane>
+
+                          <TabPane
+                            tab={t('价格设置')}
+                            itemKey='price'
+                          >
+                            <div className="py-4">
+                              <div className="bg-white rounded-xl">
+                                <div className="flex items-start">
+                                  <div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mt-1">
+                                    <Shield size={20} className="text-orange-500" />
+                                  </div>
+                                  <div className="flex-1">
+                                    <div className="flex items-center justify-between">
+                                      <div>
+                                        <Typography.Text strong className="block mb-2">
+                                          {t('接受未设置价格模型')}
+                                        </Typography.Text>
+                                        <div className="text-gray-500 text-sm">
+                                          {t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
+                                        </div>
+                                      </div>
+                                      <Checkbox
+                                        checked={notificationSettings.acceptUnsetModelRatioModel}
+                                        onChange={(e) =>
+                                          handleNotificationSettingChange(
+                                            'acceptUnsetModelRatioModel',
+                                            e.target.checked,
+                                          )
+                                        }
+                                        className="ml-4"
+                                      />
+                                    </div>
+                                  </div>
+                                </div>
+                              </div>
+                            </div>
+                          </TabPane>
+                        </Tabs>
+
+                        <div className="mt-6 flex justify-end">
+                          <Button
+                            type='primary'
+                            onClick={saveNotificationSettings}
+                            size="large"
+                            className="!rounded-lg !bg-purple-500 hover:!bg-purple-600"
+                            icon={<IconSetting />}
+                          >
+                            {t('保存设置')}
+                          </Button>
+                        </div>
+                      </div>
+                    </TabPane>
+                  </Tabs>
+                </div>
+              </Card>
+            </div>
+          </div>
+        </Layout.Content>
+      </Layout>
+
+      {/* 邮箱绑定模态框 */}
+      <Modal
+        title={
+          <div className="flex items-center">
+            <IconMail className="mr-2 text-blue-500" />
+            {t('绑定邮箱地址')}
+          </div>
+        }
+        visible={showEmailBindModal}
+        onCancel={() => setShowEmailBindModal(false)}
+        onOk={bindEmail}
+        size={'small'}
+        centered={true}
+        maskClosable={false}
+        className="modern-modal"
+      >
+        <div className="space-y-4 py-4">
+          <div className="flex gap-3">
+            <Input
+              placeholder={t('输入邮箱地址')}
+              onChange={(value) => handleInputChange('email', value)}
+              name='email'
+              type='email'
+              size="large"
+              className="!rounded-lg flex-1"
+              prefix={<IconMail />}
+            />
+            <Button
+              onClick={sendVerificationCode}
+              disabled={disableButton || loading}
+              className="!rounded-lg"
+              type="primary"
+              theme="outline"
+              size='large'
+            >
+              {disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
+            </Button>
+          </div>
+
+          <Input
+            placeholder={t('验证码')}
+            name='email_verification_code'
+            value={inputs.email_verification_code}
+            onChange={(value) =>
+              handleInputChange('email_verification_code', value)
+            }
+            size="large"
+            className="!rounded-lg"
+            prefix={<IconKey />}
+          />
+
+          {turnstileEnabled && (
+            <div className="flex justify-center">
+              <Turnstile
+                sitekey={turnstileSiteKey}
+                onVerify={(token) => {
+                  setTurnstileToken(token);
+                }}
+              />
+            </div>
+          )}
+        </div>
+      </Modal>
+
+      {/* 微信绑定模态框 */}
+      <Modal
+        title={
+          <div className="flex items-center">
+            <SiWechat className="mr-2 text-green-500" size={20} />
+            {t('绑定微信账户')}
+          </div>
+        }
+        visible={showWeChatBindModal}
+        onCancel={() => setShowWeChatBindModal(false)}
+        footer={null}
+        size={'small'}
+        centered={true}
+        className="modern-modal"
+      >
+        <div className="space-y-4 py-4 text-center">
+          <Image src={status.wechat_qrcode} className="mx-auto" />
+          <div className="text-gray-600">
+            <p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
+          </div>
+          <Input
+            placeholder={t('验证码')}
+            name='wechat_verification_code'
+            value={inputs.wechat_verification_code}
+            onChange={(v) =>
+              handleInputChange('wechat_verification_code', v)
+            }
+            size="large"
+            className="!rounded-lg"
+            prefix={<IconKey />}
+          />
+          <Button
+            type="primary"
+            theme="solid"
+            size='large'
+            onClick={bindWeChat}
+            className="!rounded-lg w-full !bg-green-500 hover:!bg-green-600"
+            icon={<SiWechat size={16} />}
+          >
+            {t('绑定')}
+          </Button>
+        </div>
+      </Modal>
+
+      {/* 账户删除模态框 */}
+      <Modal
+        title={
+          <div className="flex items-center">
+            <IconDelete className="mr-2 text-red-500" />
+            {t('删除账户确认')}
+          </div>
+        }
+        visible={showAccountDeleteModal}
+        onCancel={() => setShowAccountDeleteModal(false)}
+        onOk={deleteAccount}
+        size={'small'}
+        centered={true}
+        className="modern-modal"
+      >
+        <div className="space-y-4 py-4">
+          <Banner
+            type='danger'
+            description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
+            closeIcon={null}
+            className="!rounded-lg"
+          />
+
+          <div>
+            <Typography.Text strong className="block mb-2 text-red-600">
+              {t('请输入您的用户名以确认删除')}
+            </Typography.Text>
+            <Input
+              placeholder={t('输入你的账户名{{username}}以确认删除', { username: ` ${userState?.user?.username} ` })}
+              name='self_account_deletion_confirmation'
+              value={inputs.self_account_deletion_confirmation}
+              onChange={(value) =>
+                handleInputChange('self_account_deletion_confirmation', value)
+              }
+              size="large"
+              className="!rounded-lg"
+              prefix={<IconUser />}
+            />
+          </div>
+
+          {turnstileEnabled && (
+            <div className="flex justify-center">
+              <Turnstile
+                sitekey={turnstileSiteKey}
+                onVerify={(token) => {
+                  setTurnstileToken(token);
+                }}
+              />
+            </div>
+          )}
+        </div>
+      </Modal>
+
+      {/* 修改密码模态框 */}
+      <Modal
+        title={
+          <div className="flex items-center">
+            <IconLock className="mr-2 text-orange-500" />
+            {t('修改密码')}
+          </div>
+        }
+        visible={showChangePasswordModal}
+        onCancel={() => setShowChangePasswordModal(false)}
+        onOk={changePassword}
+        size={'small'}
+        centered={true}
+        className="modern-modal"
+      >
+        <div className="space-y-4 py-4">
+          <div>
+            <Typography.Text strong className="block mb-2">{t('原密码')}</Typography.Text>
+            <Input
+              name='original_password'
+              placeholder={t('请输入原密码')}
+              type='password'
+              value={inputs.original_password}
+              onChange={(value) =>
+                handleInputChange('original_password', value)
+              }
+              size="large"
+              className="!rounded-lg"
+              prefix={<IconLock />}
+            />
+          </div>
+
+          <div>
+            <Typography.Text strong className="block mb-2">{t('新密码')}</Typography.Text>
+            <Input
+              name='set_new_password'
+              placeholder={t('请输入新密码')}
+              type='password'
+              value={inputs.set_new_password}
+              onChange={(value) =>
+                handleInputChange('set_new_password', value)
+              }
+              size="large"
+              className="!rounded-lg"
+              prefix={<IconLock />}
+            />
+          </div>
+
+          <div>
+            <Typography.Text strong className="block mb-2">{t('确认新密码')}</Typography.Text>
+            <Input
+              name='set_new_password_confirmation'
+              placeholder={t('请再次输入新密码')}
+              type='password'
+              value={inputs.set_new_password_confirmation}
+              onChange={(value) =>
+                handleInputChange('set_new_password_confirmation', value)
+              }
+              size="large"
+              className="!rounded-lg"
+              prefix={<IconLock />}
+            />
+          </div>
+
+          {turnstileEnabled && (
+            <div className="flex justify-center">
+              <Turnstile
+                sitekey={turnstileSiteKey}
+                onVerify={(token) => {
+                  setTurnstileToken(token);
+                }}
+              />
+            </div>
+          )}
+        </div>
+      </Modal>
+    </div>
+  );
+};
+
+export default PersonalSetting;

+ 3 - 3
web/src/components/RateLimitSetting.js → web/src/components/settings/RateLimitSetting.js

@@ -1,10 +1,10 @@
 import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 
-import { API, showError, showSuccess } from '../helpers';
-import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
+import { API, showError, showSuccess } from '../../helpers/index.js';
+import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
 import { useTranslation } from 'react-i18next';
-import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
+import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
 
 const RateLimitSetting = () => {
   const { t } = useTranslation();

+ 3 - 3
web/src/components/SystemSetting.js → web/src/components/settings/SystemSetting.js

@@ -13,12 +13,12 @@ import {
 } from '@douyinfe/semi-ui';
 const { Text } = Typography;
 import {
+  API,
   removeTrailingSlash,
   showError,
   showSuccess,
-  verifyJSON,
-} from '../helpers/utils';
-import { API } from '../helpers/api';
+  verifyJSON
+} from '../../helpers';
 import axios from 'axios';
 
 const SystemSetting = () => {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 539 - 580
web/src/components/table/ChannelsTable.js


+ 307 - 316
web/src/components/LogsTable.js → web/src/components/table/LogsTable.js

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   API,
@@ -8,14 +8,25 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+  renderAudioModelPrice,
+  renderClaudeLogContent,
+  renderClaudeModelPrice,
+  renderClaudeModelPriceSimple,
+  renderGroup,
+  renderLogContent,
+  renderModelPrice,
+  renderModelPriceSimple,
+  renderNumber,
+  renderQuota,
+  stringToColor,
+  getLogOther,
+  renderModelTag,
+} from '../../helpers';
 
 import {
   Avatar,
   Button,
   Descriptions,
-  Form,
-  Layout,
   Modal,
   Popover,
   Select,
@@ -25,27 +36,17 @@ import {
   Tag,
   Tooltip,
   Checkbox,
+  Card,
+  Typography,
+  Divider,
+  Input,
+  DatePicker,
 } from '@douyinfe/semi-ui';
-import { ITEMS_PER_PAGE } from '../constants';
-import {
-  renderAudioModelPrice,
-  renderClaudeLogContent,
-  renderClaudeModelPrice,
-  renderClaudeModelPriceSimple,
-  renderGroup,
-  renderLogContent,
-  renderModelPrice,
-  renderModelPriceSimple,
-  renderNumber,
-  renderQuota,
-  stringToColor,
-} from '../helpers/render';
+import { ITEMS_PER_PAGE } from '../../constants';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
-import { getLogOther } from '../helpers/other.js';
-import { StyleContext } from '../context/Style/index.js';
-import { IconInherit, IconRefresh, IconSetting } from '@douyinfe/semi-icons';
+import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
 
-const { Header } = Layout;
+const { Text } = Typography;
 
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
@@ -81,37 +82,37 @@ const LogsTable = () => {
     switch (type) {
       case 1:
         return (
-          <Tag color='cyan' size='large'>
+          <Tag color='cyan' size='large' shape='circle'>
             {t('充值')}
           </Tag>
         );
       case 2:
         return (
-          <Tag color='lime' size='large'>
+          <Tag color='lime' size='large' shape='circle'>
             {t('消费')}
           </Tag>
         );
       case 3:
         return (
-          <Tag color='orange' size='large'>
+          <Tag color='orange' size='large' shape='circle'>
             {t('管理')}
           </Tag>
         );
       case 4:
         return (
-          <Tag color='purple' size='large'>
+          <Tag color='purple' size='large' shape='circle'>
             {t('系统')}
           </Tag>
         );
       case 5:
         return (
-          <Tag color='red' size='large'>
+          <Tag color='red' size='large' shape='circle'>
             {t('错误')}
           </Tag>
         );
       default:
         return (
-          <Tag color='grey' size='large'>
+          <Tag color='grey' size='large' shape='circle'>
             {t('未知')}
           </Tag>
         );
@@ -121,13 +122,13 @@ const LogsTable = () => {
   function renderIsStream(bool) {
     if (bool) {
       return (
-        <Tag color='blue' size='large'>
+        <Tag color='blue' size='large' shape='circle'>
           {t('流')}
         </Tag>
       );
     } else {
       return (
-        <Tag color='purple' size='large'>
+        <Tag color='purple' size='large' shape='circle'>
           {t('非流')}
         </Tag>
       );
@@ -138,21 +139,21 @@ const LogsTable = () => {
     const time = parseInt(type);
     if (time < 101) {
       return (
-        <Tag color='green' size='large'>
+        <Tag color='green' size='large' shape='circle'>
           {' '}
           {time} s{' '}
         </Tag>
       );
     } else if (time < 300) {
       return (
-        <Tag color='orange' size='large'>
+        <Tag color='orange' size='large' shape='circle'>
           {' '}
           {time} s{' '}
         </Tag>
       );
     } else {
       return (
-        <Tag color='red' size='large'>
+        <Tag color='red' size='large' shape='circle'>
           {' '}
           {time} s{' '}
         </Tag>
@@ -165,21 +166,21 @@ const LogsTable = () => {
     time = time.toFixed(1);
     if (time < 3) {
       return (
-        <Tag color='green' size='large'>
+        <Tag color='green' size='large' shape='circle'>
           {' '}
           {time} s{' '}
         </Tag>
       );
     } else if (time < 10) {
       return (
-        <Tag color='orange' size='large'>
+        <Tag color='orange' size='large' shape='circle'>
           {' '}
           {time} s{' '}
         </Tag>
       );
     } else {
       return (
-        <Tag color='red' size='large'>
+        <Tag color='red' size='large' shape='circle'>
           {' '}
           {time} s{' '}
         </Tag>
@@ -194,18 +195,11 @@ const LogsTable = () => {
       other?.upstream_model_name &&
       other?.upstream_model_name !== '';
     if (!modelMapped) {
-      return (
-        <Tag
-          color={stringToColor(record.model_name)}
-          size='large'
-          onClick={(event) => {
-            copyText(event, record.model_name).then((r) => {});
-          }}
-        >
-          {' '}
-          {record.model_name}{' '}
-        </Tag>
-      );
+      return renderModelTag(record.model_name, {
+        onClick: (event) => {
+          copyText(event, record.model_name).then((r) => { });
+        },
+      });
     } else {
       return (
         <>
@@ -214,57 +208,43 @@ const LogsTable = () => {
               content={
                 <div style={{ padding: 10 }}>
                   <Space vertical align={'start'}>
-                    <Tag
-                      color={stringToColor(record.model_name)}
-                      size='large'
-                      onClick={(event) => {
-                        copyText(event, record.model_name).then((r) => {});
-                      }}
-                    >
-                      {t('请求并计费模型')} {record.model_name}{' '}
-                    </Tag>
-                    <Tag
-                      color={stringToColor(other.upstream_model_name)}
-                      size='large'
-                      onClick={(event) => {
-                        copyText(event, other.upstream_model_name).then(
-                          (r) => {},
-                        );
-                      }}
-                    >
-                      {t('实际模型')} {other.upstream_model_name}{' '}
-                    </Tag>
+                    <div className='flex items-center'>
+                      <Text strong style={{ marginRight: 8 }}>
+                        {t('请求并计费模型')}:
+                      </Text>
+                      {renderModelTag(record.model_name, {
+                        onClick: (event) => {
+                          copyText(event, record.model_name).then((r) => { });
+                        },
+                      })}
+                    </div>
+                    <div className='flex items-center'>
+                      <Text strong style={{ marginRight: 8 }}>
+                        {t('实际模型')}:
+                      </Text>
+                      {renderModelTag(other.upstream_model_name, {
+                        onClick: (event) => {
+                          copyText(event, other.upstream_model_name).then(
+                            (r) => { },
+                          );
+                        },
+                      })}
+                    </div>
                   </Space>
                 </div>
               }
             >
-              <Tag
-                color={stringToColor(record.model_name)}
-                size='large'
-                onClick={(event) => {
-                  copyText(event, record.model_name).then((r) => {});
-                }}
-                suffixIcon={
-                  <IconRefresh
-                    style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }}
+              {renderModelTag(record.model_name, {
+                onClick: (event) => {
+                  copyText(event, record.model_name).then((r) => { });
+                },
+                suffixIcon: (
+                  <IconForward
+                    style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
                   />
-                }
-              >
-                {' '}
-                {record.model_name}{' '}
-              </Tag>
+                ),
+              })}
             </Popover>
-            {/*<Tooltip content={t('实际模型')}>*/}
-            {/*  <Tag*/}
-            {/*    color={stringToColor(other.upstream_model_name)}*/}
-            {/*    size='large'*/}
-            {/*    onClick={(event) => {*/}
-            {/*      copyText(event, other.upstream_model_name).then(r => {});*/}
-            {/*    }}*/}
-            {/*  >*/}
-            {/*    {' '}{other.upstream_model_name}{' '}*/}
-            {/*  </Tag>*/}
-            {/*</Tooltip>*/}
           </Space>
         </>
       );
@@ -386,6 +366,7 @@ const LogsTable = () => {
                   <Tag
                     color={colors[parseInt(text) % colors.length]}
                     size='large'
+                    shape='circle'
                   >
                     {' '}
                     {text}{' '}
@@ -437,6 +418,7 @@ const LogsTable = () => {
             <Tag
               color='grey'
               size='large'
+              shape='circle'
               onClick={(event) => {
                 //cancel the row click event
                 copyText(event, text);
@@ -600,6 +582,7 @@ const LogsTable = () => {
       key: COLUMN_KEYS.DETAILS,
       title: t('详情'),
       dataIndex: 'content',
+      fixed: 'right',
       render: (text, record, index) => {
         let other = getLogOther(record.other);
         if (other == null || record.type !== 2) {
@@ -620,21 +603,21 @@ const LogsTable = () => {
         }
         let content = other?.claude
           ? renderClaudeModelPriceSimple(
-              other.model_ratio,
-              other.model_price,
-              other.group_ratio,
-              other.cache_tokens || 0,
-              other.cache_ratio || 1.0,
-              other.cache_creation_tokens || 0,
-              other.cache_creation_ratio || 1.0,
-            )
+            other.model_ratio,
+            other.model_price,
+            other.group_ratio,
+            other.cache_tokens || 0,
+            other.cache_ratio || 1.0,
+            other.cache_creation_tokens || 0,
+            other.cache_creation_ratio || 1.0,
+          )
           : renderModelPriceSimple(
-              other.model_ratio,
-              other.model_price,
-              other.group_ratio,
-              other.cache_tokens || 0,
-              other.cache_ratio || 1.0,
-            );
+            other.model_ratio,
+            other.model_price,
+            other.group_ratio,
+            other.cache_tokens || 0,
+            other.cache_ratio || 1.0,
+          );
         return (
           <Paragraph
             ellipsis={{
@@ -673,15 +656,29 @@ const LogsTable = () => {
         visible={showColumnSelector}
         onCancel={() => setShowColumnSelector(false)}
         footer={
-          <>
-            <Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
-            <Button onClick={() => setShowColumnSelector(false)}>
+          <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)}>
+            <Button
+              type='primary'
+              onClick={() => setShowColumnSelector(false)}
+              className='!rounded-full'
+            >
               {t('确定')}
             </Button>
-          </>
+          </div>
         }
       >
         <div style={{ marginBottom: 20 }}>
@@ -697,15 +694,8 @@ const LogsTable = () => {
           </Checkbox>
         </div>
         <div
-          style={{
-            display: 'flex',
-            flexWrap: 'wrap',
-            maxHeight: '400px',
-            overflowY: 'auto',
-            border: '1px solid var(--semi-color-border)',
-            borderRadius: '6px',
-            padding: '16px',
-          }}
+          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 admin-only columns for non-admin users
@@ -719,10 +709,7 @@ const LogsTable = () => {
             }
 
             return (
-              <div
-                key={column.key}
-                style={{ width: '50%', marginBottom: 16, paddingRight: 8 }}
-              >
+              <div key={column.key} className='w-1/2 mb-4 pr-2'>
                 <Checkbox
                   checked={!!visibleColumns[column.key]}
                   onChange={(e) =>
@@ -739,7 +726,6 @@ const LogsTable = () => {
     );
   };
 
-  const [styleState, styleDispatch] = useContext(StyleContext);
   const [logs, setLogs] = useState([]);
   const [expandData, setExpandData] = useState({});
   const [showStat, setShowStat] = useState(false);
@@ -921,27 +907,27 @@ const LogsTable = () => {
           key: t('日志详情'),
           value: other?.claude
             ? renderClaudeLogContent(
-                other?.model_ratio,
-                other.completion_ratio,
-                other.model_price,
-                other.group_ratio,
-                other.cache_ratio || 1.0,
-                other.cache_creation_ratio || 1.0,
-              )
+              other?.model_ratio,
+              other.completion_ratio,
+              other.model_price,
+              other.group_ratio,
+              other.cache_ratio || 1.0,
+              other.cache_creation_ratio || 1.0,
+            )
             : renderLogContent(
-                other?.model_ratio,
-                other.completion_ratio,
-                other.model_price,
-                other.group_ratio,
-                other?.user_group_ratio,
-                false,
-                1.0,
-                undefined,
-                other.web_search || false,
-                other.web_search_call_count || 0,
-                other.file_search || false,
-                other.file_search_call_count || 0,
-              ),
+              other?.model_ratio,
+              other.completion_ratio,
+              other.model_price,
+              other.group_ratio,
+              other?.user_group_ratio,
+              false,
+              1.0,
+              undefined,
+              other.web_search || false,
+              other.web_search_call_count || 0,
+              other.file_search || false,
+              other.file_search_call_count || 0,
+            ),
         });
       }
       if (logs[i].type === 2) {
@@ -1007,6 +993,9 @@ const LogsTable = () => {
             other?.file_search || false,
             other?.file_search_call_count || 0,
             other?.file_search_price || 0,
+            other?.audio_input_seperate_price || false,
+            other?.audio_input_token_count || 0,
+            other?.audio_input_price || 0,
           );
         }
         expandDataLocal.push({
@@ -1056,7 +1045,7 @@ const LogsTable = () => {
 
   const handlePageChange = (page) => {
     setActivePage(page);
-    loadLogs(page, pageSize, logType).then((r) => {});
+    loadLogs(page, pageSize, logType).then((r) => { });
   };
 
   const handlePageSizeChange = async (size) => {
@@ -1101,89 +1090,73 @@ const LogsTable = () => {
     return <Descriptions data={expandData[record.key]} />;
   };
 
+  // 检查是否有任何记录有展开内容
+  const hasExpandableRows = () => {
+    return logs.some(
+      (log) => expandData[log.key] && expandData[log.key].length > 0,
+    );
+  };
+
   return (
     <>
       {renderColumnSelector()}
-      <Layout>
-        <Header>
-          <Spin spinning={loadingStat}>
-            <Space>
-              <Tag
-                color='blue'
-                size='large'
-                style={{
-                  padding: 15,
-                  borderRadius: '8px',
-                  fontWeight: 500,
-                  boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                }}
-              >
-                {t('消耗额度')}: {renderQuota(stat.quota)}
-              </Tag>
-              <Tag
-                color='pink'
-                size='large'
-                style={{
-                  padding: 15,
-                  borderRadius: '8px',
-                  fontWeight: 500,
-                  boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                }}
-              >
-                RPM: {stat.rpm}
-              </Tag>
-              <Tag
-                color='white'
-                size='large'
-                style={{
-                  padding: 15,
-                  border: 'none',
-                  boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
-                  borderRadius: '8px',
-                  fontWeight: 500,
-                }}
-              >
-                TPM: {stat.tpm}
-              </Tag>
-            </Space>
-          </Spin>
-        </Header>
-        <Form layout='horizontal' style={{ marginTop: 10 }}>
-          <>
-            <Form.Section>
-              <div style={{ marginBottom: 10 }}>
-                {styleState.isMobile ? (
-                  <div>
-                    <Form.DatePicker
-                      field='start_timestamp'
-                      label={t('起始时间')}
-                      style={{ width: 272 }}
-                      initValue={start_timestamp}
-                      type='dateTime'
-                      onChange={(value) => {
-                        console.log(value);
-                        handleInputChange(value, 'start_timestamp');
-                      }}
-                    />
-                    <Form.DatePicker
-                      field='end_timestamp'
-                      fluid
-                      label={t('结束时间')}
-                      style={{ width: 272 }}
-                      initValue={end_timestamp}
-                      type='dateTime'
-                      onChange={(value) =>
-                        handleInputChange(value, 'end_timestamp')
-                      }
-                    />
-                  </div>
-                ) : (
-                  <Form.DatePicker
-                    field='range_timestamp'
-                    label={t('时间范围')}
-                    initValue={[start_timestamp, end_timestamp]}
+      <Card
+        className='!rounded-2xl mb-4'
+        title={
+          <div className='flex flex-col w-full'>
+            <Spin spinning={loadingStat}>
+              <Space>
+                <Tag
+                  color='blue'
+                  size='large'
+                  style={{
+                    padding: 15,
+                    borderRadius: '9999px',
+                    fontWeight: 500,
+                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                  }}
+                >
+                  {t('消耗额度')}: {renderQuota(stat.quota)}
+                </Tag>
+                <Tag
+                  color='pink'
+                  size='large'
+                  style={{
+                    padding: 15,
+                    borderRadius: '9999px',
+                    fontWeight: 500,
+                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                  }}
+                >
+                  RPM: {stat.rpm}
+                </Tag>
+                <Tag
+                  color='white'
+                  size='large'
+                  style={{
+                    padding: 15,
+                    border: 'none',
+                    boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
+                    borderRadius: '9999px',
+                    fontWeight: 500,
+                  }}
+                >
+                  TPM: {stat.tpm}
+                </Tag>
+              </Space>
+            </Spin>
+
+            <Divider margin='12px' />
+
+            {/* 搜索表单区域 */}
+            <div className='flex flex-col gap-4'>
+              <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'>
+                  <DatePicker
+                    className='w-full'
+                    value={[start_timestamp, end_timestamp]}
                     type='dateTimeRange'
-                    name='range_timestamp'
                     onChange={(value) => {
                       if (Array.isArray(value) && value.length === 2) {
                         handleInputChange(value[0], 'start_timestamp');
@@ -1191,100 +1164,118 @@ const LogsTable = () => {
                       }
                     }}
                   />
-                )}
-              </div>
-            </Form.Section>
-            <Form.Input
-              field='token_name'
-              label={t('令牌名称')}
-              value={token_name}
-              placeholder={t('可选值')}
-              name='token_name'
-              onChange={(value) => handleInputChange(value, 'token_name')}
-            />
-            <Form.Input
-              field='model_name'
-              label={t('模型名称')}
-              value={model_name}
-              placeholder={t('可选值')}
-              name='model_name'
-              onChange={(value) => handleInputChange(value, 'model_name')}
-            />
-            <Form.Input
-              field='group'
-              label={t('分组')}
-              value={group}
-              placeholder={t('可选值')}
-              name='group'
-              onChange={(value) => handleInputChange(value, 'group')}
-            />
-            {isAdminUser && (
-              <>
-                <Form.Input
-                  field='channel'
-                  label={t('渠道 ID')}
-                  value={channel}
-                  placeholder={t('可选值')}
-                  name='channel'
-                  onChange={(value) => handleInputChange(value, 'channel')}
+                </div>
+
+                {/* 日志类型选择器 */}
+                <Select
+                  value={logType.toString()}
+                  placeholder={t('日志类型')}
+                  className='!rounded-full'
+                  onChange={(value) => {
+                    setLogType(parseInt(value));
+                    loadLogs(0, pageSize, parseInt(value));
+                  }}
+                >
+                  <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
                 />
-                <Form.Input
-                  field='username'
-                  label={t('用户名称')}
-                  value={username}
-                  placeholder={t('可选值')}
-                  name='username'
-                  onChange={(value) => handleInputChange(value, 'username')}
+
+                <Input
+                  prefix={<IconSearch />}
+                  placeholder={t('模型名称')}
+                  value={model_name}
+                  onChange={(value) => handleInputChange(value, 'model_name')}
+                  className='!rounded-full'
+                  showClear
                 />
-              </>
-            )}
-            <Button
-              label={t('查询')}
-              type='primary'
-              htmlType='submit'
-              className='btn-margin-right'
-              onClick={refresh}
-              loading={loading}
-              style={{ marginTop: 24 }}
-            >
-              {t('查询')}
-            </Button>
-            <Form.Section></Form.Section>
-          </>
-        </Form>
-        <div style={{ marginTop: 10 }}>
-          <Select
-            defaultValue='0'
-            style={{ width: 120 }}
-            onChange={(value) => {
-              setLogType(parseInt(value));
-              loadLogs(0, pageSize, parseInt(value));
-            }}
-          >
-            <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>
-          <Button
-            theme='light'
-            type='tertiary'
-            icon={<IconSetting />}
-            onClick={() => setShowColumnSelector(true)}
-            style={{ marginLeft: 8 }}
-          >
-            {t('列设置')}
-          </Button>
-        </div>
+
+                <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
+                    />
+                    <Input
+                      prefix={<IconSearch />}
+                      placeholder={t('用户名称')}
+                      value={username}
+                      onChange={(value) => handleInputChange(value, 'username')}
+                      className='!rounded-full'
+                      showClear
+                    />
+                  </>
+                )}
+              </div>
+
+              {/* 操作按钮区域 */}
+              <div className='flex justify-between items-center pt-2'>
+                <div></div>
+                <div className='flex gap-2'>
+                  <Button
+                    type='primary'
+                    onClick={refresh}
+                    loading={loading}
+                    className='!rounded-full'
+                  >
+                    {t('查询')}
+                  </Button>
+                  <Button
+                    theme='light'
+                    type='tertiary'
+                    icon={<IconSetting />}
+                    onClick={() => setShowColumnSelector(true)}
+                    className='!rounded-full'
+                  >
+                    {t('列设置')}
+                  </Button>
+                </div>
+              </div>
+            </div>
+          </div>
+        }
+        shadows='always'
+        bordered={false}
+      >
         <Table
-          style={{ marginTop: 5 }}
           columns={getVisibleColumns()}
-          expandedRowRender={expandRowRender}
-          expandRowByClick={true}
+          {...(hasExpandableRows() && {
+            expandedRowRender: expandRowRender,
+            expandRowByClick: true,
+            rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
+          })}
           dataSource={logs}
           rowKey='key'
+          loading={loading}
+          scroll={{ x: 'max-content' }}
+          className='rounded-xl overflow-hidden'
+          size='middle'
           pagination={{
             formatPageText: (page) =>
               t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
@@ -1295,7 +1286,7 @@ const LogsTable = () => {
             currentPage: activePage,
             pageSize: pageSize,
             total: logCount,
-            pageSizeOpts: [10, 20, 50, 100],
+            pageSizeOptions: [10, 20, 50, 100],
             showSizeChanger: true,
             onPageSizeChange: (size) => {
               handlePageSizeChange(size);
@@ -1303,7 +1294,7 @@ const LogsTable = () => {
             onPageChange: handlePageChange,
           }}
         />
-      </Layout>
+      </Card>
     </>
   );
 };

+ 908 - 0
web/src/components/table/MjLogsTable.js

@@ -0,0 +1,908 @@
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  API,
+  copy,
+  isAdmin,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../../helpers';
+
+import {
+  Button,
+  Card,
+  Checkbox,
+  DatePicker,
+  Divider,
+  ImagePreview,
+  Input,
+  Layout,
+  Modal,
+  Progress,
+  Skeleton,
+  Table,
+  Tag,
+  Typography,
+} from '@douyinfe/semi-ui';
+import { ITEMS_PER_PAGE } from '../../constants';
+import {
+  IconEyeOpened,
+  IconSearch,
+  IconSetting,
+} from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
+
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
+];
+
+// 定义列键值常量
+const COLUMN_KEYS = {
+  SUBMIT_TIME: 'submit_time',
+  DURATION: 'duration',
+  CHANNEL: 'channel',
+  TYPE: 'type',
+  TASK_ID: 'task_id',
+  SUBMIT_RESULT: 'submit_result',
+  TASK_STATUS: 'task_status',
+  PROGRESS: 'progress',
+  IMAGE: 'image',
+  PROMPT: 'prompt',
+  PROMPT_EN: 'prompt_en',
+  FAIL_REASON: 'fail_reason',
+};
+
+const LogsTable = () => {
+  const { t } = useTranslation();
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [modalContent, setModalContent] = useState('');
+
+  // 列可见性状态
+  const [visibleColumns, setVisibleColumns] = useState({});
+  const [showColumnSelector, setShowColumnSelector] = useState(false);
+  const isAdminUser = isAdmin();
+
+  // 加载保存的列偏好设置
+  useEffect(() => {
+    const savedColumns = localStorage.getItem('mj-logs-table-columns');
+    if (savedColumns) {
+      try {
+        const parsed = JSON.parse(savedColumns);
+        const defaults = getDefaultColumnVisibility();
+        const merged = { ...defaults, ...parsed };
+        setVisibleColumns(merged);
+      } catch (e) {
+        console.error('Failed to parse saved column preferences', e);
+        initDefaultColumns();
+      }
+    } else {
+      initDefaultColumns();
+    }
+  }, []);
+
+  // 获取默认列可见性
+  const getDefaultColumnVisibility = () => {
+    return {
+      [COLUMN_KEYS.SUBMIT_TIME]: true,
+      [COLUMN_KEYS.DURATION]: true,
+      [COLUMN_KEYS.CHANNEL]: isAdminUser,
+      [COLUMN_KEYS.TYPE]: true,
+      [COLUMN_KEYS.TASK_ID]: true,
+      [COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
+      [COLUMN_KEYS.TASK_STATUS]: true,
+      [COLUMN_KEYS.PROGRESS]: true,
+      [COLUMN_KEYS.IMAGE]: true,
+      [COLUMN_KEYS.PROMPT]: true,
+      [COLUMN_KEYS.PROMPT_EN]: true,
+      [COLUMN_KEYS.FAIL_REASON]: true,
+    };
+  };
+
+  // 初始化默认列可见性
+  const initDefaultColumns = () => {
+    const defaults = getDefaultColumnVisibility();
+    setVisibleColumns(defaults);
+    localStorage.setItem('mj-logs-table-columns', JSON.stringify(defaults));
+  };
+
+  // 处理列可见性变化
+  const handleColumnVisibilityChange = (columnKey, checked) => {
+    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
+    setVisibleColumns(updatedColumns);
+  };
+
+  // 处理全选
+  const handleSelectAll = (checked) => {
+    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
+    const updatedColumns = {};
+
+    allKeys.forEach((key) => {
+      if ((key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) && !isAdminUser) {
+        updatedColumns[key] = false;
+      } else {
+        updatedColumns[key] = checked;
+      }
+    });
+
+    setVisibleColumns(updatedColumns);
+  };
+
+  // 更新表格时保存列可见性
+  useEffect(() => {
+    if (Object.keys(visibleColumns).length > 0) {
+      localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns));
+    }
+  }, [visibleColumns]);
+
+  function renderType(type) {
+    switch (type) {
+      case 'IMAGINE':
+        return (
+          <Tag color='blue' size='large' shape='circle'>
+            {t('绘图')}
+          </Tag>
+        );
+      case 'UPSCALE':
+        return (
+          <Tag color='orange' size='large' shape='circle'>
+            {t('放大')}
+          </Tag>
+        );
+      case 'VARIATION':
+        return (
+          <Tag color='purple' size='large' shape='circle'>
+            {t('变换')}
+          </Tag>
+        );
+      case 'HIGH_VARIATION':
+        return (
+          <Tag color='purple' size='large' shape='circle'>
+            {t('强变换')}
+          </Tag>
+        );
+      case 'LOW_VARIATION':
+        return (
+          <Tag color='purple' size='large' shape='circle'>
+            {t('弱变换')}
+          </Tag>
+        );
+      case 'PAN':
+        return (
+          <Tag color='cyan' size='large' shape='circle'>
+            {t('平移')}
+          </Tag>
+        );
+      case 'DESCRIBE':
+        return (
+          <Tag color='yellow' size='large' shape='circle'>
+            {t('图生文')}
+          </Tag>
+        );
+      case 'BLEND':
+        return (
+          <Tag color='lime' size='large' shape='circle'>
+            {t('图混合')}
+          </Tag>
+        );
+      case 'UPLOAD':
+        return (
+          <Tag color='blue' size='large' shape='circle'>
+            上传文件
+          </Tag>
+        );
+      case 'SHORTEN':
+        return (
+          <Tag color='pink' size='large' shape='circle'>
+            {t('缩词')}
+          </Tag>
+        );
+      case 'REROLL':
+        return (
+          <Tag color='indigo' size='large' shape='circle'>
+            {t('重绘')}
+          </Tag>
+        );
+      case 'INPAINT':
+        return (
+          <Tag color='violet' size='large' shape='circle'>
+            {t('局部重绘-提交')}
+          </Tag>
+        );
+      case 'ZOOM':
+        return (
+          <Tag color='teal' size='large' shape='circle'>
+            {t('变焦')}
+          </Tag>
+        );
+      case 'CUSTOM_ZOOM':
+        return (
+          <Tag color='teal' size='large' shape='circle'>
+            {t('自定义变焦-提交')}
+          </Tag>
+        );
+      case 'MODAL':
+        return (
+          <Tag color='green' size='large' shape='circle'>
+            {t('窗口处理')}
+          </Tag>
+        );
+      case 'SWAP_FACE':
+        return (
+          <Tag color='light-green' size='large' shape='circle'>
+            {t('换脸')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+    }
+  }
+
+  function renderCode(code) {
+    switch (code) {
+      case 1:
+        return (
+          <Tag color='green' size='large' shape='circle'>
+            {t('已提交')}
+          </Tag>
+        );
+      case 21:
+        return (
+          <Tag color='lime' size='large' shape='circle'>
+            {t('等待中')}
+          </Tag>
+        );
+      case 22:
+        return (
+          <Tag color='orange' size='large' shape='circle'>
+            {t('重复提交')}
+          </Tag>
+        );
+      case 0:
+        return (
+          <Tag color='yellow' size='large' shape='circle'>
+            {t('未提交')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+    }
+  }
+
+  function renderStatus(type) {
+    switch (type) {
+      case 'SUCCESS':
+        return (
+          <Tag color='green' size='large' shape='circle'>
+            {t('成功')}
+          </Tag>
+        );
+      case 'NOT_START':
+        return (
+          <Tag color='grey' size='large' shape='circle'>
+            {t('未启动')}
+          </Tag>
+        );
+      case 'SUBMITTED':
+        return (
+          <Tag color='yellow' size='large' shape='circle'>
+            {t('队列中')}
+          </Tag>
+        );
+      case 'IN_PROGRESS':
+        return (
+          <Tag color='blue' size='large' shape='circle'>
+            {t('执行中')}
+          </Tag>
+        );
+      case 'FAILURE':
+        return (
+          <Tag color='red' size='large' shape='circle'>
+            {t('失败')}
+          </Tag>
+        );
+      case 'MODAL':
+        return (
+          <Tag color='yellow' size='large' shape='circle'>
+            {t('窗口等待')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+    }
+  }
+
+  const renderTimestamp = (timestampInSeconds) => {
+    const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
+
+    const year = date.getFullYear(); // 获取年份
+    const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
+    const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
+    const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
+    const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
+    const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
+
+    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
+  };
+  // 修改renderDuration函数以包含颜色逻辑
+  function renderDuration(submit_time, finishTime) {
+    if (!submit_time || !finishTime) return 'N/A';
+
+    const start = new Date(submit_time);
+    const finish = new Date(finishTime);
+    const durationMs = finish - start;
+    const durationSec = (durationMs / 1000).toFixed(1);
+    const color = durationSec > 60 ? 'red' : 'green';
+
+    return (
+      <Tag color={color} size='large' shape='circle'>
+        {durationSec} {t('秒')}
+      </Tag>
+    );
+  }
+
+  // 定义所有列
+  const allColumns = [
+    {
+      key: COLUMN_KEYS.SUBMIT_TIME,
+      title: t('提交时间'),
+      dataIndex: 'submit_time',
+      render: (text, record, index) => {
+        return <div>{renderTimestamp(text / 1000)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.DURATION,
+      title: t('花费时间'),
+      dataIndex: 'finish_time',
+      render: (finish, record) => {
+        return renderDuration(record.submit_time, finish);
+      },
+    },
+    {
+      key: COLUMN_KEYS.CHANNEL,
+      title: t('渠道'),
+      dataIndex: 'channel_id',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return isAdminUser ? (
+          <div>
+            <Tag
+              color={colors[parseInt(text) % colors.length]}
+              size='large'
+              shape='circle'
+              onClick={() => {
+                copyText(text);
+              }}
+            >
+              {' '}
+              {text}{' '}
+            </Tag>
+          </div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.TYPE,
+      title: t('类型'),
+      dataIndex: 'action',
+      render: (text, record, index) => {
+        return <div>{renderType(text)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.TASK_ID,
+      title: t('任务ID'),
+      dataIndex: 'mj_id',
+      render: (text, record, index) => {
+        return <div>{text}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.SUBMIT_RESULT,
+      title: t('提交结果'),
+      dataIndex: 'code',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return isAdminUser ? <div>{renderCode(text)}</div> : <></>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.TASK_STATUS,
+      title: t('任务状态'),
+      dataIndex: 'status',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return <div>{renderStatus(text)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROGRESS,
+      title: t('进度'),
+      dataIndex: 'progress',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {
+              <Progress
+                stroke={
+                  record.status === 'FAILURE'
+                    ? 'var(--semi-color-warning)'
+                    : null
+                }
+                percent={text ? parseInt(text.replace('%', '')) : 0}
+                showInfo={true}
+                aria-label='drawing progress'
+                style={{ minWidth: '200px' }}
+              />
+            }
+          </div>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.IMAGE,
+      title: t('结果图片'),
+      dataIndex: 'image_url',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+        return (
+          <Button
+            onClick={() => {
+              setModalImageUrl(text);
+              setIsModalOpenurl(true);
+            }}
+          >
+            {t('查看图片')}
+          </Button>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROMPT,
+      title: 'Prompt',
+      dataIndex: 'prompt',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROMPT_EN,
+      title: 'PromptEn',
+      dataIndex: 'prompt_en',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.FAIL_REASON,
+      title: t('失败原因'),
+      dataIndex: 'fail_reason',
+      fixed: 'right',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+  ];
+
+  // 根据可见性设置过滤列
+  const getVisibleColumns = () => {
+    return allColumns.filter((column) => visibleColumns[column.key]);
+  };
+
+  const [logs, setLogs] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
+  const [logType, setLogType] = useState(0);
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
+  const [showBanner, setShowBanner] = useState(false);
+
+  // 定义模态框图片URL的状态和更新函数
+  const [modalImageUrl, setModalImageUrl] = useState('');
+  let now = new Date();
+  // 初始化start_timestamp为前一天
+  const [inputs, setInputs] = useState({
+    channel_id: '',
+    mj_id: '',
+    start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
+    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
+  });
+  const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
+
+  const [stat, setStat] = useState({
+    quota: 0,
+    token: 0,
+  });
+
+  const handleInputChange = (value, name) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
+
+  const setLogsFormat = (logs) => {
+    for (let i = 0; i < logs.length; i++) {
+      logs[i].timestamp2string = timestamp2string(logs[i].created_at);
+      logs[i].key = '' + logs[i].id;
+    }
+    // data.key = '' + data.id
+    setLogs(logs);
+    setLogCount(logs.length + pageSize);
+    // console.log(logCount);
+  };
+
+  const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
+    setLoading(true);
+
+    let url = '';
+    let localStartTimestamp = Date.parse(start_timestamp);
+    let localEndTimestamp = Date.parse(end_timestamp);
+    if (isAdminUser) {
+      url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    } else {
+      url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    }
+    const res = await API.get(url);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setLogsFormat(data);
+      } else {
+        let newLogs = [...logs];
+        newLogs.splice(startIdx * pageSize, data.length, ...data);
+        setLogsFormat(newLogs);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const pageData = logs.slice(
+    (activePage - 1) * pageSize,
+    activePage * pageSize,
+  );
+
+  const handlePageChange = (page) => {
+    setActivePage(page);
+    if (page === Math.ceil(logs.length / pageSize) + 1) {
+      // In this case we have to load more data and then append them.
+      loadLogs(page - 1, pageSize).then((r) => { });
+    }
+  };
+
+  const handlePageSizeChange = async (size) => {
+    localStorage.setItem('mj-page-size', size + '');
+    setPageSize(size);
+    setActivePage(1);
+    await loadLogs(0, size);
+  };
+
+  const refresh = async () => {
+    // setLoading(true);
+    setActivePage(1);
+    await loadLogs(0, pageSize);
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess(t('已复制:') + text);
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
+    }
+  };
+
+  useEffect(() => {
+    const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
+    setPageSize(localPageSize);
+    loadLogs(0, localPageSize).then();
+  }, [logType]);
+
+  useEffect(() => {
+    const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
+    if (mjNotifyEnabled !== 'true') {
+      setShowBanner(true);
+    }
+  }, []);
+
+  // 列选择器模态框
+  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>
+        }
+      >
+        <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) => {
+            // 为非管理员用户跳过管理员专用列
+            if (
+              !isAdminUser &&
+              (column.key === COLUMN_KEYS.CHANNEL ||
+                column.key === COLUMN_KEYS.SUBMIT_RESULT)
+            ) {
+              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>
+    );
+  };
+
+  return (
+    <>
+      {renderColumnSelector()}
+      <Layout>
+        <Card
+          className="!rounded-2xl mb-4"
+          title={
+            <div className="flex flex-col w-full">
+              <div className="flex flex-col md:flex-row justify-between items-center">
+                <div className="flex items-center text-orange-500 mb-2 md:mb-0">
+                  <IconEyeOpened className="mr-2" />
+                  {loading ? (
+                    <Skeleton.Title
+                      style={{
+                        width: 300,
+                        marginBottom: 0,
+                        marginTop: 0
+                      }}
+                    />
+                  ) : (
+                    <Text>
+                      {isAdminUser && showBanner
+                        ? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
+                        : t('Midjourney 任务记录')}
+                    </Text>
+                  )}
+                </div>
+              </div>
+
+              <Divider margin="12px" />
+
+              {/* 搜索表单区域 */}
+              <div className="flex flex-col gap-4">
+                <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">
+                    <DatePicker
+                      className="w-full"
+                      value={[start_timestamp, end_timestamp]}
+                      type='dateTimeRange'
+                      onChange={(value) => {
+                        if (Array.isArray(value) && value.length === 2) {
+                          handleInputChange(value[0], 'start_timestamp');
+                          handleInputChange(value[1], 'end_timestamp');
+                        }
+                      }}
+                    />
+                  </div>
+
+                  {/* 任务 ID */}
+                  <Input
+                    prefix={<IconSearch />}
+                    placeholder={t('任务 ID')}
+                    value={mj_id}
+                    onChange={(value) => handleInputChange(value, 'mj_id')}
+                    className="!rounded-full"
+                    showClear
+                  />
+
+                  {/* 渠道 ID - 仅管理员可见 */}
+                  {isAdminUser && (
+                    <Input
+                      prefix={<IconSearch />}
+                      placeholder={t('渠道 ID')}
+                      value={channel_id}
+                      onChange={(value) => handleInputChange(value, 'channel_id')}
+                      className="!rounded-full"
+                      showClear
+                    />
+                  )}
+                </div>
+
+                {/* 操作按钮区域 */}
+                <div className="flex justify-between items-center pt-2">
+                  <div></div>
+                  <div className="flex gap-2">
+                    <Button
+                      type='primary'
+                      onClick={refresh}
+                      loading={loading}
+                      className="!rounded-full"
+                    >
+                      {t('查询')}
+                    </Button>
+                    <Button
+                      theme='light'
+                      type='tertiary'
+                      icon={<IconSetting />}
+                      onClick={() => setShowColumnSelector(true)}
+                      className="!rounded-full"
+                    >
+                      {t('列设置')}
+                    </Button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          }
+          shadows='always'
+          bordered={false}
+        >
+          <Table
+            columns={getVisibleColumns()}
+            dataSource={pageData}
+            rowKey='key'
+            loading={loading}
+            scroll={{ x: 'max-content' }}
+            className="rounded-xl overflow-hidden"
+            size="middle"
+            pagination={{
+              formatPageText: (page) =>
+                t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+                  start: page.currentStart,
+                  end: page.currentEnd,
+                  total: logCount,
+                }),
+              currentPage: activePage,
+              pageSize: pageSize,
+              total: logCount,
+              pageSizeOptions: [10, 20, 50, 100],
+              showSizeChanger: true,
+              onPageSizeChange: (size) => {
+                handlePageSizeChange(size);
+              },
+              onPageChange: handlePageChange,
+            }}
+          />
+        </Card>
+
+        <Modal
+          visible={isModalOpen}
+          onOk={() => setIsModalOpen(false)}
+          onCancel={() => setIsModalOpen(false)}
+          closable={null}
+          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
+          width={800} // 设置模态框宽度
+        >
+          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
+        </Modal>
+        <ImagePreview
+          src={modalImageUrl}
+          visible={isModalOpenurl}
+          onVisibleChange={(visible) => setIsModalOpenurl(visible)}
+        />
+      </Layout>
+    </>
+  );
+};
+
+export default LogsTable;

+ 637 - 0
web/src/components/table/ModelPricing.js

@@ -0,0 +1,637 @@
+import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
+import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Input,
+  Layout,
+  Modal,
+  Space,
+  Table,
+  Tag,
+  Tooltip,
+  Popover,
+  ImagePreview,
+  Button,
+  Card,
+  Tabs,
+  TabPane,
+  Dropdown,
+} from '@douyinfe/semi-ui';
+import {
+  IconVerify,
+  IconHelpCircle,
+  IconSearch,
+  IconCopy,
+  IconInfoCircle,
+  IconLayers,
+} from '@douyinfe/semi-icons';
+import { UserContext } from '../../context/User/index.js';
+import { AlertCircle } from 'lucide-react';
+
+const ModelPricing = () => {
+  const { t } = useTranslation();
+  const [filteredValue, setFilteredValue] = useState([]);
+  const compositionRef = useRef({ isComposition: false });
+  const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+  const [modalImageUrl, setModalImageUrl] = useState('');
+  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
+  const [selectedGroup, setSelectedGroup] = useState('default');
+  const [activeKey, setActiveKey] = useState('all');
+  const [pageSize, setPageSize] = useState(10);
+
+  const rowSelection = useMemo(
+    () => ({
+      onChange: (selectedRowKeys, selectedRows) => {
+        setSelectedRowKeys(selectedRowKeys);
+      },
+    }),
+    [],
+  );
+
+  const handleChange = (value) => {
+    if (compositionRef.current.isComposition) {
+      return;
+    }
+    const newFilteredValue = value ? [value] : [];
+    setFilteredValue(newFilteredValue);
+  };
+
+  const handleCompositionStart = () => {
+    compositionRef.current.isComposition = true;
+  };
+
+  const handleCompositionEnd = (event) => {
+    compositionRef.current.isComposition = false;
+    const value = event.target.value;
+    const newFilteredValue = value ? [value] : [];
+    setFilteredValue(newFilteredValue);
+  };
+
+  function renderQuotaType(type) {
+    switch (type) {
+      case 1:
+        return (
+          <Tag color='teal' size='large' shape='circle'>
+            {t('按次计费')}
+          </Tag>
+        );
+      case 0:
+        return (
+          <Tag color='violet' size='large' shape='circle'>
+            {t('按量计费')}
+          </Tag>
+        );
+      default:
+        return t('未知');
+    }
+  }
+
+  function renderAvailable(available) {
+    return available ? (
+      <Popover
+        content={
+          <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
+        }
+        position='top'
+        key={available}
+        className="bg-green-50"
+      >
+        <IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
+      </Popover>
+    ) : null;
+  }
+
+  const columns = [
+    {
+      title: t('可用性'),
+      dataIndex: 'available',
+      render: (text, record, index) => {
+        return renderAvailable(record.enable_groups.includes(selectedGroup));
+      },
+      sorter: (a, b) => {
+        const aAvailable = a.enable_groups.includes(selectedGroup);
+        const bAvailable = b.enable_groups.includes(selectedGroup);
+        return Number(aAvailable) - Number(bAvailable);
+      },
+      defaultSortOrder: 'descend',
+    },
+    {
+      title: t('模型名称'),
+      dataIndex: 'model_name',
+      render: (text, record, index) => {
+        return renderModelTag(text, {
+          onClick: () => {
+            copyText(text);
+          }
+        });
+      },
+      onFilter: (value, record) =>
+        record.model_name.toLowerCase().includes(value.toLowerCase()),
+      filteredValue,
+    },
+    {
+      title: t('计费类型'),
+      dataIndex: 'quota_type',
+      render: (text, record, index) => {
+        return renderQuotaType(parseInt(text));
+      },
+      sorter: (a, b) => a.quota_type - b.quota_type,
+    },
+    {
+      title: t('可用分组'),
+      dataIndex: 'enable_groups',
+      render: (text, record, index) => {
+        return (
+          <Space wrap>
+            {text.map((group) => {
+              if (usableGroup[group]) {
+                if (group === selectedGroup) {
+                  return (
+                    <Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
+                      {group}
+                    </Tag>
+                  );
+                } else {
+                  return (
+                    <Tag
+                      color='blue'
+                      size='large'
+                      onClick={() => {
+                        setSelectedGroup(group);
+                        showInfo(
+                          t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
+                            group: group,
+                            ratio: groupRatio[group],
+                          }),
+                        );
+                      }}
+                      className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
+                    >
+                      {group}
+                    </Tag>
+                  );
+                }
+              }
+            })}
+          </Space>
+        );
+      },
+    },
+    {
+      title: () => (
+        <div className="flex items-center space-x-1">
+          <span>{t('倍率')}</span>
+          <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
+            <IconHelpCircle
+              className="text-blue-500 cursor-pointer"
+              onClick={() => {
+                setModalImageUrl('/ratio.png');
+                setIsModalOpenurl(true);
+              }}
+            />
+          </Tooltip>
+        </div>
+      ),
+      dataIndex: 'model_ratio',
+      render: (text, record, index) => {
+        let content = text;
+        let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
+        content = (
+          <div className="space-y-1">
+            <div className="text-gray-700">
+              {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
+            </div>
+            <div className="text-gray-700">
+              {t('补全倍率')}:
+              {record.quota_type === 0 ? completionRatio : t('无')}
+            </div>
+            <div className="text-gray-700">
+              {t('分组倍率')}:{groupRatio[selectedGroup]}
+            </div>
+          </div>
+        );
+        return content;
+      },
+    },
+    {
+      title: t('模型价格'),
+      dataIndex: 'model_price',
+      render: (text, record, index) => {
+        let content = text;
+        if (record.quota_type === 0) {
+          let inputRatioPrice =
+            record.model_ratio * 2 * groupRatio[selectedGroup];
+          let completionRatioPrice =
+            record.model_ratio *
+            record.completion_ratio *
+            2 *
+            groupRatio[selectedGroup];
+          content = (
+            <div className="space-y-1">
+              <div className="text-gray-700">
+                {t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
+              </div>
+              <div className="text-gray-700">
+                {t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
+              </div>
+            </div>
+          );
+        } else {
+          let price = parseFloat(text) * groupRatio[selectedGroup];
+          content = (
+            <div className="text-gray-700">
+              {t('模型价格')}:${price.toFixed(3)}
+            </div>
+          );
+        }
+        return content;
+      },
+    },
+  ];
+
+  const [models, setModels] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [userState, userDispatch] = useContext(UserContext);
+  const [groupRatio, setGroupRatio] = useState({});
+  const [usableGroup, setUsableGroup] = useState({});
+
+  const setModelsFormat = (models, groupRatio) => {
+    for (let i = 0; i < models.length; i++) {
+      models[i].key = models[i].model_name;
+      models[i].group_ratio = groupRatio[models[i].model_name];
+    }
+    models.sort((a, b) => {
+      return a.quota_type - b.quota_type;
+    });
+
+    models.sort((a, b) => {
+      if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
+        return -1;
+      } else if (
+        !a.model_name.startsWith('gpt') &&
+        b.model_name.startsWith('gpt')
+      ) {
+        return 1;
+      } else {
+        return a.model_name.localeCompare(b.model_name);
+      }
+    });
+
+    setModels(models);
+  };
+
+  const loadPricing = async () => {
+    setLoading(true);
+    let url = '/api/pricing';
+    const res = await API.get(url);
+    const { success, message, data, group_ratio, usable_group } = res.data;
+    if (success) {
+      setGroupRatio(group_ratio);
+      setUsableGroup(usable_group);
+      setSelectedGroup(userState.user ? userState.user.group : 'default');
+      setModelsFormat(data, group_ratio);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const refresh = async () => {
+    await loadPricing();
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess(t('已复制:') + text);
+    } else {
+      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
+    }
+  };
+
+  useEffect(() => {
+    refresh().then();
+  }, []);
+
+  const modelCategories = getModelCategories(t);
+
+  const categoryCounts = useMemo(() => {
+    const counts = {};
+    if (models.length > 0) {
+      counts['all'] = models.length;
+
+      Object.entries(modelCategories).forEach(([key, category]) => {
+        if (key !== 'all') {
+          counts[key] = models.filter(model => category.filter(model)).length;
+        }
+      });
+    }
+    return counts;
+  }, [models, modelCategories]);
+
+  const renderArrow = (items, pos, handleArrowClick) => {
+    const style = {
+      width: 32,
+      height: 32,
+      margin: '0 12px',
+      display: 'flex',
+      justifyContent: 'center',
+      alignItems: 'center',
+      borderRadius: '100%',
+      background: 'rgba(var(--semi-grey-1), 1)',
+      color: 'var(--semi-color-text)',
+      cursor: 'pointer',
+    };
+    return (
+      <Dropdown
+        render={
+          <Dropdown.Menu>
+            {items.map(item => {
+              const key = item.itemKey;
+              const modelCount = categoryCounts[key] || 0;
+
+              return (
+                <Dropdown.Item
+                  key={item.itemKey}
+                  onClick={() => setActiveKey(item.itemKey)}
+                  icon={modelCategories[item.itemKey]?.icon}
+                >
+                  <div className="flex items-center gap-2">
+                    {modelCategories[item.itemKey]?.label || item.itemKey}
+                    <Tag
+                      color={activeKey === item.itemKey ? 'red' : 'grey'}
+                      size='small'
+                      shape='circle'
+                    >
+                      {modelCount}
+                    </Tag>
+                  </div>
+                </Dropdown.Item>
+              );
+            })}
+          </Dropdown.Menu>
+        }
+      >
+        <div style={style} onClick={handleArrowClick}>
+          {pos === 'start' ? '←' : '→'}
+        </div>
+      </Dropdown>
+    );
+  };
+
+  // 检查分类是否有对应的模型
+  const availableCategories = useMemo(() => {
+    if (!models.length) return ['all'];
+
+    return Object.entries(modelCategories).filter(([key, category]) => {
+      if (key === 'all') return true;
+      return models.some(model => category.filter(model));
+    }).map(([key]) => key);
+  }, [models]);
+
+  // 渲染标签页
+  const renderTabs = () => {
+    return (
+      <Tabs
+        renderArrow={renderArrow}
+        activeKey={activeKey}
+        type="card"
+        collapsible
+        onChange={key => setActiveKey(key)}
+        className="mt-2"
+      >
+        {Object.entries(modelCategories)
+          .filter(([key]) => availableCategories.includes(key))
+          .map(([key, category]) => {
+            const modelCount = categoryCounts[key] || 0;
+
+            return (
+              <TabPane
+                tab={
+                  <span className="flex items-center gap-2">
+                    {category.icon && <span className="w-4 h-4">{category.icon}</span>}
+                    {category.label}
+                    <Tag
+                      color={activeKey === key ? 'red' : 'grey'}
+                      size='small'
+                      shape='circle'
+                    >
+                      {modelCount}
+                    </Tag>
+                  </span>
+                }
+                itemKey={key}
+                key={key}
+              />
+            );
+          })}
+      </Tabs>
+    );
+  };
+
+  // 优化过滤逻辑
+  const filteredModels = useMemo(() => {
+    let result = models;
+
+    // 先按分类过滤
+    if (activeKey !== 'all') {
+      result = result.filter(model => modelCategories[activeKey].filter(model));
+    }
+
+    // 再按搜索词过滤
+    if (filteredValue.length > 0) {
+      const searchTerm = filteredValue[0].toLowerCase();
+      result = result.filter(model =>
+        model.model_name.toLowerCase().includes(searchTerm)
+      );
+    }
+
+    return result;
+  }, [activeKey, models, filteredValue]);
+
+  // 搜索和操作区组件
+  const SearchAndActions = useMemo(() => (
+    <Card className="!rounded-xl mb-6" bordered={false}>
+      <div className="flex flex-wrap items-center gap-4">
+        <div className="flex-1 min-w-[200px]">
+          <Input
+            prefix={<IconSearch />}
+            placeholder={t('模糊搜索模型名称')}
+            className="!rounded-lg"
+            onCompositionStart={handleCompositionStart}
+            onCompositionEnd={handleCompositionEnd}
+            onChange={handleChange}
+            showClear
+            size="large"
+          />
+        </div>
+        <Button
+          theme='light'
+          type='primary'
+          icon={<IconCopy />}
+          onClick={() => copyText(selectedRowKeys)}
+          disabled={selectedRowKeys.length === 0}
+          className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
+          size="large"
+        >
+          {t('复制选中模型')}
+        </Button>
+      </div>
+    </Card>
+  ), [selectedRowKeys, t]);
+
+  // 表格组件
+  const ModelTable = useMemo(() => (
+    <Card className="!rounded-xl overflow-hidden" bordered={false}>
+      <Table
+        columns={columns}
+        dataSource={filteredModels}
+        loading={loading}
+        rowSelection={rowSelection}
+        className="custom-table"
+        pagination={{
+          defaultPageSize: 10,
+          pageSize: pageSize,
+          showSizeChanger: true,
+          pageSizeOptions: [10, 20, 50, 100],
+          formatPageText: (page) =>
+            t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+              start: page.currentStart,
+              end: page.currentEnd,
+              total: filteredModels.length,
+            }),
+          onPageSizeChange: (size) => setPageSize(size),
+        }}
+      />
+    </Card>
+  ), [filteredModels, loading, columns, rowSelection, pageSize, t]);
+
+  return (
+    <div className="bg-gray-50">
+      <Layout>
+        <Layout.Content>
+          <div className="flex justify-center p-4 sm:p-6 md:p-8">
+            <div className="w-full">
+              {/* 主卡片容器 */}
+              <Card className="!rounded-2xl shadow-lg border-0">
+                {/* 顶部状态卡片 */}
+                <Card
+                  className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
+                  style={{
+                    background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
+                    position: 'relative'
+                  }}
+                  bodyStyle={{ padding: 0 }}
+                >
+                  {/* 装饰性背景元素 */}
+                  <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 -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 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>
+
+                  <div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
+                    <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
+                      <div className="flex items-start">
+                        <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
+                          <IconLayers size="extra-large" className="text-white" />
+                        </div>
+                        <div className="flex-1 min-w-0">
+                          <div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
+                            {t('模型定价')}
+                          </div>
+                          <div className="text-sm text-white/80">
+                            {userState.user ? (
+                              <div className="flex items-center">
+                                <IconVerify className="mr-1.5 flex-shrink-0" size="small" />
+                                <span className="truncate">
+                                  {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
+                                </span>
+                              </div>
+                            ) : (
+                              <div className="flex items-center">
+                                <AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
+                                <span className="truncate">
+                                  {t('未登录,使用默认分组倍率')}: {groupRatio['default']}
+                                </span>
+                              </div>
+                            )}
+                          </div>
+                        </div>
+                      </div>
+
+                      <div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
+                        <div
+                          className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
+                          style={{ backdropFilter: 'blur(10px)' }}
+                        >
+                          <div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
+                          <div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
+                        </div>
+                        <div
+                          className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
+                          style={{ backdropFilter: 'blur(10px)' }}
+                        >
+                          <div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
+                          <div className="text-sm sm:text-base font-semibold">
+                            {models.filter(m => m.enable_groups.includes(selectedGroup)).length}
+                          </div>
+                        </div>
+                        <div
+                          className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
+                          style={{ backdropFilter: 'blur(10px)' }}
+                        >
+                          <div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
+                          <div className="text-sm sm:text-base font-semibold">2</div>
+                        </div>
+                      </div>
+                    </div>
+
+                    {/* 计费说明 */}
+                    <div className="mt-4 sm:mt-5">
+                      <div className="flex items-start">
+                        <div
+                          className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
+                          style={{
+                            backgroundColor: 'rgba(255, 255, 255, 0.2)',
+                            color: 'white',
+                            backdropFilter: 'blur(10px)'
+                          }}
+                        >
+                          <IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
+                          <span>
+                            {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
+                          </span>
+                        </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>
+                </Card>
+
+                {/* 模型分类 Tabs */}
+                <div className="mb-6">
+                  {renderTabs()}
+
+                  {/* 搜索和表格区域 */}
+                  {SearchAndActions}
+                  {ModelTable}
+                </div>
+
+                {/* 倍率说明图预览 */}
+                <ImagePreview
+                  src={modalImageUrl}
+                  visible={isModalOpenurl}
+                  onVisibleChange={(visible) => setIsModalOpenurl(visible)}
+                />
+              </Card>
+            </div>
+          </div>
+        </Layout.Content>
+      </Layout>
+    </div>
+  );
+};
+
+export default ModelPricing;

+ 244 - 154
web/src/components/RedemptionsTable.js → web/src/components/table/RedemptionsTable.js

@@ -5,23 +5,39 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+  renderQuota
+} from '../../helpers';
 
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderQuota } from '../helpers/render';
+import { ITEMS_PER_PAGE } from '../../constants';
 import {
   Button,
+  Card,
   Divider,
-  Form,
+  Dropdown,
+  Input,
   Modal,
-  Popconfirm,
   Popover,
+  Space,
   Table,
   Tag,
+  Typography,
 } from '@douyinfe/semi-ui';
-import EditRedemption from '../pages/Redemption/EditRedemption';
+import {
+  IconPlus,
+  IconCopy,
+  IconSearch,
+  IconEyeOpened,
+  IconEdit,
+  IconDelete,
+  IconStop,
+  IconPlay,
+  IconMore,
+} from '@douyinfe/semi-icons';
+import EditRedemption from '../../pages/Redemption/EditRedemption';
 import { useTranslation } from 'react-i18next';
 
+const { Text } = Typography;
+
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
 }
@@ -33,25 +49,25 @@ const RedemptionsTable = () => {
     switch (status) {
       case 1:
         return (
-          <Tag color='green' size='large'>
+          <Tag color='green' size='large' shape='circle'>
             {t('未使用')}
           </Tag>
         );
       case 2:
         return (
-          <Tag color='red' size='large'>
+          <Tag color='red' size='large' shape='circle'>
             {t('已禁用')}
           </Tag>
         );
       case 3:
         return (
-          <Tag color='grey' size='large'>
+          <Tag color='grey' size='large' shape='circle'>
             {t('已使用')}
           </Tag>
         );
       default:
         return (
-          <Tag color='black' size='large'>
+          <Tag color='black' size='large' shape='circle'>
             {t('未知状态')}
           </Tag>
         );
@@ -99,76 +115,108 @@ const RedemptionsTable = () => {
     {
       title: '',
       dataIndex: 'operate',
-      render: (text, record, index) => (
-        <div>
-          <Popover content={record.key} style={{ padding: 20 }} position='top'>
-            <Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
-              {t('查看')}
-            </Button>
-          </Popover>
-          <Button
-            theme='light'
-            type='secondary'
-            style={{ marginRight: 1 }}
-            onClick={async (text) => {
-              await copyText(record.key);
-            }}
-          >
-            {t('复制')}
-          </Button>
-          <Popconfirm
-            title={t('确定是否要删除此兑换码?')}
-            content={t('此修改将不可逆')}
-            okType={'danger'}
-            position={'left'}
-            onConfirm={() => {
-              manageRedemption(record.id, 'delete', record).then(() => {
-                removeRecord(record.key);
+      fixed: 'right',
+      render: (text, record, index) => {
+        // 创建更多操作的下拉菜单项
+        const moreMenuItems = [
+          {
+            node: 'item',
+            name: t('删除'),
+            icon: <IconDelete />,
+            type: 'danger',
+            onClick: () => {
+              Modal.confirm({
+                title: t('确定是否要删除此兑换码?'),
+                content: t('此修改将不可逆'),
+                onOk: () => {
+                  manageRedemption(record.id, 'delete', record).then(() => {
+                    removeRecord(record.key);
+                  });
+                },
               });
-            }}
-          >
-            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
-              {t('删除')}
-            </Button>
-          </Popconfirm>
-          {record.status === 1 ? (
+            },
+          }
+        ];
+
+        // 动态添加启用/禁用按钮
+        if (record.status === 1) {
+          moreMenuItems.push({
+            node: 'item',
+            name: t('禁用'),
+            icon: <IconStop />,
+            type: 'warning',
+            onClick: () => {
+              manageRedemption(record.id, 'disable', record);
+            },
+          });
+        } else {
+          moreMenuItems.push({
+            node: 'item',
+            name: t('启用'),
+            icon: <IconPlay />,
+            type: 'secondary',
+            onClick: () => {
+              manageRedemption(record.id, 'enable', record);
+            },
+            disabled: record.status === 3,
+          });
+        }
+
+        return (
+          <Space>
+            <Popover content={record.key} style={{ padding: 20 }} position='top'>
+              <Button
+                icon={<IconEyeOpened />}
+                theme='light'
+                type='tertiary'
+                size="small"
+                className="!rounded-full"
+              >
+                {t('查看')}
+              </Button>
+            </Popover>
             <Button
+              icon={<IconCopy />}
               theme='light'
-              type='warning'
-              style={{ marginRight: 1 }}
+              type='secondary'
+              size="small"
+              className="!rounded-full"
               onClick={async () => {
-                manageRedemption(record.id, 'disable', record);
+                await copyText(record.key);
               }}
             >
-              {t('禁用')}
+              {t('复制')}
             </Button>
-          ) : (
             <Button
+              icon={<IconEdit />}
               theme='light'
-              type='secondary'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageRedemption(record.id, 'enable', record);
+              type='tertiary'
+              size="small"
+              className="!rounded-full"
+              onClick={() => {
+                setEditingRedemption(record);
+                setShowEdit(true);
               }}
-              disabled={record.status === 3}
+              disabled={record.status !== 1}
             >
-              {t('启用')}
+              {t('编辑')}
             </Button>
-          )}
-          <Button
-            theme='light'
-            type='tertiary'
-            style={{ marginRight: 1 }}
-            onClick={() => {
-              setEditingRedemption(record);
-              setShowEdit(true);
-            }}
-            disabled={record.status !== 1}
-          >
-            {t('编辑')}
-          </Button>
-        </div>
-      ),
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              menu={moreMenuItems}
+            >
+              <Button
+                icon={<IconMore />}
+                theme='light'
+                type='tertiary'
+                size="small"
+                className="!rounded-full"
+              />
+            </Dropdown>
+          </Space>
+        );
+      },
     },
   ];
 
@@ -187,6 +235,11 @@ const RedemptionsTable = () => {
 
   const closeEdit = () => {
     setShowEdit(false);
+    setTimeout(() => {
+      setEditingRedemption({
+        id: undefined,
+      });
+    }, 500);
   };
 
   const setRedemptionFormat = (redeptions) => {
@@ -225,8 +278,11 @@ const RedemptionsTable = () => {
     if (await copy(text)) {
       showSuccess(t('已复制到剪贴板!'));
     } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
+      Modal.error({
+        title: t('无法复制到剪贴板,请手动复制'),
+        content: text,
+        size: 'large'
+      });
     }
   };
 
@@ -245,13 +301,14 @@ const RedemptionsTable = () => {
       .catch((reason) => {
         showError(reason);
       });
-  }, []);
+  }, [pageSize]);
 
   const refresh = async () => {
     await loadRedemptions(activePage - 1, pageSize);
   };
 
   const manageRedemption = async (id, action, record) => {
+    setLoading(true);
     let data = { id };
     let res;
     switch (action) {
@@ -272,7 +329,6 @@ const RedemptionsTable = () => {
       showSuccess(t('操作成功完成!'));
       let redemption = res.data.data;
       let newRedemptions = [...redemptions];
-      // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
       if (action === 'delete') {
       } else {
         record.status = redemption.status;
@@ -281,6 +337,7 @@ const RedemptionsTable = () => {
     } else {
       showError(message);
     }
+    setLoading(false);
   };
 
   const searchRedemptions = async (keyword, page, pageSize) => {
@@ -333,8 +390,8 @@ const RedemptionsTable = () => {
 
   let pageData = redemptions;
   const rowSelection = {
-    onSelect: (record, selected) => {},
-    onSelectAll: (selected, selectedRows) => {},
+    onSelect: (record, selected) => { },
+    onSelectAll: (selected, selectedRows) => { },
     onChange: (selectedRowKeys, selectedRows) => {
       setSelectedKeys(selectedRows);
     },
@@ -352,6 +409,80 @@ const RedemptionsTable = () => {
     }
   };
 
+  const renderHeader = () => (
+    <div className="flex flex-col w-full">
+      <div className="mb-2">
+        <div className="flex items-center text-orange-500">
+          <IconEyeOpened className="mr-2" />
+          <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</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={<IconPlus />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => {
+              setEditingRedemption({
+                id: undefined,
+              });
+              setShowEdit(true);
+            }}
+          >
+            {t('添加兑换码')}
+          </Button>
+          <Button
+            type='warning'
+            icon={<IconCopy />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={async () => {
+              if (selectedKeys.length === 0) {
+                showError(t('请至少选择一个兑换码!'));
+                return;
+              }
+              let keys = '';
+              for (let i = 0; i < selectedKeys.length; i++) {
+                keys +=
+                  selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
+              }
+              await copyText(keys);
+            }}
+          >
+            {t('复制所选兑换码到剪贴板')}
+          </Button>
+        </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="relative w-full md:w-64">
+            <Input
+              prefix={<IconSearch />}
+              placeholder={t('关键字(id或者名称)')}
+              value={searchKeyword}
+              onChange={handleKeywordChange}
+              className="!rounded-full"
+              showClear
+            />
+          </div>
+          <Button
+            type="primary"
+            onClick={() => {
+              searchRedemptions(searchKeyword, 1, pageSize).then();
+            }}
+            loading={searching}
+            className="!rounded-full w-full md:w-auto"
+          >
+            {t('查询')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+
   return (
     <>
       <EditRedemption
@@ -360,88 +491,47 @@ const RedemptionsTable = () => {
         visiable={showEdit}
         handleClose={closeEdit}
       ></EditRedemption>
-      <Form
-        onSubmit={() => {
-          searchRedemptions(searchKeyword, activePage, pageSize).then();
-        }}
+
+      <Card
+        className="!rounded-2xl"
+        title={renderHeader()}
+        shadows='always'
+        bordered={false}
       >
-        <Form.Input
-          label={t('搜索关键字')}
-          field='keyword'
-          icon='search'
-          iconPosition='left'
-          placeholder={t('关键字(id或者名称)')}
-          value={searchKeyword}
-          loading={searching}
-          onChange={handleKeywordChange}
-        />
-      </Form>
-      <Divider style={{ margin: '5px 0 15px 0' }} />
-      <div>
-        <Button
-          theme='light'
-          type='primary'
-          style={{ marginRight: 8 }}
-          onClick={() => {
-            setEditingRedemption({
-              id: undefined,
-            });
-            setShowEdit(true);
-          }}
-        >
-          {t('添加兑换码')}
-        </Button>
-        <Button
-          label={t('复制所选兑换码')}
-          type='warning'
-          onClick={async () => {
-            if (selectedKeys.length === 0) {
-              showError(t('请至少选择一个兑换码!'));
-              return;
-            }
-            let keys = '';
-            for (let i = 0; i < selectedKeys.length; i++) {
-              keys +=
-                selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
-            }
-            await copyText(keys);
+        <Table
+          columns={columns}
+          dataSource={pageData}
+          scroll={{ x: 'max-content' }}
+          pagination={{
+            currentPage: activePage,
+            pageSize: pageSize,
+            total: tokenCount,
+            showSizeChanger: true,
+            pageSizeOptions: [10, 20, 50, 100],
+            formatPageText: (page) =>
+              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+                start: page.currentStart,
+                end: page.currentEnd,
+                total: tokenCount,
+              }),
+            onPageSizeChange: (size) => {
+              setPageSize(size);
+              setActivePage(1);
+              if (searchKeyword === '') {
+                loadRedemptions(1, size).then();
+              } else {
+                searchRedemptions(searchKeyword, 1, size).then();
+              }
+            },
+            onPageChange: handlePageChange,
           }}
-        >
-          {t('复制所选兑换码到剪贴板')}
-        </Button>
-      </div>
-
-      <Table
-        style={{ marginTop: 20 }}
-        columns={columns}
-        dataSource={pageData}
-        pagination={{
-          currentPage: activePage,
-          pageSize: pageSize,
-          total: tokenCount,
-          showSizeChanger: true,
-          pageSizeOpts: [10, 20, 50, 100],
-          formatPageText: (page) =>
-            t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-              start: page.currentStart,
-              end: page.currentEnd,
-              total: tokenCount,
-            }),
-          onPageSizeChange: (size) => {
-            setPageSize(size);
-            setActivePage(1);
-            if (searchKeyword === '') {
-              loadRedemptions(1, size).then();
-            } else {
-              searchRedemptions(searchKeyword, 1, size).then();
-            }
-          },
-          onPageChange: handlePageChange,
-        }}
-        loading={loading}
-        rowSelection={rowSelection}
-        onRow={handleRow}
-      ></Table>
+          loading={loading}
+          rowSelection={rowSelection}
+          onRow={handleRow}
+          className="rounded-xl overflow-hidden"
+          size="middle"
+        ></Table>
+      </Card>
     </>
   );
 };

+ 743 - 0
web/src/components/table/TaskLogsTable.js

@@ -0,0 +1,743 @@
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  API,
+  copy,
+  isAdmin,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../../helpers';
+
+import {
+  Button,
+  Card,
+  Checkbox,
+  DatePicker,
+  Divider,
+  Input,
+  Layout,
+  Modal,
+  Progress,
+  Skeleton,
+  Table,
+  Tag,
+  Typography,
+} from '@douyinfe/semi-ui';
+import { ITEMS_PER_PAGE } from '../../constants';
+import {
+  IconEyeOpened,
+  IconSearch,
+  IconSetting,
+} from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
+
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
+];
+
+// 定义列键值常量
+const COLUMN_KEYS = {
+  SUBMIT_TIME: 'submit_time',
+  FINISH_TIME: 'finish_time',
+  DURATION: 'duration',
+  CHANNEL: 'channel',
+  PLATFORM: 'platform',
+  TYPE: 'type',
+  TASK_ID: 'task_id',
+  TASK_STATUS: 'task_status',
+  PROGRESS: 'progress',
+  FAIL_REASON: 'fail_reason',
+};
+
+const renderTimestamp = (timestampInSeconds) => {
+  const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
+
+  const year = date.getFullYear(); // 获取年份
+  const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
+  const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
+  const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
+  const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
+  const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
+
+  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
+};
+
+function renderDuration(submit_time, finishTime) {
+  // 确保startTime和finishTime都是有效的时间戳
+  if (!submit_time || !finishTime) return 'N/A';
+
+  // 将时间戳转换为Date对象
+  const start = new Date(submit_time);
+  const finish = new Date(finishTime);
+
+  // 计算时间差(毫秒)
+  const durationMs = finish - start;
+
+  // 将时间差转换为秒,并保留一位小数
+  const durationSec = (durationMs / 1000).toFixed(1);
+
+  // 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
+  const color = durationSec > 60 ? 'red' : 'green';
+
+  // 返回带有样式的颜色标签
+  return (
+    <Tag color={color} size='large'>
+      {durationSec} 秒
+    </Tag>
+  );
+}
+
+const LogsTable = () => {
+  const { t } = useTranslation();
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [modalContent, setModalContent] = useState('');
+
+  // 列可见性状态
+  const [visibleColumns, setVisibleColumns] = useState({});
+  const [showColumnSelector, setShowColumnSelector] = useState(false);
+  const isAdminUser = isAdmin();
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+
+  // 加载保存的列偏好设置
+  useEffect(() => {
+    const savedColumns = localStorage.getItem('task-logs-table-columns');
+    if (savedColumns) {
+      try {
+        const parsed = JSON.parse(savedColumns);
+        const defaults = getDefaultColumnVisibility();
+        const merged = { ...defaults, ...parsed };
+        setVisibleColumns(merged);
+      } catch (e) {
+        console.error('Failed to parse saved column preferences', e);
+        initDefaultColumns();
+      }
+    } else {
+      initDefaultColumns();
+    }
+  }, []);
+
+  // 获取默认列可见性
+  const getDefaultColumnVisibility = () => {
+    return {
+      [COLUMN_KEYS.SUBMIT_TIME]: true,
+      [COLUMN_KEYS.FINISH_TIME]: true,
+      [COLUMN_KEYS.DURATION]: true,
+      [COLUMN_KEYS.CHANNEL]: isAdminUser,
+      [COLUMN_KEYS.PLATFORM]: true,
+      [COLUMN_KEYS.TYPE]: true,
+      [COLUMN_KEYS.TASK_ID]: true,
+      [COLUMN_KEYS.TASK_STATUS]: true,
+      [COLUMN_KEYS.PROGRESS]: true,
+      [COLUMN_KEYS.FAIL_REASON]: true,
+    };
+  };
+
+  // 初始化默认列可见性
+  const initDefaultColumns = () => {
+    const defaults = getDefaultColumnVisibility();
+    setVisibleColumns(defaults);
+    localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults));
+  };
+
+  // 处理列可见性变化
+  const handleColumnVisibilityChange = (columnKey, checked) => {
+    const updatedColumns = { ...visibleColumns, [columnKey]: checked };
+    setVisibleColumns(updatedColumns);
+  };
+
+  // 处理全选
+  const handleSelectAll = (checked) => {
+    const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
+    const updatedColumns = {};
+
+    allKeys.forEach((key) => {
+      if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
+        updatedColumns[key] = false;
+      } else {
+        updatedColumns[key] = checked;
+      }
+    });
+
+    setVisibleColumns(updatedColumns);
+  };
+
+  // 更新表格时保存列可见性
+  useEffect(() => {
+    if (Object.keys(visibleColumns).length > 0) {
+      localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns));
+    }
+  }, [visibleColumns]);
+
+  const renderType = (type) => {
+    switch (type) {
+      case 'MUSIC':
+        return (
+          <Tag color='grey' size='large' shape='circle'>
+            {t('生成音乐')}
+          </Tag>
+        );
+      case 'LYRICS':
+        return (
+          <Tag color='pink' size='large' shape='circle'>
+            {t('生成歌词')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+    }
+  };
+
+  const renderPlatform = (type) => {
+    switch (type) {
+      case 'suno':
+        return (
+          <Tag color='green' size='large' shape='circle'>
+            Suno
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+    }
+  };
+
+  const renderStatus = (type) => {
+    switch (type) {
+      case 'SUCCESS':
+        return (
+          <Tag color='green' size='large' shape='circle'>
+            {t('成功')}
+          </Tag>
+        );
+      case 'NOT_START':
+        return (
+          <Tag color='grey' size='large' shape='circle'>
+            {t('未启动')}
+          </Tag>
+        );
+      case 'SUBMITTED':
+        return (
+          <Tag color='yellow' size='large' shape='circle'>
+            {t('队列中')}
+          </Tag>
+        );
+      case 'IN_PROGRESS':
+        return (
+          <Tag color='blue' size='large' shape='circle'>
+            {t('执行中')}
+          </Tag>
+        );
+      case 'FAILURE':
+        return (
+          <Tag color='red' size='large' shape='circle'>
+            {t('失败')}
+          </Tag>
+        );
+      case 'QUEUED':
+        return (
+          <Tag color='orange' size='large' shape='circle'>
+            {t('排队中')}
+          </Tag>
+        );
+      case 'UNKNOWN':
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+      case '':
+        return (
+          <Tag color='grey' size='large' shape='circle'>
+            {t('正在提交')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='white' size='large' shape='circle'>
+            {t('未知')}
+          </Tag>
+        );
+    }
+  };
+
+  // 定义所有列
+  const allColumns = [
+    {
+      key: COLUMN_KEYS.SUBMIT_TIME,
+      title: t('提交时间'),
+      dataIndex: 'submit_time',
+      render: (text, record, index) => {
+        return <div>{text ? renderTimestamp(text) : '-'}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.FINISH_TIME,
+      title: t('结束时间'),
+      dataIndex: 'finish_time',
+      render: (text, record, index) => {
+        return <div>{text ? renderTimestamp(text) : '-'}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.DURATION,
+      title: t('花费时间'),
+      dataIndex: 'finish_time',
+      render: (finish, record) => {
+        return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.CHANNEL,
+      title: t('渠道'),
+      dataIndex: 'channel_id',
+      className: isAdminUser ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return isAdminUser ? (
+          <div>
+            <Tag
+              color={colors[parseInt(text) % colors.length]}
+              size='large'
+              shape='circle'
+              onClick={() => {
+                copyText(text);
+              }}
+            >
+              {text}
+            </Tag>
+          </div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.PLATFORM,
+      title: t('平台'),
+      dataIndex: 'platform',
+      render: (text, record, index) => {
+        return <div>{renderPlatform(text)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.TYPE,
+      title: t('类型'),
+      dataIndex: 'action',
+      render: (text, record, index) => {
+        return <div>{renderType(text)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.TASK_ID,
+      title: t('任务ID'),
+      dataIndex: 'task_id',
+      render: (text, record, index) => {
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            onClick={() => {
+              setModalContent(JSON.stringify(record, null, 2));
+              setIsModalOpen(true);
+            }}
+          >
+            <div>{text}</div>
+          </Typography.Text>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.TASK_STATUS,
+      title: t('任务状态'),
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        return <div>{renderStatus(text)}</div>;
+      },
+    },
+    {
+      key: COLUMN_KEYS.PROGRESS,
+      title: t('进度'),
+      dataIndex: 'progress',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {
+              isNaN(text?.replace('%', '')) ? (
+                text || '-'
+              ) : (
+                <Progress
+                  stroke={
+                    record.status === 'FAILURE'
+                      ? 'var(--semi-color-warning)'
+                      : null
+                  }
+                  percent={text ? parseInt(text.replace('%', '')) : 0}
+                  showInfo={true}
+                  aria-label='task progress'
+                  style={{ minWidth: '200px' }}
+                />
+              )
+            }
+          </div>
+        );
+      },
+    },
+    {
+      key: COLUMN_KEYS.FAIL_REASON,
+      title: t('失败原因'),
+      dataIndex: 'fail_reason',
+      fixed: 'right',
+      render: (text, record, index) => {
+        if (!text) {
+          return t('无');
+        }
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      },
+    },
+  ];
+
+  // 根据可见性设置过滤列
+  const getVisibleColumns = () => {
+    return allColumns.filter((column) => visibleColumns[column.key]);
+  };
+
+  const [logs, setLogs] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
+  const [logType] = useState(0);
+
+  let now = new Date();
+  // 初始化start_timestamp为前一天
+  let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+  const [inputs, setInputs] = useState({
+    channel_id: '',
+    task_id: '',
+    start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
+    end_timestamp: '',
+  });
+  const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
+
+  const handleInputChange = (value, name) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
+
+  const setLogsFormat = (logs) => {
+    for (let i = 0; i < logs.length; i++) {
+      logs[i].timestamp2string = timestamp2string(logs[i].created_at);
+      logs[i].key = '' + logs[i].id;
+    }
+    // data.key = '' + data.id
+    setLogs(logs);
+    setLogCount(logs.length + ITEMS_PER_PAGE);
+    // console.log(logCount);
+  };
+
+  const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
+    setLoading(true);
+
+    let url = '';
+    let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
+    let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
+    if (isAdminUser) {
+      url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    } else {
+      url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    }
+    const res = await API.get(url);
+    let { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setLogsFormat(data);
+      } else {
+        let newLogs = [...logs];
+        newLogs.splice(startIdx * pageSize, data.length, ...data);
+        setLogsFormat(newLogs);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const pageData = logs.slice(
+    (activePage - 1) * pageSize,
+    activePage * pageSize,
+  );
+
+  const handlePageChange = (page) => {
+    setActivePage(page);
+    if (page === Math.ceil(logs.length / pageSize) + 1) {
+      loadLogs(page - 1, pageSize).then((r) => { });
+    }
+  };
+
+  const handlePageSizeChange = async (size) => {
+    localStorage.setItem('task-page-size', size + '');
+    setPageSize(size);
+    setActivePage(1);
+    await loadLogs(0, size);
+  };
+
+  const refresh = async () => {
+    setActivePage(1);
+    await loadLogs(0, pageSize);
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess(t('已复制:') + text);
+    } else {
+      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
+    }
+  };
+
+  useEffect(() => {
+    const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
+    setPageSize(localPageSize);
+    loadLogs(0, localPageSize).then();
+  }, [logType]);
+
+  // 列选择器模态框
+  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>
+        }
+      >
+        <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) => {
+            // 为非管理员用户跳过管理员专用列
+            if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
+              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>
+    );
+  };
+
+  return (
+    <>
+      {renderColumnSelector()}
+      <Layout>
+        <Card
+          className="!rounded-2xl mb-4"
+          title={
+            <div className="flex flex-col w-full">
+              <div className="flex flex-col md:flex-row justify-between items-center">
+                <div className="flex items-center text-orange-500 mb-2 md:mb-0">
+                  <IconEyeOpened className="mr-2" />
+                  {loading ? (
+                    <Skeleton.Title
+                      style={{
+                        width: 300,
+                        marginBottom: 0,
+                        marginTop: 0
+                      }}
+                    />
+                  ) : (
+                    <Text>{t('任务记录')}</Text>
+                  )}
+                </div>
+              </div>
+
+              <Divider margin="12px" />
+
+              {/* 搜索表单区域 */}
+              <div className="flex flex-col gap-4">
+                <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">
+                    <DatePicker
+                      className="w-full"
+                      value={[start_timestamp, end_timestamp]}
+                      type='dateTimeRange'
+                      onChange={(value) => {
+                        if (Array.isArray(value) && value.length === 2) {
+                          handleInputChange(value[0], 'start_timestamp');
+                          handleInputChange(value[1], 'end_timestamp');
+                        }
+                      }}
+                    />
+                  </div>
+
+                  {/* 任务 ID */}
+                  <Input
+                    prefix={<IconSearch />}
+                    placeholder={t('任务 ID')}
+                    value={task_id}
+                    onChange={(value) => handleInputChange(value, 'task_id')}
+                    className="!rounded-full"
+                    showClear
+                  />
+
+                  {/* 渠道 ID - 仅管理员可见 */}
+                  {isAdminUser && (
+                    <Input
+                      prefix={<IconSearch />}
+                      placeholder={t('渠道 ID')}
+                      value={channel_id}
+                      onChange={(value) => handleInputChange(value, 'channel_id')}
+                      className="!rounded-full"
+                      showClear
+                    />
+                  )}
+                </div>
+
+                {/* 操作按钮区域 */}
+                <div className="flex justify-between items-center pt-2">
+                  <div></div>
+                  <div className="flex gap-2">
+                    <Button
+                      type='primary'
+                      onClick={refresh}
+                      loading={loading}
+                      className="!rounded-full"
+                    >
+                      {t('查询')}
+                    </Button>
+                    <Button
+                      theme='light'
+                      type='tertiary'
+                      icon={<IconSetting />}
+                      onClick={() => setShowColumnSelector(true)}
+                      className="!rounded-full"
+                    >
+                      {t('列设置')}
+                    </Button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          }
+          shadows='always'
+          bordered={false}
+        >
+          <Table
+            columns={getVisibleColumns()}
+            dataSource={pageData}
+            rowKey='key'
+            loading={loading}
+            scroll={{ x: 'max-content' }}
+            className="rounded-xl overflow-hidden"
+            size="middle"
+            pagination={{
+              formatPageText: (page) =>
+                t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+                  start: page.currentStart,
+                  end: page.currentEnd,
+                  total: logCount,
+                }),
+              currentPage: activePage,
+              pageSize: pageSize,
+              total: logCount,
+              pageSizeOptions: [10, 20, 50, 100],
+              showSizeChanger: true,
+              onPageSizeChange: (size) => {
+                handlePageSizeChange(size);
+              },
+              onPageChange: handlePageChange,
+            }}
+          />
+        </Card>
+
+        <Modal
+          visible={isModalOpen}
+          onOk={() => setIsModalOpen(false)}
+          onCancel={() => setIsModalOpen(false)}
+          closable={null}
+          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
+          width={800} // 设置模态框宽度
+        >
+          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
+        </Modal>
+      </Layout>
+    </>
+  );
+};
+
+export default LogsTable;

+ 233 - 181
web/src/components/TokensTable.js → web/src/components/table/TokensTable.js

@@ -5,26 +5,36 @@ import {
   showError,
   showSuccess,
   timestamp2string,
-} from '../helpers';
+  renderGroup,
+  renderQuota
+} from '../../helpers';
 
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderGroup, renderQuota } from '../helpers/render';
+import { ITEMS_PER_PAGE } from '../../constants';
 import {
   Button,
-  Divider,
+  Card,
   Dropdown,
-  Form,
   Modal,
-  Popconfirm,
-  Popover,
   Space,
   SplitButtonGroup,
   Table,
   Tag,
+  Input,
 } from '@douyinfe/semi-ui';
 
-import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
-import EditToken from '../pages/Token/EditToken';
+import {
+  IconPlus,
+  IconCopy,
+  IconSearch,
+  IconTreeTriangleDown,
+  IconEyeOpened,
+  IconEdit,
+  IconDelete,
+  IconStop,
+  IconPlay,
+  IconMore,
+} from '@douyinfe/semi-icons';
+import EditToken from '../../pages/Token/EditToken';
 import { useTranslation } from 'react-i18next';
 
 function renderTimestamp(timestamp) {
@@ -39,38 +49,38 @@ const TokensTable = () => {
       case 1:
         if (model_limits_enabled) {
           return (
-            <Tag color='green' size='large'>
+            <Tag color='green' size='large' shape='circle'>
               {t('已启用:限制模型')}
             </Tag>
           );
         } else {
           return (
-            <Tag color='green' size='large'>
+            <Tag color='green' size='large' shape='circle'>
               {t('已启用')}
             </Tag>
           );
         }
       case 2:
         return (
-          <Tag color='red' size='large'>
+          <Tag color='red' size='large' shape='circle'>
             {t('已禁用')}
           </Tag>
         );
       case 3:
         return (
-          <Tag color='yellow' size='large'>
+          <Tag color='yellow' size='large' shape='circle'>
             {t('已过期')}
           </Tag>
         );
       case 4:
         return (
-          <Tag color='grey' size='large'>
+          <Tag color='grey' size='large' shape='circle'>
             {t('已耗尽')}
           </Tag>
         );
       default:
         return (
-          <Tag color='black' size='large'>
+          <Tag color='black' size='large' shape='circle'>
             {t('未知状态')}
           </Tag>
         );
@@ -111,11 +121,11 @@ const TokensTable = () => {
         return (
           <div>
             {record.unlimited_quota ? (
-              <Tag size={'large'} color={'white'}>
+              <Tag size={'large'} color={'white'} shape='circle'>
                 {t('无限制')}
               </Tag>
             ) : (
-              <Tag size={'large'} color={'light-blue'}>
+              <Tag size={'large'} color={'light-blue'} shape='circle'>
                 {renderQuota(parseInt(text))}
               </Tag>
             )}
@@ -144,6 +154,7 @@ const TokensTable = () => {
     {
       title: '',
       dataIndex: 'operate',
+      fixed: 'right',
       render: (text, record, index) => {
         let chats = localStorage.getItem('chats');
         let chatsArray = [];
@@ -151,16 +162,11 @@ const TokensTable = () => {
 
         if (shouldUseCustom) {
           try {
-            // console.log(chats);
             chats = JSON.parse(chats);
-            // check chats is array
             if (Array.isArray(chats)) {
               for (let i = 0; i < chats.length; i++) {
                 let chat = {};
                 chat.node = 'item';
-                // c is a map
-                // chat.key = chats[i].name;
-                // console.log(chats[i])
                 for (let key in chats[i]) {
                   if (chats[i].hasOwnProperty(key)) {
                     chat.key = i;
@@ -178,33 +184,72 @@ const TokensTable = () => {
             showError(t('聊天链接配置错误,请联系管理员'));
           }
         }
+
+        // 创建更多操作的下拉菜单项
+        const moreMenuItems = [
+          {
+            node: 'item',
+            name: t('查看'),
+            icon: <IconEyeOpened />,
+            onClick: () => {
+              Modal.info({
+                title: t('令牌详情'),
+                content: 'sk-' + record.key,
+                size: 'large',
+              });
+            },
+          },
+          {
+            node: 'item',
+            name: t('删除'),
+            icon: <IconDelete />,
+            type: 'danger',
+            onClick: () => {
+              Modal.confirm({
+                title: t('确定是否要删除此令牌?'),
+                content: t('此修改将不可逆'),
+                onOk: () => {
+                  manageToken(record.id, 'delete', record).then(() => {
+                    removeRecord(record.key);
+                  });
+                },
+              });
+            },
+          }
+        ];
+
+        // 动态添加启用/禁用按钮
+        if (record.status === 1) {
+          moreMenuItems.push({
+            node: 'item',
+            name: t('禁用'),
+            icon: <IconStop />,
+            type: 'warning',
+            onClick: () => {
+              manageToken(record.id, 'disable', record);
+            },
+          });
+        } else {
+          moreMenuItems.push({
+            node: 'item',
+            name: t('启用'),
+            icon: <IconPlay />,
+            type: 'secondary',
+            onClick: () => {
+              manageToken(record.id, 'enable', record);
+            },
+          });
+        }
+
         return (
-          <div>
-            <Popover
-              content={'sk-' + record.key}
-              style={{ padding: 20 }}
-              position='top'
-            >
-              <Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
-                {t('查看')}
-              </Button>
-            </Popover>
-            <Button
-              theme='light'
-              type='secondary'
-              style={{ marginRight: 1 }}
-              onClick={async (text) => {
-                await copyText('sk-' + record.key);
-              }}
-            >
-              {t('复制')}
-            </Button>
+          <Space wrap>
             <SplitButtonGroup
-              style={{ marginRight: 1 }}
+              className="!rounded-full overflow-hidden"
               aria-label={t('项目操作按钮组')}
             >
               <Button
                 theme='light'
+                size="small"
                 style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
                 onClick={() => {
                   if (chatsArray.length === 0) {
@@ -227,56 +272,35 @@ const TokensTable = () => {
               >
                 <Button
                   style={{
-                    padding: '8px 4px',
+                    padding: '4px 4px',
                     color: 'rgba(var(--semi-teal-7), 1)',
                   }}
                   type='primary'
                   icon={<IconTreeTriangleDown />}
+                  size="small"
                 ></Button>
               </Dropdown>
             </SplitButtonGroup>
-            <Popconfirm
-              title={t('确定是否要删除此令牌?')}
-              content={t('此修改将不可逆')}
-              okType={'danger'}
-              position={'left'}
-              onConfirm={() => {
-                manageToken(record.id, 'delete', record).then(() => {
-                  removeRecord(record.key);
-                });
+
+            <Button
+              icon={<IconCopy />}
+              theme='light'
+              type='secondary'
+              size="small"
+              className="!rounded-full"
+              onClick={async (text) => {
+                await copyText('sk-' + record.key);
               }}
             >
-              <Button theme='light' type='danger' style={{ marginRight: 1 }}>
-                {t('删除')}
-              </Button>
-            </Popconfirm>
-            {record.status === 1 ? (
-              <Button
-                theme='light'
-                type='warning'
-                style={{ marginRight: 1 }}
-                onClick={async () => {
-                  manageToken(record.id, 'disable', record);
-                }}
-              >
-                {t('禁用')}
-              </Button>
-            ) : (
-              <Button
-                theme='light'
-                type='secondary'
-                style={{ marginRight: 1 }}
-                onClick={async () => {
-                  manageToken(record.id, 'enable', record);
-                }}
-              >
-                {t('启用')}
-              </Button>
-            )}
+              {t('复制')}
+            </Button>
+
             <Button
+              icon={<IconEdit />}
               theme='light'
               type='tertiary'
-              style={{ marginRight: 1 }}
+              size="small"
+              className="!rounded-full"
               onClick={() => {
                 setEditingToken(record);
                 setShowEdit(true);
@@ -284,7 +308,21 @@ const TokensTable = () => {
             >
               {t('编辑')}
             </Button>
-          </div>
+
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              menu={moreMenuItems}
+            >
+              <Button
+                icon={<IconMore />}
+                theme='light'
+                type='tertiary'
+                size="small"
+                className="!rounded-full"
+              />
+            </Dropdown>
+          </Space>
         );
       },
     },
@@ -362,7 +400,6 @@ const TokensTable = () => {
   };
 
   const onOpenLink = async (type, url, record) => {
-    // console.log(type, url, key);
     let status = localStorage.getItem('status');
     let serverAddress = '';
     if (status) {
@@ -379,6 +416,8 @@ const TokensTable = () => {
     window.open(url, '_blank');
   };
 
+
+
   useEffect(() => {
     loadTokens(0)
       .then()
@@ -421,11 +460,9 @@ const TokensTable = () => {
       showSuccess('操作成功完成!');
       let token = res.data.data;
       let newTokens = [...tokens];
-      // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
       if (action === 'delete') {
       } else {
         record.status = token.status;
-        // newTokens[realIdx].status = token.status;
       }
       setTokensFormat(newTokens);
     } else {
@@ -436,7 +473,6 @@ const TokensTable = () => {
 
   const searchTokens = async () => {
     if (searchKeyword === '' && searchToken === '') {
-      // if keyword is blank, load files instead.
       await loadTokens(0);
       setActivePage(1);
       return;
@@ -480,14 +516,13 @@ const TokensTable = () => {
   const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(tokens.length / pageSize) + 1) {
-      // In this case we have to load more data and then append them.
-      loadTokens(page - 1).then((r) => {});
+      loadTokens(page - 1).then((r) => { });
     }
   };
 
   const rowSelection = {
-    onSelect: (record, selected) => {},
-    onSelectAll: (selected, selectedRows) => {},
+    onSelect: (record, selected) => { },
+    onSelectAll: (selected, selectedRows) => { },
     onChange: (selectedRowKeys, selectedRows) => {
       setSelectedKeys(selectedRows);
     },
@@ -505,6 +540,80 @@ const TokensTable = () => {
     }
   };
 
+  const renderHeader = () => (
+    <div className="flex flex-col w-full">
+      <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={<IconPlus />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => {
+              setEditingToken({
+                id: undefined,
+              });
+              setShowEdit(true);
+            }}
+          >
+            {t('添加令牌')}
+          </Button>
+          <Button
+            theme="light"
+            type="warning"
+            icon={<IconCopy />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={async () => {
+              if (selectedKeys.length === 0) {
+                showError(t('请至少选择一个令牌!'));
+                return;
+              }
+              let keys = '';
+              for (let i = 0; i < selectedKeys.length; i++) {
+                keys +=
+                  selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
+              }
+              await copyText(keys);
+            }}
+          >
+            {t('复制所选令牌到剪贴板')}
+          </Button>
+        </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="relative w-full md:w-56">
+            <Input
+              prefix={<IconSearch />}
+              placeholder={t('搜索关键字')}
+              value={searchKeyword}
+              onChange={handleKeywordChange}
+              className="!rounded-full"
+              showClear
+            />
+          </div>
+          <div className="relative w-full md:w-56">
+            <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>
+  );
+
   return (
     <>
       <EditToken
@@ -513,99 +622,42 @@ const TokensTable = () => {
         visiable={showEdit}
         handleClose={closeEdit}
       ></EditToken>
-      <Form
-        layout='horizontal'
-        style={{ marginTop: 10 }}
-        labelPosition={'left'}
+
+      <Card
+        className="!rounded-2xl"
+        title={renderHeader()}
+        shadows='always'
+        bordered={false}
       >
-        <Form.Input
-          field='keyword'
-          label={t('搜索关键字')}
-          placeholder={t('令牌名称')}
-          value={searchKeyword}
-          loading={searching}
-          onChange={handleKeywordChange}
-        />
-        <Form.Input
-          field='token'
-          label={t('密钥')}
-          placeholder={t('密钥')}
-          value={searchToken}
-          loading={searching}
-          onChange={handleSearchTokenChange}
-        />
-        <Button
-          label={t('查询')}
-          type='primary'
-          htmlType='submit'
-          className='btn-margin-right'
-          onClick={searchTokens}
-          style={{ marginRight: 8 }}
-        >
-          {t('查询')}
-        </Button>
-      </Form>
-      <Divider style={{ margin: '15px 0' }} />
-      <div>
-        <Button
-          theme='light'
-          type='primary'
-          style={{ marginRight: 8 }}
-          onClick={() => {
-            setEditingToken({
-              id: undefined,
-            });
-            setShowEdit(true);
-          }}
-        >
-          {t('添加令牌')}
-        </Button>
-        <Button
-          label={t('复制所选令牌')}
-          type='warning'
-          onClick={async () => {
-            if (selectedKeys.length === 0) {
-              showError(t('请至少选择一个令牌!'));
-              return;
-            }
-            let keys = '';
-            for (let i = 0; i < selectedKeys.length; i++) {
-              keys +=
-                selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
-            }
-            await copyText(keys);
+        <Table
+          columns={columns}
+          dataSource={pageData}
+          scroll={{ x: 'max-content' }}
+          pagination={{
+            currentPage: activePage,
+            pageSize: pageSize,
+            total: tokenCount,
+            showSizeChanger: true,
+            pageSizeOptions: [10, 20, 50, 100],
+            formatPageText: (page) =>
+              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+                start: page.currentStart,
+                end: page.currentEnd,
+                total: tokens.length,
+              }),
+            onPageSizeChange: (size) => {
+              setPageSize(size);
+              setActivePage(1);
+            },
+            onPageChange: handlePageChange,
           }}
-        >
-          {t('复制所选令牌到剪贴板')}
-        </Button>
-      </div>
-
-      <Table
-        style={{ marginTop: 20 }}
-        columns={columns}
-        dataSource={pageData}
-        pagination={{
-          currentPage: activePage,
-          pageSize: pageSize,
-          total: tokenCount,
-          showSizeChanger: true,
-          pageSizeOptions: [10, 20, 50, 100],
-          formatPageText: (page) =>
-            t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-              start: page.currentStart,
-              end: page.currentEnd,
-              total: tokens.length,
-            }),
-          onPageSizeChange: (size) => {
-            setPageSize(size);
-            setActivePage(1);
-          },
-          onPageChange: handlePageChange,
-        }}
-        loading={loading}
-        rowSelection={rowSelection}
-        onRow={handleRow}
-      ></Table>
+          loading={loading}
+          rowSelection={rowSelection}
+          onRow={handleRow}
+          className="rounded-xl overflow-hidden"
+          size="middle"
+        ></Table>
+      </Card>
     </>
   );
 };

+ 581 - 0
web/src/components/table/UsersTable.js

@@ -0,0 +1,581 @@
+import React, { useEffect, useState } from 'react';
+import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
+import {
+  Button,
+  Card,
+  Divider,
+  Dropdown,
+  Input,
+  Modal,
+  Select,
+  Space,
+  Table,
+  Tag,
+  Typography,
+} from '@douyinfe/semi-ui';
+import {
+  IconPlus,
+  IconSearch,
+  IconEdit,
+  IconDelete,
+  IconStop,
+  IconPlay,
+  IconMore,
+  IconUserAdd,
+  IconArrowUp,
+  IconArrowDown,
+} from '@douyinfe/semi-icons';
+import { ITEMS_PER_PAGE } from '../../constants';
+import AddUser from '../../pages/User/AddUser';
+import EditUser from '../../pages/User/EditUser';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+const UsersTable = () => {
+  const { t } = useTranslation();
+
+  function renderRole(role) {
+    switch (role) {
+      case 1:
+        return (
+          <Tag size='large' color='blue' shape='circle'>
+            {t('普通用户')}
+          </Tag>
+        );
+      case 10:
+        return (
+          <Tag color='yellow' size='large' shape='circle'>
+            {t('管理员')}
+          </Tag>
+        );
+      case 100:
+        return (
+          <Tag color='orange' size='large' shape='circle'>
+            {t('超级管理员')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag color='red' size='large' shape='circle'>
+            {t('未知身份')}
+          </Tag>
+        );
+    }
+  }
+
+  const renderStatus = (status) => {
+    switch (status) {
+      case 1:
+        return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
+      case 2:
+        return (
+          <Tag size='large' color='red' shape='circle'>
+            {t('已封禁')}
+          </Tag>
+        );
+      default:
+        return (
+          <Tag size='large' color='grey' shape='circle'>
+            {t('未知状态')}
+          </Tag>
+        );
+    }
+  };
+
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+    },
+    {
+      title: t('用户名'),
+      dataIndex: 'username',
+    },
+    {
+      title: t('分组'),
+      dataIndex: 'group',
+      render: (text, record, index) => {
+        return <div>{renderGroup(text)}</div>;
+      },
+    },
+    {
+      title: t('统计信息'),
+      dataIndex: 'info',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space spacing={1}>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                {t('剩余')}: {renderQuota(record.quota)}
+              </Tag>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                {t('已用')}: {renderQuota(record.used_quota)}
+              </Tag>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                {t('调用')}: {renderNumber(record.request_count)}
+              </Tag>
+            </Space>
+          </div>
+        );
+      },
+    },
+    {
+      title: t('邀请信息'),
+      dataIndex: 'invite',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space spacing={1}>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                {t('邀请')}: {renderNumber(record.aff_count)}
+              </Tag>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                {t('收益')}: {renderQuota(record.aff_history_quota)}
+              </Tag>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
+              </Tag>
+            </Space>
+          </div>
+        );
+      },
+    },
+    {
+      title: t('角色'),
+      dataIndex: 'role',
+      render: (text, record, index) => {
+        return <div>{renderRole(text)}</div>;
+      },
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {record.DeletedAt !== null ? (
+              <Tag color='red' shape='circle'>{t('已注销')}</Tag>
+            ) : (
+              renderStatus(text)
+            )}
+          </div>
+        );
+      },
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      fixed: 'right',
+      render: (text, record, index) => {
+        if (record.DeletedAt !== null) {
+          return <></>;
+        }
+
+        // 创建更多操作的下拉菜单项
+        const moreMenuItems = [
+          {
+            node: 'item',
+            name: t('提升'),
+            icon: <IconArrowUp />,
+            type: 'warning',
+            onClick: () => {
+              Modal.confirm({
+                title: t('确定要提升此用户吗?'),
+                content: t('此操作将提升用户的权限级别'),
+                onOk: () => {
+                  manageUser(record.id, 'promote', record);
+                },
+              });
+            },
+          },
+          {
+            node: 'item',
+            name: t('降级'),
+            icon: <IconArrowDown />,
+            type: 'secondary',
+            onClick: () => {
+              Modal.confirm({
+                title: t('确定要降级此用户吗?'),
+                content: t('此操作将降低用户的权限级别'),
+                onOk: () => {
+                  manageUser(record.id, 'demote', record);
+                },
+              });
+            },
+          },
+          {
+            node: 'item',
+            name: t('注销'),
+            icon: <IconDelete />,
+            type: 'danger',
+            onClick: () => {
+              Modal.confirm({
+                title: t('确定是否要注销此用户?'),
+                content: t('相当于删除用户,此修改将不可逆'),
+                onOk: () => {
+                  manageUser(record.id, 'delete', record).then(() => {
+                    removeRecord(record.id);
+                  });
+                },
+              });
+            },
+          }
+        ];
+
+        // 动态添加启用/禁用按钮
+        if (record.status === 1) {
+          moreMenuItems.splice(-1, 0, {
+            node: 'item',
+            name: t('禁用'),
+            icon: <IconStop />,
+            type: 'warning',
+            onClick: () => {
+              manageUser(record.id, 'disable', record);
+            },
+          });
+        } else {
+          moreMenuItems.splice(-1, 0, {
+            node: 'item',
+            name: t('启用'),
+            icon: <IconPlay />,
+            type: 'secondary',
+            onClick: () => {
+              manageUser(record.id, 'enable', record);
+            },
+            disabled: record.status === 3,
+          });
+        }
+
+        return (
+          <Space>
+            <Button
+              icon={<IconEdit />}
+              theme='light'
+              type='tertiary'
+              size="small"
+              className="!rounded-full"
+              onClick={() => {
+                setEditingUser(record);
+                setShowEditUser(true);
+              }}
+            >
+              {t('编辑')}
+            </Button>
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              menu={moreMenuItems}
+            >
+              <Button
+                icon={<IconMore />}
+                theme='light'
+                type='tertiary'
+                size="small"
+                className="!rounded-full"
+              />
+            </Dropdown>
+          </Space>
+        );
+      },
+    },
+  ];
+
+  const [users, setUsers] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searching, setSearching] = useState(false);
+  const [searchGroup, setSearchGroup] = useState('');
+  const [groupOptions, setGroupOptions] = useState([]);
+  const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
+  const [showAddUser, setShowAddUser] = useState(false);
+  const [showEditUser, setShowEditUser] = useState(false);
+  const [editingUser, setEditingUser] = useState({
+    id: undefined,
+  });
+
+  const removeRecord = (key) => {
+    let newDataSource = [...users];
+    if (key != null) {
+      let idx = newDataSource.findIndex((data) => data.id === key);
+
+      if (idx > -1) {
+        // update deletedAt
+        newDataSource[idx].DeletedAt = new Date();
+        setUsers(newDataSource);
+      }
+    }
+  };
+
+  const setUserFormat = (users) => {
+    for (let i = 0; i < users.length; i++) {
+      users[i].key = users[i].id;
+    }
+    setUsers(users);
+  };
+
+  const loadUsers = async (startIdx, pageSize) => {
+    const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      const newPageData = data.items;
+      setActivePage(data.page);
+      setUserCount(data.total);
+      setUserFormat(newPageData);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    loadUsers(0, pageSize)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+    fetchGroups().then();
+  }, []);
+
+  const manageUser = async (userId, action, record) => {
+    const res = await API.post('/api/user/manage', {
+      id: userId,
+      action,
+    });
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let user = res.data.data;
+      let newUsers = [...users];
+      if (action === 'delete') {
+      } else {
+        record.status = user.status;
+        record.role = user.role;
+      }
+      setUsers(newUsers);
+    } else {
+      showError(message);
+    }
+  };
+
+  const searchUsers = async (
+    startIdx,
+    pageSize,
+    searchKeyword,
+    searchGroup,
+  ) => {
+    if (searchKeyword === '' && searchGroup === '') {
+      // if keyword is blank, load files instead.
+      await loadUsers(startIdx, pageSize);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(
+      `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
+    );
+    const { success, message, data } = res.data;
+    if (success) {
+      const newPageData = data.items;
+      setActivePage(data.page);
+      setUserCount(data.total);
+      setUserFormat(newPageData);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
+
+  const handleKeywordChange = async (value) => {
+    setSearchKeyword(value.trim());
+  };
+
+  const handlePageChange = (page) => {
+    setActivePage(page);
+    if (searchKeyword === '' && searchGroup === '') {
+      loadUsers(page, pageSize).then();
+    } else {
+      searchUsers(page, pageSize, searchKeyword, searchGroup).then();
+    }
+  };
+
+  const closeAddUser = () => {
+    setShowAddUser(false);
+  };
+
+  const closeEditUser = () => {
+    setShowEditUser(false);
+    setEditingUser({
+      id: undefined,
+    });
+  };
+
+  const refresh = async () => {
+    setActivePage(1);
+    if (searchKeyword === '') {
+      await loadUsers(activePage, pageSize);
+    } else {
+      await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
+    }
+  };
+
+  const fetchGroups = async () => {
+    try {
+      let res = await API.get(`/api/group/`);
+      // add 'all' option
+      // res.data.data.unshift('all');
+      if (res === undefined) {
+        return;
+      }
+      setGroupOptions(
+        res.data.data.map((group) => ({
+          label: group,
+          value: group,
+        })),
+      );
+    } catch (error) {
+      showError(error.message);
+    }
+  };
+
+  const handlePageSizeChange = async (size) => {
+    localStorage.setItem('page-size', size + '');
+    setPageSize(size);
+    setActivePage(1);
+    loadUsers(activePage, size)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  };
+
+  const handleRow = (record, index) => {
+    if (record.DeletedAt !== null || record.status !== 1) {
+      return {
+        style: {
+          background: 'var(--semi-color-disabled-border)',
+        },
+      };
+    } else {
+      return {};
+    }
+  };
+
+  const renderHeader = () => (
+    <div className="flex flex-col w-full">
+      <div className="mb-2">
+        <div className="flex items-center text-blue-500">
+          <IconUserAdd className="mr-2" />
+          <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</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={<IconPlus />}
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => {
+              setShowAddUser(true);
+            }}
+          >
+            {t('添加用户')}
+          </Button>
+        </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="relative w-full md:w-64">
+            <Input
+              prefix={<IconSearch />}
+              placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
+              value={searchKeyword}
+              onChange={handleKeywordChange}
+              className="!rounded-full"
+              showClear
+            />
+          </div>
+          <div className="w-full md:w-48">
+            <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>
+  );
+
+  return (
+    <>
+      <AddUser
+        refresh={refresh}
+        visible={showAddUser}
+        handleClose={closeAddUser}
+      ></AddUser>
+      <EditUser
+        refresh={refresh}
+        visible={showEditUser}
+        handleClose={closeEditUser}
+        editingUser={editingUser}
+      ></EditUser>
+
+      <Card
+        className="!rounded-2xl"
+        title={renderHeader()}
+        shadows='always'
+        bordered={false}
+      >
+        <Table
+          columns={columns}
+          dataSource={users}
+          scroll={{ x: 'max-content' }}
+          pagination={{
+            formatPageText: (page) =>
+              t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
+                start: page.currentStart,
+                end: page.currentEnd,
+                total: userCount,
+              }),
+            currentPage: activePage,
+            pageSize: pageSize,
+            total: userCount,
+            pageSizeOpts: [10, 20, 50, 100],
+            showSizeChanger: true,
+            onPageSizeChange: (size) => {
+              handlePageSizeChange(size);
+            },
+            onPageChange: handlePageChange,
+          }}
+          loading={loading}
+          onRow={handleRow}
+          className="rounded-xl overflow-hidden"
+          size="middle"
+        />
+      </Card>
+    </>
+  );
+};
+
+export default UsersTable;

+ 0 - 76
web/src/components/utils.js

@@ -1,76 +0,0 @@
-import { API, showError } from '../helpers';
-
-export async function getOAuthState() {
-  let path = '/api/oauth/state';
-  let affCode = localStorage.getItem('aff');
-  if (affCode && affCode.length > 0) {
-    path += `?aff=${affCode}`;
-  }
-  const res = await API.get(path);
-  const { success, message, data } = res.data;
-  if (success) {
-    return data;
-  } else {
-    showError(message);
-    return '';
-  }
-}
-
-export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
-  const state = await getOAuthState();
-  if (!state) return;
-  const redirect_uri = `${window.location.origin}/oauth/oidc`;
-  const response_type = 'code';
-  const scope = 'openid profile email';
-  const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
-  if (openInNewTab) {
-    window.open(url);
-  } else {
-    window.location.href = url;
-  }
-}
-
-export async function onGitHubOAuthClicked(github_client_id) {
-  const state = await getOAuthState();
-  if (!state) return;
-  window.open(
-    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
-  );
-}
-
-export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
-  const state = await getOAuthState();
-  if (!state) return;
-  window.open(
-    `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
-  );
-}
-
-let channelModels = undefined;
-export async function loadChannelModels() {
-  const res = await API.get('/api/models');
-  const { success, data } = res.data;
-  if (!success) {
-    return;
-  }
-  channelModels = data;
-  localStorage.setItem('channel_models', JSON.stringify(data));
-}
-
-export function getChannelModels(type) {
-  if (channelModels !== undefined && type in channelModels) {
-    if (!channelModels[type]) {
-      return [];
-    }
-    return channelModels[type];
-  }
-  let models = localStorage.getItem('channel_models');
-  if (!models) {
-    return [];
-  }
-  channelModels = JSON.parse(models);
-  if (type in channelModels) {
-    return channelModels[type];
-  }
-  return [];
-}

+ 1 - 1
web/src/constants/channel.constants.js

@@ -113,7 +113,7 @@ export const CHANNEL_OPTIONS = [
   {
     value: 45,
     color: 'blue',
-    label: '字节火山方舟、豆包、DeepSeek通用',
+    label: '字节火山方舟、豆包通用',
   },
   {
     value: 48,

+ 3 - 2
web/src/constants/index.js

@@ -1,4 +1,5 @@
-export * from './toast.constants';
+export * from './channel.constants';
 export * from './user.constants';
+export * from './toast.constants';
 export * from './common.constant';
-export * from './channel.constants';
+export * from './playground.constants';

+ 95 - 0
web/src/constants/playground.constants.js

@@ -0,0 +1,95 @@
+// ========== 消息相关常量 ==========
+export const MESSAGE_STATUS = {
+  LOADING: 'loading',
+  INCOMPLETE: 'incomplete',
+  COMPLETE: 'complete',
+  ERROR: 'error',
+};
+
+export const MESSAGE_ROLES = {
+  USER: 'user',
+  ASSISTANT: 'assistant',
+  SYSTEM: 'system',
+};
+
+// 默认消息示例
+export const DEFAULT_MESSAGES = [
+  {
+    role: MESSAGE_ROLES.USER,
+    id: '2',
+    createAt: 1715676751919,
+    content: '你好',
+  },
+  {
+    role: MESSAGE_ROLES.ASSISTANT,
+    id: '3',
+    createAt: 1715676751919,
+    content: '你好,请问有什么可以帮助您的吗?',
+    reasoningContent: '',
+    isReasoningExpanded: false,
+  },
+];
+
+// ========== UI 相关常量 ==========
+export const DEBUG_TABS = {
+  PREVIEW: 'preview',
+  REQUEST: 'request',
+  RESPONSE: 'response',
+};
+
+// ========== API 相关常量 ==========
+export const API_ENDPOINTS = {
+  CHAT_COMPLETIONS: '/pg/chat/completions',
+  USER_MODELS: '/api/user/models',
+  USER_GROUPS: '/api/user/self/groups',
+};
+
+// ========== 配置默认值 ==========
+export const DEFAULT_CONFIG = {
+  inputs: {
+    model: 'gpt-4o',
+    group: '',
+    temperature: 0.7,
+    top_p: 1,
+    max_tokens: 4096,
+    frequency_penalty: 0,
+    presence_penalty: 0,
+    seed: null,
+    stream: true,
+    imageEnabled: false,
+    imageUrls: [''],
+  },
+  parameterEnabled: {
+    temperature: true,
+    top_p: true,
+    max_tokens: false,
+    frequency_penalty: true,
+    presence_penalty: true,
+    seed: false,
+  },
+  systemPrompt: '',
+  showDebugPanel: false,
+  customRequestMode: false,
+  customRequestBody: '',
+};
+
+// ========== 正则表达式 ==========
+export const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
+
+// ========== 错误消息 ==========
+export const ERROR_MESSAGES = {
+  NO_TEXT_CONTENT: '此消息没有可复制的文本内容',
+  INVALID_MESSAGE_TYPE: '无法复制此类型的消息内容',
+  COPY_FAILED: '复制失败,请手动选择文本复制',
+  COPY_HTTPS_REQUIRED: '复制功能需要 HTTPS 环境,请手动复制',
+  BROWSER_NOT_SUPPORTED: '浏览器不支持复制功能,请手动复制',
+  JSON_PARSE_ERROR: '自定义请求体格式错误,请检查JSON格式',
+  API_REQUEST_ERROR: '请求发生错误',
+  NETWORK_ERROR: '网络连接失败或服务器无响应',
+};
+
+// ========== 存储键名 ==========
+export const STORAGE_KEYS = {
+  CONFIG: 'playground_config',
+  MESSAGES: 'playground_messages',
+}; 

+ 204 - 83
web/src/context/Style/index.js

@@ -1,106 +1,227 @@
-// contexts/User/index.jsx
+// contexts/Style/index.js
 
-import React, { useState, useEffect } from 'react';
-import { isMobile } from '../../helpers/index.js';
+import React, { useReducer, useEffect, useMemo, createContext } from 'react';
+import { useLocation } from 'react-router-dom';
+import { isMobile as getIsMobile } from '../../helpers';
 
-export const StyleContext = React.createContext({
-  dispatch: () => null,
-});
+// Action Types
+const ACTION_TYPES = {
+  TOGGLE_SIDER: 'TOGGLE_SIDER',
+  SET_SIDER: 'SET_SIDER',
+  SET_MOBILE: 'SET_MOBILE',
+  SET_SIDER_COLLAPSED: 'SET_SIDER_COLLAPSED',
+  BATCH_UPDATE: 'BATCH_UPDATE',
+};
 
-export const StyleProvider = ({ children }) => {
-  const [state, setState] = useState({
-    isMobile: isMobile(),
-    showSider: false,
-    siderCollapsed: false,
-    shouldInnerPadding: false,
-  });
-
-  const dispatch = (action) => {
-    if ('type' in action) {
-      switch (action.type) {
-        case 'TOGGLE_SIDER':
-          setState((prev) => ({ ...prev, showSider: !prev.showSider }));
-          break;
-        case 'SET_SIDER':
-          setState((prev) => ({ ...prev, showSider: action.payload }));
-          break;
-        case 'SET_MOBILE':
-          setState((prev) => ({ ...prev, isMobile: action.payload }));
-          break;
-        case 'SET_SIDER_COLLAPSED':
-          setState((prev) => ({ ...prev, siderCollapsed: action.payload }));
-          break;
-        case 'SET_INNER_PADDING':
-          setState((prev) => ({ ...prev, shouldInnerPadding: action.payload }));
-          break;
-        default:
-          setState((prev) => ({ ...prev, ...action }));
-      }
-    } else {
-      setState((prev) => ({ ...prev, ...action }));
-    }
+// Constants
+const STORAGE_KEYS = {
+  SIDEBAR_COLLAPSED: 'default_collapse_sidebar',
+};
+
+const ROUTE_PATTERNS = {
+  CONSOLE: '/console',
+};
+
+/**
+ * 判断路径是否为控制台路由
+ * @param {string} pathname - 路由路径
+ * @returns {boolean} 是否为控制台路由
+ */
+const isConsoleRoute = (pathname) => {
+  return pathname === ROUTE_PATTERNS.CONSOLE ||
+    pathname.startsWith(ROUTE_PATTERNS.CONSOLE + '/');
+};
+
+/**
+ * 获取初始状态
+ * @param {string} pathname - 当前路由路径
+ * @returns {Object} 初始状态对象
+ */
+const getInitialState = (pathname) => {
+  const isMobile = getIsMobile();
+  const isConsole = isConsoleRoute(pathname);
+  const isCollapsed = localStorage.getItem(STORAGE_KEYS.SIDEBAR_COLLAPSED) === 'true';
+
+  return {
+    isMobile,
+    showSider: isConsole && !isMobile,
+    siderCollapsed: isCollapsed,
+    isManualSiderControl: false,
   };
+};
 
-  useEffect(() => {
-    const updateIsMobile = () => {
-      const mobileDetected = isMobile();
-      dispatch({ type: 'SET_MOBILE', payload: mobileDetected });
+/**
+ * Style reducer
+ * @param {Object} state - 当前状态
+ * @param {Object} action - action 对象
+ * @returns {Object} 新状态
+ */
+const styleReducer = (state, action) => {
+  switch (action.type) {
+    case ACTION_TYPES.TOGGLE_SIDER:
+      return {
+        ...state,
+        showSider: !state.showSider,
+        isManualSiderControl: true,
+      };
 
-      // If on mobile, we might want to auto-hide the sidebar
-      if (mobileDetected && state.showSider) {
-        dispatch({ type: 'SET_SIDER', payload: false });
-      }
-    };
+    case ACTION_TYPES.SET_SIDER:
+      return {
+        ...state,
+        showSider: action.payload,
+        isManualSiderControl: action.isManualControl ?? false,
+      };
 
-    updateIsMobile();
-
-    const updateShowSider = () => {
-      // check pathname
-      const pathname = window.location.pathname;
-      if (
-        pathname === '' ||
-        pathname === '/' ||
-        pathname.includes('/home') ||
-        pathname.includes('/chat')
-      ) {
-        dispatch({ type: 'SET_SIDER', payload: false });
-        dispatch({ type: 'SET_INNER_PADDING', payload: false });
-      } else if (pathname === '/setup') {
-        dispatch({ type: 'SET_SIDER', payload: false });
-        dispatch({ type: 'SET_INNER_PADDING', payload: false });
-      } else {
-        // Only show sidebar on non-mobile devices by default
-        dispatch({ type: 'SET_SIDER', payload: !isMobile() });
-        dispatch({ type: 'SET_INNER_PADDING', payload: true });
-      }
-    };
+    case ACTION_TYPES.SET_MOBILE:
+      return {
+        ...state,
+        isMobile: action.payload,
+      };
 
-    updateShowSider();
+    case ACTION_TYPES.SET_SIDER_COLLAPSED:
+      // 自动保存到 localStorage
+      localStorage.setItem(STORAGE_KEYS.SIDEBAR_COLLAPSED, action.payload.toString());
+      return {
+        ...state,
+        siderCollapsed: action.payload,
+      };
 
-    const updateSiderCollapsed = () => {
-      const isCollapsed =
-        localStorage.getItem('default_collapse_sidebar') === 'true';
-      dispatch({ type: 'SET_SIDER_COLLAPSED', payload: isCollapsed });
-    };
+    case ACTION_TYPES.BATCH_UPDATE:
+      return {
+        ...state,
+        ...action.payload,
+      };
+
+    default:
+      return state;
+  }
+};
 
-    updateSiderCollapsed();
+// Context (内部使用,不导出)
+const StyleContext = createContext(null);
 
-    // Add event listeners to handle window resize
+/**
+ * 自定义 Hook - 处理窗口大小变化
+ * @param {Function} dispatch - dispatch 函数
+ * @param {Object} state - 当前状态
+ * @param {string} pathname - 当前路径
+ */
+const useWindowResize = (dispatch, state, pathname) => {
+  useEffect(() => {
     const handleResize = () => {
-      updateIsMobile();
+      const isMobile = getIsMobile();
+      dispatch({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile });
+
+      // 只有在非手动控制的情况下,才根据屏幕大小自动调整侧边栏
+      if (!state.isManualSiderControl && isConsoleRoute(pathname)) {
+        dispatch({
+          type: ACTION_TYPES.SET_SIDER,
+          payload: !isMobile,
+          isManualControl: false
+        });
+      }
     };
 
-    window.addEventListener('resize', handleResize);
+    let timeoutId;
+    const debouncedResize = () => {
+      clearTimeout(timeoutId);
+      timeoutId = setTimeout(handleResize, 150);
+    };
 
-    // Cleanup event listener on component unmount
+    window.addEventListener('resize', debouncedResize);
     return () => {
-      window.removeEventListener('resize', handleResize);
+      window.removeEventListener('resize', debouncedResize);
+      clearTimeout(timeoutId);
     };
-  }, []);
+  }, [dispatch, state.isManualSiderControl, pathname]);
+};
+
+/**
+ * 自定义 Hook - 处理路由变化
+ * @param {Function} dispatch - dispatch 函数
+ * @param {string} pathname - 当前路径
+ */
+const useRouteChange = (dispatch, pathname) => {
+  useEffect(() => {
+    const isMobile = getIsMobile();
+    const isConsole = isConsoleRoute(pathname);
+
+    dispatch({
+      type: ACTION_TYPES.BATCH_UPDATE,
+      payload: {
+        showSider: isConsole && !isMobile,
+        isManualSiderControl: false,
+      },
+    });
+  }, [pathname, dispatch]);
+};
+
+/**
+ * 自定义 Hook - 处理移动设备侧边栏自动收起
+ * @param {Object} state - 当前状态
+ * @param {Function} dispatch - dispatch 函数
+ */
+const useMobileSiderAutoHide = (state, dispatch) => {
+  useEffect(() => {
+    // 移动设备上,如果不是手动控制且侧边栏是打开的,则自动关闭
+    if (state.isMobile && state.showSider && !state.isManualSiderControl) {
+      dispatch({ type: ACTION_TYPES.SET_SIDER, payload: false });
+    }
+  }, [state.isMobile, state.showSider, state.isManualSiderControl, dispatch]);
+};
+
+/**
+ * Style Provider 组件
+ */
+export const StyleProvider = ({ children }) => {
+  const location = useLocation();
+  const pathname = location.pathname;
+
+  const [state, dispatch] = useReducer(
+    styleReducer,
+    pathname,
+    getInitialState
+  );
+
+  useWindowResize(dispatch, state, pathname);
+  useRouteChange(dispatch, pathname);
+  useMobileSiderAutoHide(state, dispatch);
+
+  const contextValue = useMemo(
+    () => ({ state, dispatch }),
+    [state]
+  );
 
   return (
-    <StyleContext.Provider value={[state, dispatch]}>
+    <StyleContext.Provider value={contextValue}>
       {children}
     </StyleContext.Provider>
   );
 };
+
+/**
+ * 自定义 Hook - 使用 StyleContext
+ * @returns {{state: Object, dispatch: Function}} context value
+ */
+export const useStyle = () => {
+  const context = React.useContext(StyleContext);
+  if (!context) {
+    throw new Error('useStyle must be used within StyleProvider');
+  }
+  return context;
+};
+
+// 导出 action creators 以便外部使用
+export const styleActions = {
+  toggleSider: () => ({ type: ACTION_TYPES.TOGGLE_SIDER }),
+  setSider: (show, isManualControl = false) => ({
+    type: ACTION_TYPES.SET_SIDER,
+    payload: show,
+    isManualControl
+  }),
+  setMobile: (isMobile) => ({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile }),
+  setSiderCollapsed: (collapsed) => ({
+    type: ACTION_TYPES.SET_SIDER_COLLAPSED,
+    payload: collapsed
+  }),
+};

+ 184 - 1
web/src/helpers/api.js

@@ -1,5 +1,6 @@
-import { getUserIdFromLocalStorage, showError } from './utils';
+import { getUserIdFromLocalStorage, showError, formatMessageForAPI, isValidMessage } from './utils';
 import axios from 'axios';
+import { MESSAGE_ROLES } from '../constants/playground.constants';
 
 export let API = axios.create({
   baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -29,3 +30,185 @@ API.interceptors.response.use(
     showError(error);
   },
 );
+
+// playground
+
+// 构建API请求负载
+export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
+  const processedMessages = messages
+    .filter(isValidMessage)
+    .map(formatMessageForAPI)
+    .filter(Boolean);
+
+  // 如果有系统提示,插入到消息开头
+  if (systemPrompt && systemPrompt.trim()) {
+    processedMessages.unshift({
+      role: MESSAGE_ROLES.SYSTEM,
+      content: systemPrompt.trim()
+    });
+  }
+
+  const payload = {
+    model: inputs.model,
+    messages: processedMessages,
+    stream: inputs.stream,
+  };
+
+  // 添加启用的参数
+  const parameterMappings = {
+    temperature: 'temperature',
+    top_p: 'top_p',
+    max_tokens: 'max_tokens',
+    frequency_penalty: 'frequency_penalty',
+    presence_penalty: 'presence_penalty',
+    seed: 'seed'
+  };
+
+  Object.entries(parameterMappings).forEach(([key, param]) => {
+    if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
+      payload[param] = inputs[param];
+    }
+  });
+
+  return payload;
+};
+
+// 处理API错误响应
+export const handleApiError = (error, response = null) => {
+  const errorInfo = {
+    error: error.message || '未知错误',
+    timestamp: new Date().toISOString(),
+    stack: error.stack
+  };
+
+  if (response) {
+    errorInfo.status = response.status;
+    errorInfo.statusText = response.statusText;
+  }
+
+  if (error.message.includes('HTTP error')) {
+    errorInfo.details = '服务器返回了错误状态码';
+  } else if (error.message.includes('Failed to fetch')) {
+    errorInfo.details = '网络连接失败或服务器无响应';
+  }
+
+  return errorInfo;
+};
+
+// 处理模型数据
+export const processModelsData = (data, currentModel) => {
+  const modelOptions = data.map(model => ({
+    label: model,
+    value: model,
+  }));
+
+  const hasCurrentModel = modelOptions.some(option => option.value === currentModel);
+  const selectedModel = hasCurrentModel && modelOptions.length > 0
+    ? currentModel
+    : modelOptions[0]?.value;
+
+  return { modelOptions, selectedModel };
+};
+
+// 处理分组数据
+export const processGroupsData = (data, userGroup) => {
+  let groupOptions = Object.entries(data).map(([group, info]) => ({
+    label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
+    value: group,
+    ratio: info.ratio,
+    fullLabel: info.desc,
+  }));
+
+  if (groupOptions.length === 0) {
+    groupOptions = [{
+      label: '用户分组',
+      value: '',
+      ratio: 1,
+    }];
+  } else if (userGroup) {
+    const userGroupIndex = groupOptions.findIndex(g => g.value === userGroup);
+    if (userGroupIndex > -1) {
+      const userGroupOption = groupOptions.splice(userGroupIndex, 1)[0];
+      groupOptions.unshift(userGroupOption);
+    }
+  }
+
+  return groupOptions;
+};
+
+// 原来components中的utils.js
+
+export async function getOAuthState() {
+  let path = '/api/oauth/state';
+  let affCode = localStorage.getItem('aff');
+  if (affCode && affCode.length > 0) {
+    path += `?aff=${affCode}`;
+  }
+  const res = await API.get(path);
+  const { success, message, data } = res.data;
+  if (success) {
+    return data;
+  } else {
+    showError(message);
+    return '';
+  }
+}
+
+export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
+  const state = await getOAuthState();
+  if (!state) return;
+  const redirect_uri = `${window.location.origin}/oauth/oidc`;
+  const response_type = 'code';
+  const scope = 'openid profile email';
+  const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
+  if (openInNewTab) {
+    window.open(url);
+  } else {
+    window.location.href = url;
+  }
+}
+
+export async function onGitHubOAuthClicked(github_client_id) {
+  const state = await getOAuthState();
+  if (!state) return;
+  window.open(
+    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
+  );
+}
+
+export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
+  const state = await getOAuthState();
+  if (!state) return;
+  window.open(
+    `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
+  );
+}
+
+let channelModels = undefined;
+export async function loadChannelModels() {
+  const res = await API.get('/api/models');
+  const { success, data } = res.data;
+  if (!success) {
+    return;
+  }
+  channelModels = data;
+  localStorage.setItem('channel_models', JSON.stringify(data));
+}
+
+export function getChannelModels(type) {
+  if (channelModels !== undefined && type in channelModels) {
+    if (!channelModels[type]) {
+      return [];
+    }
+    return channelModels[type];
+  }
+  let models = localStorage.getItem('channel_models');
+  if (!models) {
+    return [];
+  }
+  channelModels = JSON.parse(models);
+  if (type in channelModels) {
+    return channelModels[type];
+  }
+  return [];
+}

+ 0 - 10
web/src/helpers/auth-header.js

@@ -1,10 +0,0 @@
-export function authHeader() {
-  // return authorization header with jwt token
-  let user = JSON.parse(localStorage.getItem('user'));
-
-  if (user && user.token) {
-    return { Authorization: 'Bearer ' + user.token };
-  } else {
-    return {};
-  }
-}

+ 33 - 0
web/src/helpers/auth.js

@@ -0,0 +1,33 @@
+import React from 'react';
+import { Navigate } from 'react-router-dom';
+import { history } from './history';
+
+export function authHeader() {
+  // return authorization header with jwt token
+  let user = JSON.parse(localStorage.getItem('user'));
+
+  if (user && user.token) {
+    return { Authorization: 'Bearer ' + user.token };
+  } else {
+    return {};
+  }
+}
+
+export const AuthRedirect = ({ children }) => {
+  const user = localStorage.getItem('user');
+
+  if (user) {
+    return <Navigate to="/console" replace />;
+  }
+
+  return children;
+};
+
+function PrivateRoute({ children }) {
+  if (!localStorage.getItem('user')) {
+    return <Navigate to='/login' state={{ from: history.location }} />;
+  }
+  return children;
+}
+
+export { PrivateRoute };

+ 5 - 1
web/src/helpers/index.js

@@ -1,4 +1,8 @@
 export * from './history';
-export * from './auth-header';
+export * from './auth';
 export * from './utils';
 export * from './api';
+export * from './render';
+export * from './log';
+export * from './data';
+export * from './token';

+ 1 - 1
web/src/helpers/other.js → web/src/helpers/log.js

@@ -4,4 +4,4 @@ export function getLogOther(otherStr) {
   }
   let other = JSON.parse(otherStr);
   return other;
-}
+} 

+ 644 - 215
web/src/helpers/render.js

@@ -1,6 +1,479 @@
 import i18next from 'i18next';
 import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
-import { copy, isMobile, showSuccess } from './utils.js';
+import { copy, isMobile, showSuccess } from './utils';
+import { visit } from 'unist-util-visit';
+import {
+  OpenAI,
+  Claude,
+  Gemini,
+  Moonshot,
+  Zhipu,
+  Qwen,
+  DeepSeek,
+  Minimax,
+  Wenxin,
+  Spark,
+  Midjourney,
+  Hunyuan,
+  Cohere,
+  Cloudflare,
+  Ai360,
+  Yi,
+  Jina,
+  Mistral,
+  XAI,
+  Ollama,
+  Doubao,
+} from '@lobehub/icons';
+
+import {
+  LayoutDashboard,
+  TerminalSquare,
+  MessageSquare,
+  Key,
+  BarChart3,
+  Image as ImageIcon,
+  CheckSquare,
+  CreditCard,
+  Layers,
+  Gift,
+  User,
+  Settings,
+  CircleUser,
+} from 'lucide-react';
+
+// 侧边栏图标颜色映射
+export const sidebarIconColors = {
+  dashboard: '#4F46E5', // 紫蓝色
+  terminal: '#10B981', // 绿色
+  message: '#06B6D4', // 青色
+  key: '#3B82F6', // 蓝色
+  chart: '#8B5CF6', // 紫色
+  image: '#EC4899', // 粉色
+  check: '#F59E0B', // 琥珀色
+  credit: '#F97316', // 橙色
+  layers: '#EF4444', // 红色
+  gift: '#F43F5E', // 玫红色
+  user: '#6366F1', // 靛蓝色
+  settings: '#6B7280', // 灰色
+};
+
+// 获取侧边栏Lucide图标组件
+export function getLucideIcon(key, selected = false) {
+  const size = 16;
+  const strokeWidth = 2;
+  const commonProps = {
+    size,
+    strokeWidth,
+    className: `transition-colors duration-200 ${selected ? 'transition-transform duration-200 scale-105' : ''}`,
+  };
+
+  // 根据不同的key返回不同的图标
+  switch (key) {
+    case 'detail':
+      return (
+        <LayoutDashboard
+          {...commonProps}
+          color={selected ? sidebarIconColors.dashboard : 'currentColor'}
+        />
+      );
+    case 'playground':
+      return (
+        <TerminalSquare
+          {...commonProps}
+          color={selected ? sidebarIconColors.terminal : 'currentColor'}
+        />
+      );
+    case 'chat':
+      return (
+        <MessageSquare
+          {...commonProps}
+          color={selected ? sidebarIconColors.message : 'currentColor'}
+        />
+      );
+    case 'token':
+      return (
+        <Key
+          {...commonProps}
+          color={selected ? sidebarIconColors.key : 'currentColor'}
+        />
+      );
+    case 'log':
+      return (
+        <BarChart3
+          {...commonProps}
+          color={selected ? sidebarIconColors.chart : 'currentColor'}
+        />
+      );
+    case 'midjourney':
+      return (
+        <ImageIcon
+          {...commonProps}
+          color={selected ? sidebarIconColors.image : 'currentColor'}
+        />
+      );
+    case 'task':
+      return (
+        <CheckSquare
+          {...commonProps}
+          color={selected ? sidebarIconColors.check : 'currentColor'}
+        />
+      );
+    case 'topup':
+      return (
+        <CreditCard
+          {...commonProps}
+          color={selected ? sidebarIconColors.credit : 'currentColor'}
+        />
+      );
+    case 'channel':
+      return (
+        <Layers
+          {...commonProps}
+          color={selected ? sidebarIconColors.layers : 'currentColor'}
+        />
+      );
+    case 'redemption':
+      return (
+        <Gift
+          {...commonProps}
+          color={selected ? sidebarIconColors.gift : 'currentColor'}
+        />
+      );
+    case 'user':
+    case 'personal':
+      return (
+        <User
+          {...commonProps}
+          color={selected ? sidebarIconColors.user : 'currentColor'}
+        />
+      );
+    case 'setting':
+      return (
+        <Settings
+          {...commonProps}
+          color={selected ? sidebarIconColors.settings : 'currentColor'}
+        />
+      );
+    default:
+      return (
+        <CircleUser
+          {...commonProps}
+          color={selected ? sidebarIconColors.user : 'currentColor'}
+        />
+      );
+  }
+}
+
+// 获取模型分类
+export const getModelCategories = (() => {
+  let categoriesCache = null;
+  let lastLocale = null;
+
+  return (t) => {
+    const currentLocale = i18next.language;
+    if (categoriesCache && lastLocale === currentLocale) {
+      return categoriesCache;
+    }
+
+    categoriesCache = {
+      all: {
+        label: t('全部模型'),
+        icon: null,
+        filter: () => true,
+      },
+      openai: {
+        label: 'OpenAI',
+        icon: <OpenAI />,
+        filter: (model) =>
+          model.model_name.toLowerCase().includes('gpt') ||
+          model.model_name.toLowerCase().includes('dall-e') ||
+          model.model_name.toLowerCase().includes('whisper') ||
+          model.model_name.toLowerCase().includes('tts') ||
+          model.model_name.toLowerCase().includes('text-') ||
+          model.model_name.toLowerCase().includes('babbage') ||
+          model.model_name.toLowerCase().includes('davinci') ||
+          model.model_name.toLowerCase().includes('curie') ||
+          model.model_name.toLowerCase().includes('ada') ||
+          model.model_name.toLowerCase().includes('o1') ||
+          model.model_name.toLowerCase().includes('o3') ||
+          model.model_name.toLowerCase().includes('o4'),
+      },
+      anthropic: {
+        label: 'Anthropic',
+        icon: <Claude.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('claude'),
+      },
+      gemini: {
+        label: 'Gemini',
+        icon: <Gemini.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('gemini'),
+      },
+      moonshot: {
+        label: 'Moonshot',
+        icon: <Moonshot />,
+        filter: (model) => model.model_name.toLowerCase().includes('moonshot'),
+      },
+      zhipu: {
+        label: t('智谱'),
+        icon: <Zhipu.Color />,
+        filter: (model) =>
+          model.model_name.toLowerCase().includes('chatglm') ||
+          model.model_name.toLowerCase().includes('glm-'),
+      },
+      qwen: {
+        label: t('通义千问'),
+        icon: <Qwen.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('qwen'),
+      },
+      deepseek: {
+        label: 'DeepSeek',
+        icon: <DeepSeek.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
+      },
+      minimax: {
+        label: 'MiniMax',
+        icon: <Minimax.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('abab'),
+      },
+      baidu: {
+        label: t('文心一言'),
+        icon: <Wenxin.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('ernie'),
+      },
+      xunfei: {
+        label: t('讯飞星火'),
+        icon: <Spark.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('spark'),
+      },
+      midjourney: {
+        label: 'Midjourney',
+        icon: <Midjourney />,
+        filter: (model) => model.model_name.toLowerCase().includes('mj_'),
+      },
+      tencent: {
+        label: t('腾讯混元'),
+        icon: <Hunyuan.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
+      },
+      cohere: {
+        label: 'Cohere',
+        icon: <Cohere.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('command'),
+      },
+      cloudflare: {
+        label: 'Cloudflare',
+        icon: <Cloudflare.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
+      },
+      ai360: {
+        label: t('360智脑'),
+        icon: <Ai360.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('360'),
+      },
+      yi: {
+        label: t('零一万物'),
+        icon: <Yi.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('yi'),
+      },
+      jina: {
+        label: 'Jina',
+        icon: <Jina />,
+        filter: (model) => model.model_name.toLowerCase().includes('jina'),
+      },
+      mistral: {
+        label: 'Mistral AI',
+        icon: <Mistral.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('mistral'),
+      },
+      xai: {
+        label: 'xAI',
+        icon: <XAI />,
+        filter: (model) => model.model_name.toLowerCase().includes('grok'),
+      },
+      llama: {
+        label: 'Llama',
+        icon: <Ollama />,
+        filter: (model) => model.model_name.toLowerCase().includes('llama'),
+      },
+      doubao: {
+        label: t('豆包'),
+        icon: <Doubao.Color />,
+        filter: (model) => model.model_name.toLowerCase().includes('doubao'),
+      },
+    };
+
+    lastLocale = currentLocale;
+    return categoriesCache;
+  };
+})();
+
+// 颜色列表
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
+];
+
+// 基础10色色板 (N ≤ 10)
+const baseColors = [
+  '#1664FF', // 主色
+  '#1AC6FF',
+  '#FF8A00',
+  '#3CC780',
+  '#7442D4',
+  '#FFC400',
+  '#304D77',
+  '#B48DEB',
+  '#009488',
+  '#FF7DDA',
+];
+
+// 扩展20色色板 (10 < N ≤ 20)
+const extendedColors = [
+  '#1664FF',
+  '#B2CFFF',
+  '#1AC6FF',
+  '#94EFFF',
+  '#FF8A00',
+  '#FFCE7A',
+  '#3CC780',
+  '#B9EDCD',
+  '#7442D4',
+  '#DDC5FA',
+  '#FFC400',
+  '#FAE878',
+  '#304D77',
+  '#8B959E',
+  '#B48DEB',
+  '#EFE3FF',
+  '#009488',
+  '#59BAA8',
+  '#FF7DDA',
+  '#FFCFEE',
+];
+
+// 模型颜色映射
+export const modelColorMap = {
+  'dall-e': 'rgb(147,112,219)', // 深紫色
+  // 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
+  'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
+  'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
+  // 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
+  'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
+  'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
+  'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
+  'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
+  'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
+  'gpt-4': 'rgb(135,206,235)', // 天蓝色
+  // 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
+  'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
+  'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
+  'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
+  'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
+  'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
+  // 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
+  'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
+  'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
+  'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
+  'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
+  'text-ada-001': 'rgb(255,192,203)', // 粉红色
+  'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
+  'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
+  // 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
+  'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
+  'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
+  'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
+  'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
+  'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
+  'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
+  'tts-1': 'rgb(255,140,0)', // 深橙色
+  'tts-1-1106': 'rgb(255,165,0)', // 橙色
+  'tts-1-hd': 'rgb(255,215,0)', // 金色
+  'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
+  'whisper-1': 'rgb(245,245,220)', // 米色
+  'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
+  'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
+  'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
+  'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
+};
+
+export function modelToColor(modelName) {
+  // 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
+  if (modelColorMap[modelName]) {
+    return modelColorMap[modelName];
+  }
+
+  // 2. 生成一个稳定的数字作为索引
+  let hash = 0;
+  for (let i = 0; i < modelName.length; i++) {
+    hash = (hash << 5) - hash + modelName.charCodeAt(i);
+    hash = hash & hash; // Convert to 32-bit integer
+  }
+  hash = Math.abs(hash);
+
+  // 3. 根据模型名称长度选择不同的色板
+  const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
+
+  // 4. 使用hash值选择颜色
+  const index = hash % colorPalette.length;
+  return colorPalette[index];
+}
+
+export function stringToColor(str) {
+  let sum = 0;
+  for (let i = 0; i < str.length; i++) {
+    sum += str.charCodeAt(i);
+  }
+  let i = sum % colors.length;
+  return colors[i];
+}
+
+// 渲染带有模型图标的标签
+export function renderModelTag(modelName, options = {}) {
+  const {
+    color,
+    size = 'large',
+    shape = 'circle',
+    onClick,
+    suffixIcon,
+  } = options;
+
+  const categories = getModelCategories(i18next.t);
+  let icon = null;
+
+  for (const [key, category] of Object.entries(categories)) {
+    if (key !== 'all' && category.filter({ model_name: modelName })) {
+      icon = category.icon;
+      break;
+    }
+  }
+
+  return (
+    <Tag
+      color={color || stringToColor(modelName)}
+      prefixIcon={icon}
+      suffixIcon={suffixIcon}
+      size={size}
+      shape={shape}
+      onClick={onClick}
+    >
+      {modelName}
+    </Tag>
+  );
+}
 
 export function renderText(text, limit) {
   if (text.length > limit) {
@@ -17,7 +490,7 @@ export function renderText(text, limit) {
 export function renderGroup(group) {
   if (group === '') {
     return (
-      <Tag size='large' key='default' color='orange'>
+      <Tag size='large' key='default' color='orange' shape='circle'>
         {i18next.t('用户分组')}
       </Tag>
     );
@@ -39,6 +512,7 @@ export function renderGroup(group) {
           size='large'
           color={tagColors[group] || stringToColor(group)}
           key={group}
+          shape='circle'
           onClick={async (event) => {
             event.stopPropagation();
             if (await copy(group)) {
@@ -323,6 +797,9 @@ export function renderModelPrice(
   fileSearch = false,
   fileSearchCallCount = 0,
   fileSearchPrice = 0,
+  audioInputSeperatePrice = false,
+  audioInputTokens = 0,
+  audioInputPrice = 0,
 ) {
   if (modelPrice !== -1) {
     return i18next.t(
@@ -350,9 +827,12 @@ export function renderModelPrice(
       effectiveInputTokens =
         inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
     }
-
+    if (audioInputTokens > 0) {
+      effectiveInputTokens -= audioInputTokens;
+    }
     let price =
       (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
+      (audioInputTokens / 1000000) * audioInputPrice * groupRatio +
       (completionTokens / 1000000) * completionRatioPrice * groupRatio +
       (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
       (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
@@ -361,8 +841,11 @@ export function renderModelPrice(
       <>
         <article>
           <p>
-            {i18next.t('输入价格:${{price}} / 1M tokens', {
+            {i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
               price: inputRatioPrice,
+              audioPrice: audioInputSeperatePrice
+                ? `,音频 $${audioInputPrice} / 1M tokens`
+                : '',
             })}
           </p>
           <p>
@@ -416,96 +899,93 @@ export function renderModelPrice(
           )}
           <p></p>
           <p>
-            {cacheTokens > 0 && !image && !webSearch && !fileSearch
-              ? i18next.t(
-                  '输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+            {(() => {
+              // 构建输入部分描述
+              let inputDesc = '';
+              if (image && imageOutputTokens > 0) {
+                inputDesc = i18next.t(
+                  '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
+                  {
+                    nonImageInput: inputTokens - imageOutputTokens,
+                    imageInput: imageOutputTokens,
+                    imageRatio: imageRatio,
+                    price: inputRatioPrice,
+                  },
+                );
+              } else if (cacheTokens > 0) {
+                inputDesc = i18next.t(
+                  '(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
                   {
                     nonCacheInput: inputTokens - cacheTokens,
                     cacheInput: cacheTokens,
-                    cachePrice: inputRatioPrice * cacheRatio,
                     price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    ratio: groupRatio,
-                    total: price.toFixed(6),
+                    cachePrice: cacheRatioPrice,
                   },
-                )
-              : image && imageOutputTokens > 0 && !webSearch && !fileSearch
-                ? i18next.t(
-                    '输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                    {
-                      nonImageInput: inputTokens - imageOutputTokens,
-                      imageInput: imageOutputTokens,
-                      imageRatio: imageRatio,
-                      price: inputRatioPrice,
-                      completion: completionTokens,
-                      compPrice: completionRatioPrice,
-                      ratio: groupRatio,
-                      total: price.toFixed(6),
-                    },
-                  )
-                : webSearch && webSearchCallCount > 0 && !image && !fileSearch
+                );
+              } else if (audioInputSeperatePrice && audioInputTokens > 0) {
+                inputDesc = i18next.t(
+                  '(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
+                  {
+                    nonAudioInput: inputTokens - audioInputTokens,
+                    audioInput: audioInputTokens,
+                    price: inputRatioPrice,
+                    audioPrice: audioInputPrice,
+                  },
+                );
+              } else {
+                inputDesc = i18next.t(
+                  '(输入 {{input}} tokens / 1M tokens * ${{price}}',
+                  {
+                    input: inputTokens,
+                    price: inputRatioPrice,
+                  },
+                );
+              }
+
+              // 构建输出部分描述
+              const outputDesc = i18next.t(
+                '输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
+                {
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                },
+              );
+
+              // 构建额外服务描述
+              const extraServices = [
+                webSearch && webSearchCallCount > 0
                   ? i18next.t(
-                      '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}',
+                      ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
                       {
-                        input: inputTokens,
-                        price: inputRatioPrice,
-                        completion: completionTokens,
-                        compPrice: completionRatioPrice,
+                        count: webSearchCallCount,
+                        price: webSearchPrice,
                         ratio: groupRatio,
-                        webSearchCallCount,
-                        webSearchPrice,
-                        total: price.toFixed(6),
                       },
                     )
-                  : fileSearch &&
-                      fileSearchCallCount > 0 &&
-                      !image &&
-                      !webSearch
-                    ? i18next.t(
-                        '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
-                        {
-                          input: inputTokens,
-                          price: inputRatioPrice,
-                          completion: completionTokens,
-                          compPrice: completionRatioPrice,
-                          ratio: groupRatio,
-                          fileSearchCallCount,
-                          fileSearchPrice,
-                          total: price.toFixed(6),
-                        },
-                      )
-                    : webSearch &&
-                        webSearchCallCount > 0 &&
-                        fileSearch &&
-                        fileSearchCallCount > 0 &&
-                        !image
-                      ? i18next.t(
-                          '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}}+ 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
-                          {
-                            input: inputTokens,
-                            price: inputRatioPrice,
-                            completion: completionTokens,
-                            compPrice: completionRatioPrice,
-                            ratio: groupRatio,
-                            webSearchCallCount,
-                            webSearchPrice,
-                            fileSearchCallCount,
-                            fileSearchPrice,
-                            total: price.toFixed(6),
-                          },
-                        )
-                      : i18next.t(
-                          '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                          {
-                            input: inputTokens,
-                            price: inputRatioPrice,
-                            completion: completionTokens,
-                            compPrice: completionRatioPrice,
-                            ratio: groupRatio,
-                            total: price.toFixed(6),
-                          },
-                        )}
+                  : '',
+                fileSearch && fileSearchCallCount > 0
+                  ? i18next.t(
+                      ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
+                      {
+                        count: fileSearchCallCount,
+                        price: fileSearchPrice,
+                        ratio: groupRatio,
+                      },
+                    )
+                  : '',
+              ].join('');
+
+              return i18next.t(
+                '{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
+                {
+                  inputDesc,
+                  outputDesc,
+                  extraServices,
+                  total: price.toFixed(6),
+                },
+              );
+            })()}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -798,137 +1278,6 @@ export function renderQuotaWithPrompt(quota, digits) {
   return '';
 }
 
-const colors = [
-  'amber',
-  'blue',
-  'cyan',
-  'green',
-  'grey',
-  'indigo',
-  'light-blue',
-  'lime',
-  'orange',
-  'pink',
-  'purple',
-  'red',
-  'teal',
-  'violet',
-  'yellow',
-];
-
-// 基础10色色板 (N ≤ 10)
-const baseColors = [
-  '#1664FF', // 主色
-  '#1AC6FF',
-  '#FF8A00',
-  '#3CC780',
-  '#7442D4',
-  '#FFC400',
-  '#304D77',
-  '#B48DEB',
-  '#009488',
-  '#FF7DDA',
-];
-
-// 扩展20色色板 (10 < N ≤ 20)
-const extendedColors = [
-  '#1664FF',
-  '#B2CFFF',
-  '#1AC6FF',
-  '#94EFFF',
-  '#FF8A00',
-  '#FFCE7A',
-  '#3CC780',
-  '#B9EDCD',
-  '#7442D4',
-  '#DDC5FA',
-  '#FFC400',
-  '#FAE878',
-  '#304D77',
-  '#8B959E',
-  '#B48DEB',
-  '#EFE3FF',
-  '#009488',
-  '#59BAA8',
-  '#FF7DDA',
-  '#FFCFEE',
-];
-
-export const modelColorMap = {
-  'dall-e': 'rgb(147,112,219)', // 深紫色
-  // 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
-  'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
-  'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
-  // 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
-  'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
-  'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
-  'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
-  'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
-  'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
-  'gpt-4': 'rgb(135,206,235)', // 天蓝色
-  // 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
-  'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
-  'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
-  'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
-  'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
-  'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
-  // 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
-  'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
-  'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
-  'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
-  'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
-  'text-ada-001': 'rgb(255,192,203)', // 粉红色
-  'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
-  'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
-  // 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
-  'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
-  'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
-  'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
-  'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
-  'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
-  'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
-  'tts-1': 'rgb(255,140,0)', // 深橙色
-  'tts-1-1106': 'rgb(255,165,0)', // 橙色
-  'tts-1-hd': 'rgb(255,215,0)', // 金色
-  'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
-  'whisper-1': 'rgb(245,245,220)', // 米色
-  'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
-  'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
-  'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
-  'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
-};
-
-export function modelToColor(modelName) {
-  // 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
-  if (modelColorMap[modelName]) {
-    return modelColorMap[modelName];
-  }
-
-  // 2. 生成一个稳定的数字作为索引
-  let hash = 0;
-  for (let i = 0; i < modelName.length; i++) {
-    hash = (hash << 5) - hash + modelName.charCodeAt(i);
-    hash = hash & hash; // Convert to 32-bit integer
-  }
-  hash = Math.abs(hash);
-
-  // 3. 根据模型名称长度选择不同的色板
-  const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
-
-  // 4. 使用hash值选择颜色
-  const index = hash % colorPalette.length;
-  return colorPalette[index];
-}
-
-export function stringToColor(str) {
-  let sum = 0;
-  for (let i = 0; i < str.length; i++) {
-    sum += str.charCodeAt(i);
-  }
-  let i = sum % colors.length;
-  return colors[i];
-}
-
 export function renderClaudeModelPrice(
   inputTokens,
   completionTokens,
@@ -1127,3 +1476,83 @@ export function renderClaudeModelPriceSimple(
     }
   }
 }
+
+/**
+ * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
+ * 仅在流式渲染阶段使用,避免已渲染文字重复动画。
+ */
+export function rehypeSplitWordsIntoSpans(options = {}) {
+  const { previousContentLength = 0 } = options;
+
+  return (tree) => {
+    let currentCharCount = 0; // 当前已处理的字符数
+
+    visit(tree, 'element', (node) => {
+      if (
+        ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
+          node.tagName,
+        ) &&
+        node.children
+      ) {
+        const newChildren = [];
+        node.children.forEach((child) => {
+          if (child.type === 'text') {
+            try {
+              // 使用 Intl.Segmenter 精准拆分中英文及标点
+              const segmenter = new Intl.Segmenter('zh', {
+                granularity: 'word',
+              });
+              const segments = segmenter.segment(child.value);
+
+              Array.from(segments)
+                .map((seg) => seg.segment)
+                .filter(Boolean)
+                .forEach((word) => {
+                  const wordStartPos = currentCharCount;
+                  const wordEndPos = currentCharCount + word.length;
+
+                  // 判断这个词是否是新增的(在 previousContentLength 之后)
+                  const isNewContent = wordStartPos >= previousContentLength;
+
+                  newChildren.push({
+                    type: 'element',
+                    tagName: 'span',
+                    properties: {
+                      className: isNewContent ? ['animate-fade-in'] : [],
+                    },
+                    children: [{ type: 'text', value: word }],
+                  });
+
+                  currentCharCount = wordEndPos;
+                });
+            } catch (_) {
+              // Fallback:如果浏览器不支持 Segmenter
+              const textStartPos = currentCharCount;
+              const isNewContent = textStartPos >= previousContentLength;
+
+              if (isNewContent) {
+                // 新内容,添加动画
+                newChildren.push({
+                  type: 'element',
+                  tagName: 'span',
+                  properties: {
+                    className: ['animate-fade-in'],
+                  },
+                  children: [{ type: 'text', value: child.value }],
+                });
+              } else {
+                // 旧内容,不添加动画
+                newChildren.push(child);
+              }
+
+              currentCharCount += child.value.length;
+            }
+          } else {
+            newChildren.push(child);
+          }
+        });
+        node.children = newChildren;
+      }
+    });
+  };
+}

+ 45 - 0
web/src/helpers/token.js

@@ -0,0 +1,45 @@
+import { API } from './api';
+
+/**
+ * 获取可用的token keys
+ * @returns {Promise<string[]>} 返回active状态的token key数组
+ */
+export async function fetchTokenKeys() {
+  try {
+    const response = await API.get('/api/token/?p=0&size=100');
+    const { success, data } = response.data;
+    if (success) {
+      const activeTokens = data.filter((token) => token.status === 1);
+      return activeTokens.map((token) => token.key);
+    } else {
+      throw new Error('Failed to fetch token keys');
+    }
+  } catch (error) {
+    console.error('Error fetching token keys:', error);
+    return [];
+  }
+}
+
+/**
+ * 获取服务器地址
+ * @returns {string} 服务器地址
+ */
+export function getServerAddress() {
+  let status = localStorage.getItem('status');
+  let serverAddress = '';
+
+  if (status) {
+    try {
+      status = JSON.parse(status);
+      serverAddress = status.server_address || '';
+    } catch (error) {
+      console.error('Failed to parse status from localStorage:', error);
+    }
+  }
+
+  if (!serverAddress) {
+    serverAddress = window.location.origin;
+  }
+
+  return serverAddress;
+} 

+ 165 - 0
web/src/helpers/utils.js

@@ -2,6 +2,7 @@ import { Toast } from '@douyinfe/semi-ui';
 import { toastConstants } from '../constants';
 import React from 'react';
 import { toast } from 'react-toastify';
+import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
 
 const HTMLToastContent = ({ htmlContent }) => {
   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -95,6 +96,8 @@ export function showError(error) {
     if (error.name === 'AxiosError') {
       switch (error.response.status) {
         case 401:
+          // 清除用户状态
+          localStorage.removeItem('user');
           // toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
           window.location.href = '/login?expired=true';
           break;
@@ -281,3 +284,165 @@ export function compareObjects(oldObject, newObject) {
 
   return changedProperties;
 }
+
+// playground message
+
+// 生成唯一ID
+let messageId = 4;
+export const generateMessageId = () => `${messageId++}`;
+
+// 提取消息中的文本内容
+export const getTextContent = (message) => {
+  if (!message || !message.content) return '';
+
+  if (Array.isArray(message.content)) {
+    const textContent = message.content.find(item => item.type === 'text');
+    return textContent?.text || '';
+  }
+  return typeof message.content === 'string' ? message.content : '';
+};
+
+// 处理 think 标签
+export const processThinkTags = (content, reasoningContent = '') => {
+  if (!content || !content.includes('<think>')) {
+    return { content, reasoningContent };
+  }
+
+  const thoughts = [];
+  const replyParts = [];
+  let lastIndex = 0;
+  let match;
+
+  THINK_TAG_REGEX.lastIndex = 0;
+  while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
+    replyParts.push(content.substring(lastIndex, match.index));
+    thoughts.push(match[1]);
+    lastIndex = match.index + match[0].length;
+  }
+  replyParts.push(content.substring(lastIndex));
+
+  const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
+  const thoughtsStr = thoughts.join('\n\n---\n\n');
+  const processedReasoningContent = reasoningContent && thoughtsStr
+    ? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
+    : reasoningContent || thoughtsStr;
+
+  return {
+    content: processedContent,
+    reasoningContent: processedReasoningContent
+  };
+};
+
+// 处理未完成的 think 标签
+export const processIncompleteThinkTags = (content, reasoningContent = '') => {
+  if (!content) return { content: '', reasoningContent };
+
+  const lastOpenThinkIndex = content.lastIndexOf('<think>');
+  if (lastOpenThinkIndex === -1) {
+    return processThinkTags(content, reasoningContent);
+  }
+
+  const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
+  if (!fragmentAfterLastOpen.includes('</think>')) {
+    const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
+    const cleanContent = content.substring(0, lastOpenThinkIndex);
+    const processedReasoningContent = unclosedThought
+      ? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
+      : reasoningContent;
+
+    return processThinkTags(cleanContent, processedReasoningContent);
+  }
+
+  return processThinkTags(content, reasoningContent);
+};
+
+// 构建消息内容(包含图片)
+export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
+  if (!textContent && (!imageUrls || imageUrls.length === 0)) {
+    return '';
+  }
+
+  const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
+
+  if (imageEnabled && validImageUrls.length > 0) {
+    return [
+      { type: 'text', text: textContent || '' },
+      ...validImageUrls.map(url => ({
+        type: 'image_url',
+        image_url: { url: url.trim() }
+      }))
+    ];
+  }
+
+  return textContent || '';
+};
+
+// 创建新消息
+export const createMessage = (role, content, options = {}) => ({
+  role,
+  content,
+  createAt: Date.now(),
+  id: generateMessageId(),
+  ...options
+});
+
+// 创建加载中的助手消息
+export const createLoadingAssistantMessage = () => createMessage(
+  MESSAGE_ROLES.ASSISTANT,
+  '',
+  {
+    reasoningContent: '',
+    isReasoningExpanded: true,
+    isThinkingComplete: false,
+    hasAutoCollapsed: false,
+    status: 'loading'
+  }
+);
+
+// 检查消息是否包含图片
+export const hasImageContent = (message) => {
+  return message &&
+    Array.isArray(message.content) &&
+    message.content.some(item => item.type === 'image_url');
+};
+
+// 格式化消息用于API请求
+export const formatMessageForAPI = (message) => {
+  if (!message) return null;
+
+  return {
+    role: message.role,
+    content: message.content
+  };
+};
+
+// 验证消息是否有效
+export const isValidMessage = (message) => {
+  return message &&
+    message.role &&
+    (message.content || message.content === '');
+};
+
+// 获取最后一条用户消息
+export const getLastUserMessage = (messages) => {
+  if (!Array.isArray(messages)) return null;
+
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === MESSAGE_ROLES.USER) {
+      return messages[i];
+    }
+  }
+  return null;
+};
+
+// 获取最后一条助手消息
+export const getLastAssistantMessage = (messages) => {
+  if (!Array.isArray(messages)) return null;
+
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
+      return messages[i];
+    }
+  }
+  return null;
+};

+ 404 - 0
web/src/hooks/useApiRequest.js

@@ -0,0 +1,404 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { SSE } from 'sse';
+import {
+  API_ENDPOINTS,
+  MESSAGE_STATUS,
+  DEBUG_TABS
+} from '../constants/playground.constants';
+import {
+  getUserIdFromLocalStorage,
+  handleApiError,
+  processThinkTags,
+  processIncompleteThinkTags
+} from '../helpers';
+
+export const useApiRequest = (
+  setMessage,
+  setDebugData,
+  setActiveDebugTab,
+  sseSourceRef,
+  saveMessages
+) => {
+  const { t } = useTranslation();
+
+  // 处理消息自动关闭逻辑的公共函数
+  const applyAutoCollapseLogic = useCallback((message, isThinkingComplete = true) => {
+    const shouldAutoCollapse = isThinkingComplete && !message.hasAutoCollapsed;
+    return {
+      isThinkingComplete,
+      hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed,
+      isReasoningExpanded: shouldAutoCollapse ? false : message.isReasoningExpanded,
+    };
+  }, []);
+
+  // 流式消息更新
+  const streamMessageUpdate = useCallback((textChunk, type) => {
+    setMessage(prevMessage => {
+      const lastMessage = prevMessage[prevMessage.length - 1];
+      if (!lastMessage) return prevMessage;
+      if (lastMessage.role !== 'assistant') return prevMessage;
+      if (lastMessage.status === MESSAGE_STATUS.ERROR) {
+        return prevMessage;
+      }
+
+      if (lastMessage.status === MESSAGE_STATUS.LOADING ||
+        lastMessage.status === MESSAGE_STATUS.INCOMPLETE) {
+
+        let newMessage = { ...lastMessage };
+
+        if (type === 'reasoning') {
+          newMessage = {
+            ...newMessage,
+            reasoningContent: (lastMessage.reasoningContent || '') + textChunk,
+            status: MESSAGE_STATUS.INCOMPLETE,
+            isThinkingComplete: false,
+          };
+        } else if (type === 'content') {
+          const shouldCollapseReasoning = !lastMessage.content && lastMessage.reasoningContent;
+          const newContent = (lastMessage.content || '') + textChunk;
+
+          let shouldCollapseFromThinkTag = false;
+          let thinkingCompleteFromTags = lastMessage.isThinkingComplete;
+
+          if (lastMessage.isReasoningExpanded && newContent.includes('</think>')) {
+            const thinkMatches = newContent.match(/<think>/g);
+            const thinkCloseMatches = newContent.match(/<\/think>/g);
+            if (thinkMatches && thinkCloseMatches &&
+              thinkCloseMatches.length >= thinkMatches.length) {
+              shouldCollapseFromThinkTag = true;
+              thinkingCompleteFromTags = true; // think标签闭合也标记思考完成
+            }
+          }
+
+          // 如果开始接收content内容,且之前有reasoning内容,或者think标签已闭合,则标记思考完成
+          const isThinkingComplete = (lastMessage.reasoningContent && !lastMessage.isThinkingComplete) ||
+            thinkingCompleteFromTags;
+
+          const autoCollapseState = applyAutoCollapseLogic(lastMessage, isThinkingComplete);
+
+          newMessage = {
+            ...newMessage,
+            content: newContent,
+            status: MESSAGE_STATUS.INCOMPLETE,
+            ...autoCollapseState,
+          };
+        }
+
+        return [...prevMessage.slice(0, -1), newMessage];
+      }
+
+      return prevMessage;
+    });
+  }, [setMessage, applyAutoCollapseLogic]);
+
+  // 完成消息
+  const completeMessage = useCallback((status = MESSAGE_STATUS.COMPLETE) => {
+    setMessage(prevMessage => {
+      const lastMessage = prevMessage[prevMessage.length - 1];
+      if (lastMessage.status === MESSAGE_STATUS.COMPLETE ||
+        lastMessage.status === MESSAGE_STATUS.ERROR) {
+        return prevMessage;
+      }
+
+      const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
+
+      const updatedMessages = [
+        ...prevMessage.slice(0, -1),
+        {
+          ...lastMessage,
+          status: status,
+          ...autoCollapseState,
+        }
+      ];
+
+      // 在消息完成时保存,传入更新后的消息列表
+      if (status === MESSAGE_STATUS.COMPLETE || status === MESSAGE_STATUS.ERROR) {
+        setTimeout(() => saveMessages(updatedMessages), 0);
+      }
+
+      return updatedMessages;
+    });
+  }, [setMessage, applyAutoCollapseLogic, saveMessages]);
+
+  // 非流式请求
+  const handleNonStreamRequest = useCallback(async (payload) => {
+    setDebugData(prev => ({
+      ...prev,
+      request: payload,
+      timestamp: new Date().toISOString(),
+      response: null
+    }));
+    setActiveDebugTab(DEBUG_TABS.REQUEST);
+
+    try {
+      const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'New-Api-User': getUserIdFromLocalStorage(),
+        },
+        body: JSON.stringify(payload),
+      });
+
+      if (!response.ok) {
+        let errorBody = '';
+        try {
+          errorBody = await response.text();
+        } catch (e) {
+          errorBody = '无法读取错误响应体';
+        }
+
+        const errorInfo = handleApiError(
+          new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`),
+          response
+        );
+
+        setDebugData(prev => ({
+          ...prev,
+          response: JSON.stringify(errorInfo, null, 2)
+        }));
+        setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+        throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`);
+      }
+
+      const data = await response.json();
+
+      setDebugData(prev => ({
+        ...prev,
+        response: JSON.stringify(data, null, 2)
+      }));
+      setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+      if (data.choices?.[0]) {
+        const choice = data.choices[0];
+        let content = choice.message?.content || '';
+        let reasoningContent = choice.message?.reasoning_content || '';
+
+        const processed = processThinkTags(content, reasoningContent);
+
+        setMessage(prevMessage => {
+          const newMessages = [...prevMessage];
+          const lastMessage = newMessages[newMessages.length - 1];
+          if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
+            const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
+
+            newMessages[newMessages.length - 1] = {
+              ...lastMessage,
+              content: processed.content,
+              reasoningContent: processed.reasoningContent,
+              status: MESSAGE_STATUS.COMPLETE,
+              ...autoCollapseState,
+            };
+          }
+          return newMessages;
+        });
+      }
+    } catch (error) {
+      console.error('Non-stream request error:', error);
+
+      const errorInfo = handleApiError(error);
+      setDebugData(prev => ({
+        ...prev,
+        response: JSON.stringify(errorInfo, null, 2)
+      }));
+      setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+      setMessage(prevMessage => {
+        const newMessages = [...prevMessage];
+        const lastMessage = newMessages[newMessages.length - 1];
+        if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
+          const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
+
+          newMessages[newMessages.length - 1] = {
+            ...lastMessage,
+            content: t('请求发生错误: ') + error.message,
+            status: MESSAGE_STATUS.ERROR,
+            ...autoCollapseState,
+          };
+        }
+        return newMessages;
+      });
+    }
+  }, [setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic]);
+
+  // SSE请求
+  const handleSSE = useCallback((payload) => {
+    setDebugData(prev => ({
+      ...prev,
+      request: payload,
+      timestamp: new Date().toISOString(),
+      response: null
+    }));
+    setActiveDebugTab(DEBUG_TABS.REQUEST);
+
+    const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
+      headers: {
+        'Content-Type': 'application/json',
+        'New-Api-User': getUserIdFromLocalStorage(),
+      },
+      method: 'POST',
+      payload: JSON.stringify(payload),
+    });
+
+    sseSourceRef.current = source;
+
+    let responseData = '';
+    let hasReceivedFirstResponse = false;
+
+    source.addEventListener('message', (e) => {
+      if (e.data === '[DONE]') {
+        source.close();
+        sseSourceRef.current = null;
+        setDebugData(prev => ({ ...prev, response: responseData }));
+        completeMessage();
+        return;
+      }
+
+      try {
+        const payload = JSON.parse(e.data);
+        responseData += e.data + '\n';
+
+        if (!hasReceivedFirstResponse) {
+          setActiveDebugTab(DEBUG_TABS.RESPONSE);
+          hasReceivedFirstResponse = true;
+        }
+
+        const delta = payload.choices?.[0]?.delta;
+        if (delta) {
+          if (delta.reasoning_content) {
+            streamMessageUpdate(delta.reasoning_content, 'reasoning');
+          }
+          if (delta.content) {
+            streamMessageUpdate(delta.content, 'content');
+          }
+        }
+      } catch (error) {
+        console.error('Failed to parse SSE message:', error);
+        const errorInfo = `解析错误: ${error.message}`;
+
+        setDebugData(prev => ({
+          ...prev,
+          response: responseData + `\n\nError: ${errorInfo}`
+        }));
+        setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+        streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
+        completeMessage(MESSAGE_STATUS.ERROR);
+      }
+    });
+
+    source.addEventListener('error', (e) => {
+      console.error('SSE Error:', e);
+      const errorMessage = e.data || t('请求发生错误');
+
+      const errorInfo = handleApiError(new Error(errorMessage));
+      errorInfo.readyState = source.readyState;
+
+      setDebugData(prev => ({
+        ...prev,
+        response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
+      }));
+      setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+      streamMessageUpdate(errorMessage, 'content');
+      completeMessage(MESSAGE_STATUS.ERROR);
+      sseSourceRef.current = null;
+      source.close();
+    });
+
+    source.addEventListener('readystatechange', (e) => {
+      if (e.readyState >= 2 && source.status !== undefined && source.status !== 200) {
+        const errorInfo = handleApiError(new Error('HTTP状态错误'));
+        errorInfo.status = source.status;
+        errorInfo.readyState = source.readyState;
+
+        setDebugData(prev => ({
+          ...prev,
+          response: responseData + '\n\nHTTP Error:\n' + JSON.stringify(errorInfo, null, 2)
+        }));
+        setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+        source.close();
+        streamMessageUpdate(t('连接已断开'), 'content');
+        completeMessage(MESSAGE_STATUS.ERROR);
+      }
+    });
+
+    try {
+      source.stream();
+    } catch (error) {
+      console.error('Failed to start SSE stream:', error);
+      const errorInfo = handleApiError(error);
+
+      setDebugData(prev => ({
+        ...prev,
+        response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2)
+      }));
+      setActiveDebugTab(DEBUG_TABS.RESPONSE);
+
+      streamMessageUpdate(t('建立连接时发生错误'), 'content');
+      completeMessage(MESSAGE_STATUS.ERROR);
+    }
+  }, [setDebugData, setActiveDebugTab, streamMessageUpdate, completeMessage, t, applyAutoCollapseLogic]);
+
+  // 停止生成
+  const onStopGenerator = useCallback(() => {
+    // 如果仍有活动的 SSE 连接,首先关闭
+    if (sseSourceRef.current) {
+      sseSourceRef.current.close();
+      sseSourceRef.current = null;
+    }
+
+    // 无论是否存在 SSE 连接,都尝试处理最后一条正在生成的消息
+    setMessage(prevMessage => {
+      if (prevMessage.length === 0) return prevMessage;
+      const lastMessage = prevMessage[prevMessage.length - 1];
+
+      if (lastMessage.status === MESSAGE_STATUS.LOADING ||
+        lastMessage.status === MESSAGE_STATUS.INCOMPLETE) {
+
+        const processed = processIncompleteThinkTags(
+          lastMessage.content || '',
+          lastMessage.reasoningContent || ''
+        );
+
+        const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
+
+        const updatedMessages = [
+          ...prevMessage.slice(0, -1),
+          {
+            ...lastMessage,
+            status: MESSAGE_STATUS.COMPLETE,
+            reasoningContent: processed.reasoningContent || null,
+            content: processed.content,
+            ...autoCollapseState,
+          }
+        ];
+
+        // 停止生成时也保存,传入更新后的消息列表
+        setTimeout(() => saveMessages(updatedMessages), 0);
+
+        return updatedMessages;
+      }
+      return prevMessage;
+    });
+  }, [setMessage, applyAutoCollapseLogic, saveMessages]);
+
+  // 发送请求
+  const sendRequest = useCallback((payload, isStream) => {
+    if (isStream) {
+      handleSSE(payload);
+    } else {
+      handleNonStreamRequest(payload);
+    }
+  }, [handleSSE, handleNonStreamRequest]);
+
+  return {
+    sendRequest,
+    onStopGenerator,
+    streamMessageUpdate,
+    completeMessage,
+  };
+}; 

+ 69 - 0
web/src/hooks/useDataLoader.js

@@ -0,0 +1,69 @@
+import { useCallback, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { API, processModelsData, processGroupsData } from '../helpers';
+import { API_ENDPOINTS } from '../constants/playground.constants';
+
+export const useDataLoader = (
+  userState,
+  inputs,
+  handleInputChange,
+  setModels,
+  setGroups
+) => {
+  const { t } = useTranslation();
+
+  const loadModels = useCallback(async () => {
+    try {
+      const res = await API.get(API_ENDPOINTS.USER_MODELS);
+      const { success, message, data } = res.data;
+
+      if (success) {
+        const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
+        setModels(modelOptions);
+
+        if (selectedModel !== inputs.model) {
+          handleInputChange('model', selectedModel);
+        }
+      } else {
+        showError(t(message));
+      }
+    } catch (error) {
+      showError(t('加载模型失败'));
+    }
+  }, [inputs.model, handleInputChange, setModels, t]);
+
+  const loadGroups = useCallback(async () => {
+    try {
+      const res = await API.get(API_ENDPOINTS.USER_GROUPS);
+      const { success, message, data } = res.data;
+
+      if (success) {
+        const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
+        const groupOptions = processGroupsData(data, userGroup);
+        setGroups(groupOptions);
+
+        const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
+        if (!hasCurrentGroup) {
+          handleInputChange('group', groupOptions[0]?.value || '');
+        }
+      } else {
+        showError(t(message));
+      }
+    } catch (error) {
+      showError(t('加载分组失败'));
+    }
+  }, [userState, inputs.group, handleInputChange, setGroups, t]);
+
+  // 自动加载数据
+  useEffect(() => {
+    if (userState?.user) {
+      loadModels();
+      loadGroups();
+    }
+  }, [userState?.user, loadModels, loadGroups]);
+
+  return {
+    loadModels,
+    loadGroups
+  };
+}; 

+ 223 - 0
web/src/hooks/useMessageActions.js

@@ -0,0 +1,223 @@
+import { useCallback } from 'react';
+import { Toast, Modal } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { getTextContent } from '../helpers';
+import { ERROR_MESSAGES } from '../constants/playground.constants';
+
+export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => {
+  const { t } = useTranslation();
+
+  // 复制消息
+  const handleMessageCopy = useCallback((targetMessage) => {
+    const textToCopy = getTextContent(targetMessage);
+
+    if (!textToCopy) {
+      Toast.warning({
+        content: t(ERROR_MESSAGES.NO_TEXT_CONTENT),
+        duration: 2,
+      });
+      return;
+    }
+
+    const copyToClipboard = async (text) => {
+      if (navigator.clipboard?.writeText) {
+        try {
+          await navigator.clipboard.writeText(text);
+          Toast.success({
+            content: t('消息已复制到剪贴板'),
+            duration: 2,
+          });
+        } catch (err) {
+          console.error('Clipboard API 复制失败:', err);
+          fallbackCopy(text);
+        }
+      } else {
+        fallbackCopy(text);
+      }
+    };
+
+    const fallbackCopy = (text) => {
+      try {
+        const textArea = document.createElement('textarea');
+        textArea.value = text;
+        textArea.style.cssText = `
+          position: fixed;
+          top: -9999px;
+          left: -9999px;
+          opacity: 0;
+          pointer-events: none;
+          z-index: -1;
+        `;
+        textArea.setAttribute('readonly', '');
+
+        document.body.appendChild(textArea);
+        textArea.select();
+        textArea.setSelectionRange(0, text.length);
+
+        const successful = document.execCommand('copy');
+        document.body.removeChild(textArea);
+
+        if (successful) {
+          Toast.success({
+            content: t('消息已复制到剪贴板'),
+            duration: 2,
+          });
+        } else {
+          throw new Error('execCommand copy failed');
+        }
+      } catch (err) {
+        console.error('回退复制方案也失败:', err);
+
+        let errorMessage = t(ERROR_MESSAGES.COPY_FAILED);
+        if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') {
+          errorMessage = t(ERROR_MESSAGES.COPY_HTTPS_REQUIRED);
+        } else if (!navigator.clipboard && !document.execCommand) {
+          errorMessage = t(ERROR_MESSAGES.BROWSER_NOT_SUPPORTED);
+        }
+
+        Toast.error({
+          content: errorMessage,
+          duration: 4,
+        });
+      }
+    };
+
+    copyToClipboard(textToCopy);
+  }, [t]);
+
+  // 重新生成消息
+  const handleMessageReset = useCallback((targetMessage) => {
+    setMessage(prevMessages => {
+      // 使用引用查找索引,防止重复 id 造成误匹配
+      let messageIndex = prevMessages.findIndex(msg => msg === targetMessage);
+
+      // 回退到 id 匹配(兼容不同引用场景)
+      if (messageIndex === -1) {
+        messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id);
+      }
+
+      if (messageIndex === -1) return prevMessages;
+
+      if (targetMessage.role === 'user') {
+        const newMessages = prevMessages.slice(0, messageIndex);
+        const contentToSend = getTextContent(targetMessage);
+
+        setTimeout(() => {
+          onMessageSend(contentToSend);
+        }, 100);
+
+        return newMessages;
+      } else if (targetMessage.role === 'assistant' || targetMessage.role === 'system') {
+        let userMessageIndex = messageIndex - 1;
+        while (userMessageIndex >= 0 && prevMessages[userMessageIndex].role !== 'user') {
+          userMessageIndex--;
+        }
+
+        if (userMessageIndex >= 0) {
+          const userMessage = prevMessages[userMessageIndex];
+          const newMessages = prevMessages.slice(0, userMessageIndex);
+          const contentToSend = getTextContent(userMessage);
+
+          setTimeout(() => {
+            onMessageSend(contentToSend);
+          }, 100);
+
+          return newMessages;
+        }
+      }
+
+      return prevMessages;
+    });
+  }, [setMessage, onMessageSend]);
+
+  // 删除消息
+  const handleMessageDelete = useCallback((targetMessage) => {
+    Modal.confirm({
+      title: t('确认删除'),
+      content: t('确定要删除这条消息吗?'),
+      okText: t('确定'),
+      cancelText: t('取消'),
+      okButtonProps: {
+        type: 'danger',
+      },
+      onOk: () => {
+        setMessage(prevMessages => {
+          // 使用引用查找索引,防止重复 id 造成误匹配
+          let messageIndex = prevMessages.findIndex(msg => msg === targetMessage);
+
+          // 回退到 id 匹配(兼容不同引用场景)
+          if (messageIndex === -1) {
+            messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id);
+          }
+
+          if (messageIndex === -1) return prevMessages;
+
+          let updatedMessages;
+          if (targetMessage.role === 'user' && messageIndex < prevMessages.length - 1) {
+            const nextMessage = prevMessages[messageIndex + 1];
+            if (nextMessage.role === 'assistant') {
+              Toast.success({
+                content: t('已删除消息及其回复'),
+                duration: 2,
+              });
+              updatedMessages = prevMessages.filter((_, index) =>
+                index !== messageIndex && index !== messageIndex + 1
+              );
+            } else {
+              Toast.success({
+                content: t('消息已删除'),
+                duration: 2,
+              });
+              updatedMessages = prevMessages.filter(msg => msg.id !== targetMessage.id);
+            }
+          } else {
+            Toast.success({
+              content: t('消息已删除'),
+              duration: 2,
+            });
+            updatedMessages = prevMessages.filter(msg => msg.id !== targetMessage.id);
+          }
+
+          // 删除消息后保存,传入更新后的消息列表
+          setTimeout(() => saveMessages(updatedMessages), 0);
+          return updatedMessages;
+        });
+      },
+    });
+  }, [setMessage, t, saveMessages]);
+
+  // 切换角色
+  const handleRoleToggle = useCallback((targetMessage) => {
+    if (!(targetMessage.role === 'assistant' || targetMessage.role === 'system')) {
+      return;
+    }
+
+    const newRole = targetMessage.role === 'assistant' ? 'system' : 'assistant';
+
+    setMessage(prevMessages => {
+      const updatedMessages = prevMessages.map(msg => {
+        if (msg.id === targetMessage.id &&
+          (msg.role === 'assistant' || msg.role === 'system')) {
+          return { ...msg, role: newRole };
+        }
+        return msg;
+      });
+
+      // 切换角色后保存,传入更新后的消息列表
+      setTimeout(() => saveMessages(updatedMessages), 0);
+      return updatedMessages;
+    });
+
+    Toast.success({
+      content: t(`已切换为${newRole === 'system' ? 'System' : 'Assistant'}角色`),
+      duration: 2,
+    });
+  }, [setMessage, t, saveMessages]);
+
+  return {
+    handleMessageCopy,
+    handleMessageReset,
+    handleMessageDelete,
+    handleRoleToggle,
+  };
+}; 

+ 109 - 0
web/src/hooks/useMessageEdit.js

@@ -0,0 +1,109 @@
+import { useCallback, useState, useRef } from 'react';
+import { Toast, Modal } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers';
+import { MESSAGE_ROLES } from '../constants/playground.constants';
+
+export const useMessageEdit = (
+  setMessage,
+  inputs,
+  parameterEnabled,
+  sendRequest,
+  saveMessages
+) => {
+  const { t } = useTranslation();
+  const [editingMessageId, setEditingMessageId] = useState(null);
+  const [editValue, setEditValue] = useState('');
+  const editingMessageRef = useRef(null);
+
+  const handleMessageEdit = useCallback((targetMessage) => {
+    const editableContent = getTextContent(targetMessage);
+    setEditingMessageId(targetMessage.id);
+    editingMessageRef.current = targetMessage;
+    setEditValue(editableContent);
+  }, []);
+
+  const handleEditSave = useCallback(() => {
+    if (!editingMessageId || !editValue.trim()) return;
+
+    setMessage(prevMessages => {
+      let messageIndex = prevMessages.findIndex(msg => msg === editingMessageRef.current);
+
+      if (messageIndex === -1) {
+        messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
+      }
+
+      const targetMessage = prevMessages[messageIndex];
+      let newContent;
+
+      if (Array.isArray(targetMessage.content)) {
+        newContent = targetMessage.content.map(item =>
+          item.type === 'text' ? { ...item, text: editValue.trim() } : item
+        );
+      } else {
+        newContent = editValue.trim();
+      }
+
+      const updatedMessages = prevMessages.map(msg =>
+        msg.id === editingMessageId ? { ...msg, content: newContent } : msg
+      );
+
+      // 处理用户消息编辑后的重新生成
+      if (targetMessage.role === MESSAGE_ROLES.USER) {
+        const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
+          prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
+
+        if (hasSubsequentAssistantReply) {
+          Modal.confirm({
+            title: t('消息已编辑'),
+            content: t('检测到该消息后有AI回复,是否删除后续回复并重新生成?'),
+            okText: t('重新生成'),
+            cancelText: t('仅保存'),
+            onOk: () => {
+              const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
+              setMessage(messagesUntilUser);
+              // 编辑后保存(重新生成的情况),传入更新后的消息列表
+              setTimeout(() => saveMessages(messagesUntilUser), 0);
+
+              setTimeout(() => {
+                const payload = buildApiPayload(messagesUntilUser, null, inputs, parameterEnabled);
+                setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
+                sendRequest(payload, inputs.stream);
+              }, 100);
+            },
+            onCancel: () => {
+              setMessage(updatedMessages);
+              // 编辑后保存(仅保存的情况),传入更新后的消息列表
+              setTimeout(() => saveMessages(updatedMessages), 0);
+            }
+          });
+          return prevMessages;
+        }
+      }
+
+      // 编辑后保存(普通情况),传入更新后的消息列表
+      setTimeout(() => saveMessages(updatedMessages), 0);
+      return updatedMessages;
+    });
+
+    setEditingMessageId(null);
+    editingMessageRef.current = null;
+    setEditValue('');
+    Toast.success({ content: t('消息已更新'), duration: 2 });
+  }, [editingMessageId, editValue, t, inputs, parameterEnabled, sendRequest, setMessage, saveMessages]);
+
+  const handleEditCancel = useCallback(() => {
+    setEditingMessageId(null);
+    editingMessageRef.current = null;
+    setEditValue('');
+  }, []);
+
+  return {
+    editingMessageId,
+    editValue,
+    setEditValue,
+    handleMessageEdit,
+    handleEditSave,
+    handleEditCancel
+  };
+}; 

+ 225 - 0
web/src/hooks/usePlaygroundState.js

@@ -0,0 +1,225 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants';
+import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
+import { processIncompleteThinkTags } from '../helpers';
+
+export const usePlaygroundState = () => {
+  // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
+  const [savedConfig] = useState(() => loadConfig());
+  const [initialMessages] = useState(() => loadMessages() || DEFAULT_MESSAGES);
+
+  // 基础配置状态
+  const [inputs, setInputs] = useState(savedConfig.inputs || DEFAULT_CONFIG.inputs);
+  const [parameterEnabled, setParameterEnabled] = useState(
+    savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled
+  );
+  const [showDebugPanel, setShowDebugPanel] = useState(
+    savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel
+  );
+  const [customRequestMode, setCustomRequestMode] = useState(
+    savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode
+  );
+  const [customRequestBody, setCustomRequestBody] = useState(
+    savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody
+  );
+
+  // UI状态
+  const [showSettings, setShowSettings] = useState(false);
+  const [models, setModels] = useState([]);
+  const [groups, setGroups] = useState([]);
+  const [status, setStatus] = useState({});
+
+  // 消息相关状态 - 使用加载的消息初始化
+  const [message, setMessage] = useState(initialMessages);
+
+  // 调试状态
+  const [debugData, setDebugData] = useState({
+    request: null,
+    response: null,
+    timestamp: null,
+    previewRequest: null,
+    previewTimestamp: null
+  });
+  const [activeDebugTab, setActiveDebugTab] = useState(DEBUG_TABS.PREVIEW);
+  const [previewPayload, setPreviewPayload] = useState(null);
+
+  // 编辑状态
+  const [editingMessageId, setEditingMessageId] = useState(null);
+  const [editValue, setEditValue] = useState('');
+
+  // Refs
+  const sseSourceRef = useRef(null);
+  const chatRef = useRef(null);
+  const saveConfigTimeoutRef = useRef(null);
+  const saveMessagesTimeoutRef = useRef(null);
+
+  // 配置更新函数
+  const handleInputChange = useCallback((name, value) => {
+    setInputs(prev => ({ ...prev, [name]: value }));
+  }, []);
+
+  const handleParameterToggle = useCallback((paramName) => {
+    setParameterEnabled(prev => ({
+      ...prev,
+      [paramName]: !prev[paramName]
+    }));
+  }, []);
+
+  // 消息保存函数 - 改为立即保存,可以接受参数
+  const saveMessagesImmediately = useCallback((messagesToSave) => {
+    // 如果提供了参数,使用参数;否则使用当前状态
+    saveMessages(messagesToSave || message);
+  }, [message]);
+
+  // 配置保存
+  const debouncedSaveConfig = useCallback(() => {
+    if (saveConfigTimeoutRef.current) {
+      clearTimeout(saveConfigTimeoutRef.current);
+    }
+
+    saveConfigTimeoutRef.current = setTimeout(() => {
+      const configToSave = {
+        inputs,
+        parameterEnabled,
+        showDebugPanel,
+        customRequestMode,
+        customRequestBody,
+      };
+      saveConfig(configToSave);
+    }, 1000);
+  }, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody]);
+
+  // 配置导入/重置
+  const handleConfigImport = useCallback((importedConfig) => {
+    if (importedConfig.inputs) {
+      setInputs(prev => ({ ...prev, ...importedConfig.inputs }));
+    }
+    if (importedConfig.parameterEnabled) {
+      setParameterEnabled(prev => ({ ...prev, ...importedConfig.parameterEnabled }));
+    }
+    if (typeof importedConfig.showDebugPanel === 'boolean') {
+      setShowDebugPanel(importedConfig.showDebugPanel);
+    }
+    if (importedConfig.customRequestMode) {
+      setCustomRequestMode(importedConfig.customRequestMode);
+    }
+    if (importedConfig.customRequestBody) {
+      setCustomRequestBody(importedConfig.customRequestBody);
+    }
+    // 如果导入的配置包含消息,也恢复消息
+    if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
+      setMessage(importedConfig.messages);
+    }
+  }, []);
+
+  const handleConfigReset = useCallback((options = {}) => {
+    const { resetMessages = false } = options;
+
+    setInputs(DEFAULT_CONFIG.inputs);
+    setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
+    setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
+    setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
+    setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
+
+    // 只有在明确指定时才重置消息
+    if (resetMessages) {
+      setMessage([]);
+      setTimeout(() => {
+        setMessage(DEFAULT_MESSAGES);
+      }, 0);
+    }
+  }, []);
+
+  // 清理定时器
+  useEffect(() => {
+    return () => {
+      if (saveConfigTimeoutRef.current) {
+        clearTimeout(saveConfigTimeoutRef.current);
+      }
+    };
+  }, []);
+
+  // 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复
+  useEffect(() => {
+    if (!Array.isArray(message) || message.length === 0) return;
+
+    const lastMsg = message[message.length - 1];
+    if (lastMsg.status === MESSAGE_STATUS.LOADING || lastMsg.status === MESSAGE_STATUS.INCOMPLETE) {
+      const processed = processIncompleteThinkTags(
+        lastMsg.content || '',
+        lastMsg.reasoningContent || ''
+      );
+
+      const fixedLastMsg = {
+        ...lastMsg,
+        status: MESSAGE_STATUS.COMPLETE,
+        content: processed.content,
+        reasoningContent: processed.reasoningContent || null,
+        isThinkingComplete: true,
+      };
+
+      const updatedMessages = [...message.slice(0, -1), fixedLastMsg];
+      setMessage(updatedMessages);
+
+      // 保存修复后的消息列表
+      setTimeout(() => saveMessagesImmediately(updatedMessages), 0);
+    }
+  }, []);
+
+  return {
+    // 配置状态
+    inputs,
+    parameterEnabled,
+    showDebugPanel,
+    customRequestMode,
+    customRequestBody,
+
+    // UI状态
+    showSettings,
+    models,
+    groups,
+    status,
+
+    // 消息状态
+    message,
+
+    // 调试状态
+    debugData,
+    activeDebugTab,
+    previewPayload,
+
+    // 编辑状态
+    editingMessageId,
+    editValue,
+
+    // Refs
+    sseSourceRef,
+    chatRef,
+    saveConfigTimeoutRef,
+
+    // 更新函数
+    setInputs,
+    setParameterEnabled,
+    setShowDebugPanel,
+    setCustomRequestMode,
+    setCustomRequestBody,
+    setShowSettings,
+    setModels,
+    setGroups,
+    setStatus,
+    setMessage,
+    setDebugData,
+    setActiveDebugTab,
+    setPreviewPayload,
+    setEditingMessageId,
+    setEditValue,
+
+    // 处理函数
+    handleInputChange,
+    handleParameterToggle,
+    debouncedSaveConfig,
+    saveMessagesImmediately,
+    handleConfigImport,
+    handleConfigReset,
+  };
+}; 

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است