Kaynağa Gözat

Merge branch 'main' into feat-04

neotf 6 ay önce
ebeveyn
işleme
52763c09f2
100 değiştirilmiş dosya ile 7101 ekleme ve 6430 silme
  1. 14 6
      .github/workflows/docker-image-alpha.yml
  2. 1 6
      .github/workflows/docker-image-arm64.yml
  3. 9 4
      .github/workflows/linux-release.yml
  4. 9 4
      .github/workflows/macos-release.yml
  5. 9 4
      .github/workflows/windows-release.yml
  6. 3 3
      common/redis.go
  7. 10 5
      controller/channel-test.go
  8. 41 0
      controller/channel.go
  9. 10 2
      controller/misc.go
  10. 27 1
      controller/option.go
  11. 1 1
      controller/topup.go
  12. 169 0
      controller/uptime_kuma.go
  13. 67 36
      dto/claude.go
  14. 229 20
      dto/openai_request.go
  15. 3 3
      go.mod
  16. 4 4
      go.sum
  17. 1 1
      makefile
  18. 5 0
      middleware/auth.go
  19. 41 0
      middleware/stats.go
  20. 3 0
      model/option.go
  21. 1 1
      model/token_cache.go
  22. 3 2
      model/user_cache.go
  23. 5 1
      relay/channel/ali/adaptor.go
  24. 1 0
      relay/channel/ali/constants.go
  25. 27 0
      relay/channel/ali/dto.go
  26. 83 0
      relay/channel/ali/rerank.go
  27. 10 9
      relay/channel/ali/text.go
  28. 63 15
      relay/channel/api_request.go
  29. 1 2
      relay/channel/baidu/relay-baidu.go
  30. 5 8
      relay/channel/claude/relay-claude.go
  31. 1 2
      relay/channel/cohere/relay-cohere.go
  32. 1 1
      relay/channel/coze/dto.go
  33. 1 2
      relay/channel/dify/relay-dify.go
  34. 65 11
      relay/channel/gemini/dto.go
  35. 20 3
      relay/channel/gemini/relay-gemini-native.go
  36. 93 37
      relay/channel/gemini/relay-gemini.go
  37. 42 0
      relay/channel/mistral/text.go
  38. 1 2
      relay/channel/palm/relay-palm.go
  39. 1 2
      relay/channel/tencent/relay-tencent.go
  40. 1 2
      relay/channel/xunfei/relay-xunfei.go
  41. 1 2
      relay/channel/zhipu/relay-zhipu.go
  42. 128 25
      relay/helper/stream_scanner.go
  43. 38 9
      relay/relay-text.go
  44. 2 0
      router/api-router.go
  45. 1 0
      router/relay-router.go
  46. 17 0
      service/audio.go
  47. 49 58
      service/token_counter.go
  48. 327 0
      setting/console.go
  49. 18 0
      setting/operation_setting/tools.go
  50. BIN
      web/bun.lockb
  51. 23 4
      web/package.json
  52. 6 0
      web/postcss.config.js
  53. BIN
      web/public/favicon.ico
  54. BIN
      web/public/logo.png
  55. 34 31
      web/src/App.js
  56. 0 76
      web/src/components/Footer.js
  57. 0 494
      web/src/components/HeaderBar.js
  58. 0 12
      web/src/components/Loading.js
  59. 0 385
      web/src/components/LoginForm.js
  60. 0 660
      web/src/components/MjLogsTable.js
  61. 0 433
      web/src/components/ModelPricing.js
  62. 0 113
      web/src/components/PasswordResetConfirm.js
  63. 0 102
      web/src/components/PasswordResetForm.js
  64. 0 1193
      web/src/components/PersonalSetting.js
  65. 0 12
      web/src/components/PrivateRoute.js
  66. 0 449
      web/src/components/RedemptionsTable.js
  67. 0 434
      web/src/components/RegisterForm.js
  68. 0 535
      web/src/components/SiderBar.js
  69. 0 512
      web/src/components/TaskLogsTable.js
  70. 0 515
      web/src/components/UsersTable.js
  71. 524 0
      web/src/components/auth/LoginForm.js
  72. 14 20
      web/src/components/auth/OAuth2Callback.js
  73. 172 0
      web/src/components/auth/PasswordResetConfirm.js
  74. 147 0
      web/src/components/auth/PasswordResetForm.js
  75. 566 0
      web/src/components/auth/RegisterForm.js
  76. 24 0
      web/src/components/common/Loading.js
  77. 0 0
      web/src/components/common/logo/LinuxDoIcon.js
  78. 2 2
      web/src/components/common/logo/OIDCIcon.js
  79. 2 2
      web/src/components/common/logo/WeChatIcon.js
  80. 513 0
      web/src/components/common/markdown/MarkdownRenderer.js
  81. 444 0
      web/src/components/common/markdown/markdown.css
  82. 0 28
      web/src/components/custom/TextInput.js
  83. 0 21
      web/src/components/custom/TextNumberInput.js
  84. 0 68
      web/src/components/fetchTokenKeys.js
  85. 112 0
      web/src/components/layout/Footer.js
  86. 536 0
      web/src/components/layout/HeaderBar.js
  87. 94 0
      web/src/components/layout/NoticeModal.js
  88. 32 34
      web/src/components/layout/PageLayout.js
  89. 1 1
      web/src/components/layout/SetupCheck.js
  90. 448 0
      web/src/components/layout/SiderBar.js
  91. 113 0
      web/src/components/playground/ChatArea.js
  92. 313 0
      web/src/components/playground/CodeViewer.js
  93. 260 0
      web/src/components/playground/ConfigManager.js
  94. 58 0
      web/src/components/playground/CustomInputRender.js
  95. 190 0
      web/src/components/playground/CustomRequestEditor.js
  96. 193 0
      web/src/components/playground/DebugPanel.js
  97. 71 0
      web/src/components/playground/FloatingButtons.js
  98. 113 0
      web/src/components/playground/ImageUrlInput.js
  99. 121 0
      web/src/components/playground/MessageActions.js
  100. 313 0
      web/src/components/playground/MessageContent.js

+ 14 - 6
.github/workflows/docker-image-amd64.yml → .github/workflows/docker-image-alpha.yml

@@ -1,14 +1,15 @@
-name: Publish Docker image (amd64)
+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,7 +23,7 @@ jobs:
 
       - name: Save version info
         run: |
-          git describe --tags > VERSION 
+          echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
 
       - name: Log in to Docker Hub
         uses: docker/login-action@v3
@@ -37,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
@@ -44,11 +48,15 @@ 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
         with:
           context: .
+          platforms: linux/amd64,linux/arm64
           push: true
           tags: ${{ steps.meta.outputs.tags }}
-          labels: ${{ steps.meta.outputs.labels }}
+          labels: ${{ steps.meta.outputs.labels }}

+ 1 - 6
.github/workflows/docker-image-arm64.yml

@@ -1,14 +1,9 @@
-name: Publish Docker image (arm64)
+name: Publish Docker image (Multi Registries)
 
 on:
   push:
     tags:
       - '*'
-  workflow_dispatch:
-    inputs:
-      name:
-        description: 'reason'
-        required: false
 jobs:
   push_to_registries:
     name: Push Docker image to multiple registries

+ 9 - 4
.github/workflows/linux-release.yml

@@ -3,6 +3,11 @@ permissions:
   contents: write
 
 on:
+  workflow_dispatch:
+    inputs:
+      name:
+        description: 'reason'
+        required: false
   push:
     tags:
       - '*'
@@ -15,16 +20,16 @@ jobs:
         uses: actions/checkout@v3
         with:
           fetch-depth: 0
-      - uses: actions/setup-node@v3
+      - uses: oven-sh/setup-bun@v2
         with:
-          node-version: 18
+          bun-version: latest
       - name: Build Frontend
         env:
           CI: ""
         run: |
           cd web
-          npm install
-          REACT_APP_VERSION=$(git describe --tags) npm run build
+          bun install
+          DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
           cd ..
       - name: Set up Go
         uses: actions/setup-go@v3

+ 9 - 4
.github/workflows/macos-release.yml

@@ -3,6 +3,11 @@ permissions:
   contents: write
 
 on:
+  workflow_dispatch:
+    inputs:
+      name:
+        description: 'reason'
+        required: false
   push:
     tags:
       - '*'
@@ -15,16 +20,16 @@ jobs:
         uses: actions/checkout@v3
         with:
           fetch-depth: 0
-      - uses: actions/setup-node@v3
+      - uses: oven-sh/setup-bun@v2
         with:
-          node-version: 18
+          bun-version: latest
       - name: Build Frontend
         env:
           CI: ""
         run: |
           cd web
-          npm install
-          REACT_APP_VERSION=$(git describe --tags) npm run build
+          bun install
+          DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
           cd ..
       - name: Set up Go
         uses: actions/setup-go@v3

+ 9 - 4
.github/workflows/windows-release.yml

@@ -3,6 +3,11 @@ permissions:
   contents: write
 
 on:
+  workflow_dispatch:
+    inputs:
+      name:
+        description: 'reason'
+        required: false
   push:
     tags:
       - '*'
@@ -18,16 +23,16 @@ jobs:
         uses: actions/checkout@v3
         with:
           fetch-depth: 0
-      - uses: actions/setup-node@v3
+      - uses: oven-sh/setup-bun@v2
         with:
-          node-version: 18
+          bun-version: latest
       - name: Build Frontend
         env:
           CI: ""
         run: |
           cd web
-          npm install
-          REACT_APP_VERSION=$(git describe --tags) npm run build
+          bun install
+          DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
           cd ..
       - name: Set up Go
         uses: actions/setup-go@v3

+ 3 - 3
common/redis.go

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

+ 10 - 5
controller/channel-test.go

@@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
 	} else {
 		testRequest.MaxTokens = 10
 	}
-	content, _ := json.Marshal("hi")
+
 	testMessage := dto.Message{
 		Role:    "user",
-		Content: content,
+		Content: "hi",
 	}
 	testRequest.Model = model
 	testRequest.Messages = append(testRequest.Messages, testMessage)
@@ -271,6 +271,13 @@ func testAllChannels(notify bool) error {
 		disableThreshold = 10000000 // a impossible value
 	}
 	gopool.Go(func() {
+		// 使用 defer 确保无论如何都会重置运行状态,防止死锁
+		defer func() {
+			testAllChannelsLock.Lock()
+			testAllChannelsRunning = false
+			testAllChannelsLock.Unlock()
+		}()
+
 		for _, channel := range channels {
 			isChannelEnabled := channel.Status == common.ChannelStatusEnabled
 			tik := time.Now()
@@ -305,9 +312,7 @@ func testAllChannels(notify bool) error {
 			channel.UpdateResponseTime(milliseconds)
 			time.Sleep(common.RequestInterval)
 		}
-		testAllChannelsLock.Lock()
-		testAllChannelsRunning = false
-		testAllChannelsLock.Unlock()
+		
 		if notify {
 			service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
 		}

+ 41 - 0
controller/channel.go

@@ -623,3 +623,44 @@ func BatchSetChannelTag(c *gin.Context) {
 	})
 	return
 }
+
+func GetTagModels(c *gin.Context) {
+	tag := c.Query("tag")
+	if tag == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "tag不能为空",
+		})
+		return
+	}
+
+	channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	var longestModels string
+	maxLength := 0
+
+	// Find the longest models string among all channels with the given tag
+	for _, channel := range channels {
+		if channel.Models != "" {
+			currentModels := strings.Split(channel.Models, ",")
+			if len(currentModels) > maxLength {
+				maxLength = len(currentModels)
+				longestModels = channel.Models
+			}
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    longestModels,
+	})
+	return
+}

+ 10 - 2
controller/misc.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"one-api/common"
 	"one-api/constant"
+	"one-api/middleware"
 	"one-api/model"
 	"one-api/setting"
 	"one-api/setting/operation_setting"
@@ -24,14 +25,18 @@ func TestStatus(c *gin.Context) {
 		})
 		return
 	}
+	// 获取HTTP统计信息
+	httpStats := middleware.GetStats()
 	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "Server is running",
+		"success":    true,
+		"message":    "Server is running",
+		"http_stats": httpStats,
 	})
 	return
 }
 
 func GetStatus(c *gin.Context) {
+
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -74,6 +79,9 @@ func GetStatus(c *gin.Context) {
 			"oidc_client_id":              system_setting.GetOIDCSettings().ClientId,
 			"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
 			"setup":                       constant.Setup,
+			"api_info":                    setting.GetApiInfo(),
+			"announcements":               setting.GetAnnouncements(),
+			"faq":                         setting.GetFAQ(),
 		},
 	})
 	return

+ 27 - 1
controller/option.go

@@ -119,7 +119,33 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
-
+	case "ApiInfo":
+		err = setting.ValidateApiInfo(option.Value)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	case "Announcements":
+		err = setting.ValidateConsoleSettings(option.Value, "Announcements")
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	case "FAQ":
+		err = setting.ValidateConsoleSettings(option.Value, "FAQ")
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
 	}
 	err = model.UpdateOption(option.Key, option.Value)
 	if err != nil {

+ 1 - 1
controller/topup.go

@@ -106,7 +106,7 @@ func RequestEpay(c *gin.Context) {
 		payType = "wxpay"
 	}
 	callBackAddress := service.GetCallbackAddress()
-	returnUrl, _ := url.Parse(setting.ServerAddress + "/log")
+	returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
 	notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
 	tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
 	tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)

+ 169 - 0
controller/uptime_kuma.go

@@ -0,0 +1,169 @@
+package controller
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"one-api/common"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"golang.org/x/sync/errgroup"
+)
+
+type UptimeKumaMonitor struct {
+	ID   int    `json:"id"`
+	Name string `json:"name"`
+	Type string `json:"type"`
+}
+
+type UptimeKumaGroup struct {
+	ID          int                  `json:"id"`
+	Name        string               `json:"name"`
+	Weight      int                  `json:"weight"`
+	MonitorList []UptimeKumaMonitor  `json:"monitorList"`
+}
+
+type UptimeKumaHeartbeat struct {
+	Status int      `json:"status"`
+	Time   string   `json:"time"`
+	Msg    string   `json:"msg"`
+	Ping   *float64 `json:"ping"`
+}
+
+type UptimeKumaStatusResponse struct {
+	PublicGroupList []UptimeKumaGroup `json:"publicGroupList"`
+}
+
+type UptimeKumaHeartbeatResponse struct {
+	HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"`
+	UptimeList    map[string]float64               `json:"uptimeList"`
+}
+
+type MonitorStatus struct {
+	Name   string  `json:"name"`
+	Uptime float64 `json:"uptime"`
+	Status int     `json:"status"`
+}
+
+var (
+	ErrUpstreamNon200 = errors.New("upstream non-200")
+	ErrTimeout        = errors.New("context deadline exceeded")
+)
+
+func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return err
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+			return ErrTimeout
+		}
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return ErrUpstreamNon200
+	}
+
+	return json.NewDecoder(resp.Body).Decode(dest)
+}
+
+func GetUptimeKumaStatus(c *gin.Context) {
+	common.OptionMapRWMutex.RLock()
+	uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"]
+	slug := common.OptionMap["UptimeKumaSlug"]
+	common.OptionMapRWMutex.RUnlock()
+
+	if uptimeKumaUrl == "" || slug == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "",
+			"data":    []MonitorStatus{},
+		})
+		return
+	}
+
+	uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+	defer cancel()
+
+	client := &http.Client{}
+
+	statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
+	heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
+
+	var (
+		statusData    UptimeKumaStatusResponse
+		heartbeatData UptimeKumaHeartbeatResponse
+	)
+
+	g, gCtx := errgroup.WithContext(ctx)
+
+	g.Go(func() error {
+		return getAndDecode(gCtx, client, statusPageUrl, &statusData)
+	})
+
+	g.Go(func() error {
+		return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
+	})
+
+	if err := g.Wait(); err != nil {
+		switch err {
+		case ErrUpstreamNon200:
+			c.JSON(http.StatusBadRequest, gin.H{
+				"success": false,
+				"message": "上游接口出现问题",
+			})
+		case ErrTimeout:
+			c.JSON(http.StatusRequestTimeout, gin.H{
+				"success": false,
+				"message": "请求上游接口超时",
+			})
+		default:
+			c.JSON(http.StatusBadRequest, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+		}
+		return
+	}
+
+	var monitors []MonitorStatus
+	for _, group := range statusData.PublicGroupList {
+		for _, monitor := range group.MonitorList {
+			monitorStatus := MonitorStatus{
+				Name:   monitor.Name,
+				Uptime: 0.0,
+				Status: 0,
+			}
+
+			uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
+			if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
+				monitorStatus.Uptime = uptime
+			}
+
+			heartbeatKey := fmt.Sprintf("%d", monitor.ID)
+			if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
+				latestHeartbeat := heartbeats[0]
+				monitorStatus.Status = latestHeartbeat.Status
+			}
+
+			monitors = append(monitors, monitorStatus)
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    monitors,
+	})
+} 

+ 67 - 36
dto/claude.go

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

+ 229 - 20
dto/openai_request.go

@@ -37,11 +37,11 @@ type GeneralOpenAIRequest struct {
 	Input               any               `json:"input,omitempty"`
 	Instruction         string            `json:"instruction,omitempty"`
 	Size                string            `json:"size,omitempty"`
-	Functions           any               `json:"functions,omitempty"`
+	Functions           json.RawMessage   `json:"functions,omitempty"`
 	FrequencyPenalty    float64           `json:"frequency_penalty,omitempty"`
 	PresencePenalty     float64           `json:"presence_penalty,omitempty"`
 	ResponseFormat      *ResponseFormat   `json:"response_format,omitempty"`
-	EncodingFormat      any               `json:"encoding_format,omitempty"`
+	EncodingFormat      json.RawMessage   `json:"encoding_format,omitempty"`
 	Seed                float64           `json:"seed,omitempty"`
 	ParallelTooCalls    *bool             `json:"parallel_tool_calls,omitempty"`
 	Tools               []ToolCallRequest `json:"tools,omitempty"`
@@ -50,13 +50,13 @@ type GeneralOpenAIRequest struct {
 	LogProbs            bool              `json:"logprobs,omitempty"`
 	TopLogProbs         int               `json:"top_logprobs,omitempty"`
 	Dimensions          int               `json:"dimensions,omitempty"`
-	Modalities          any               `json:"modalities,omitempty"`
-	Audio               any               `json:"audio,omitempty"`
+	Modalities          json.RawMessage   `json:"modalities,omitempty"`
+	Audio               json.RawMessage   `json:"audio,omitempty"`
 	EnableThinking      any               `json:"enable_thinking,omitempty"` // ali
-	ExtraBody           any               `json:"extra_body,omitempty"`
+	ExtraBody           json.RawMessage   `json:"extra_body,omitempty"`
 	WebSearchOptions    *WebSearchOptions `json:"web_search_options,omitempty"`
 	// OpenRouter Params
-	Usage     json.RawMessage `json:"usage,omitempty"`
+  Usage     json.RawMessage `json:"usage,omitempty"`
 	Reasoning json.RawMessage `json:"reasoning,omitempty"`
 }
 
@@ -108,16 +108,16 @@ func (r *GeneralOpenAIRequest) ParseInput() []string {
 }
 
 type Message struct {
-	Role                string          `json:"role"`
-	Content             json.RawMessage `json:"content"`
-	Name                *string         `json:"name,omitempty"`
-	Prefix              *bool           `json:"prefix,omitempty"`
-	ReasoningContent    string          `json:"reasoning_content,omitempty"`
-	Reasoning           string          `json:"reasoning,omitempty"`
-	ToolCalls           json.RawMessage `json:"tool_calls,omitempty"`
-	ToolCallId          string          `json:"tool_call_id,omitempty"`
-	parsedContent       []MediaContent
-	parsedStringContent *string
+	Role             string          `json:"role"`
+	Content          any             `json:"content"`
+	Name             *string         `json:"name,omitempty"`
+	Prefix           *bool           `json:"prefix,omitempty"`
+	ReasoningContent string          `json:"reasoning_content,omitempty"`
+	Reasoning        string          `json:"reasoning,omitempty"`
+	ToolCalls        json.RawMessage `json:"tool_calls,omitempty"`
+	ToolCallId       string          `json:"tool_call_id,omitempty"`
+	parsedContent    []MediaContent
+	//parsedStringContent *string
 }
 
 type MediaContent struct {
@@ -133,21 +133,50 @@ type MediaContent struct {
 
 func (m *MediaContent) GetImageMedia() *MessageImageUrl {
 	if m.ImageUrl != nil {
-		return m.ImageUrl.(*MessageImageUrl)
+		if _, ok := m.ImageUrl.(*MessageImageUrl); ok {
+			return m.ImageUrl.(*MessageImageUrl)
+		}
+		if itemMap, ok := m.ImageUrl.(map[string]any); ok {
+			out := &MessageImageUrl{
+				Url:      common.Interface2String(itemMap["url"]),
+				Detail:   common.Interface2String(itemMap["detail"]),
+				MimeType: common.Interface2String(itemMap["mime_type"]),
+			}
+			return out
+		}
 	}
 	return nil
 }
 
 func (m *MediaContent) GetInputAudio() *MessageInputAudio {
 	if m.InputAudio != nil {
-		return m.InputAudio.(*MessageInputAudio)
+		if _, ok := m.InputAudio.(*MessageInputAudio); ok {
+			return m.InputAudio.(*MessageInputAudio)
+		}
+		if itemMap, ok := m.InputAudio.(map[string]any); ok {
+			out := &MessageInputAudio{
+				Data:   common.Interface2String(itemMap["data"]),
+				Format: common.Interface2String(itemMap["format"]),
+			}
+			return out
+		}
 	}
 	return nil
 }
 
 func (m *MediaContent) GetFile() *MessageFile {
 	if m.File != nil {
-		return m.File.(*MessageFile)
+		if _, ok := m.File.(*MessageFile); ok {
+			return m.File.(*MessageFile)
+		}
+		if itemMap, ok := m.File.(map[string]any); ok {
+			out := &MessageFile{
+				FileName: common.Interface2String(itemMap["file_name"]),
+				FileData: common.Interface2String(itemMap["file_data"]),
+				FileId:   common.Interface2String(itemMap["file_id"]),
+			}
+			return out
+		}
 	}
 	return nil
 }
@@ -213,6 +242,186 @@ func (m *Message) SetToolCalls(toolCalls any) {
 }
 
 func (m *Message) StringContent() string {
+	switch m.Content.(type) {
+	case string:
+		return m.Content.(string)
+	case []any:
+		var contentStr string
+		for _, contentItem := range m.Content.([]any) {
+			contentMap, ok := contentItem.(map[string]any)
+			if !ok {
+				continue
+			}
+			if contentMap["type"] == ContentTypeText {
+				if subStr, ok := contentMap["text"].(string); ok {
+					contentStr += subStr
+				}
+			}
+		}
+		return contentStr
+	}
+
+	return ""
+}
+
+func (m *Message) SetNullContent() {
+	m.Content = nil
+	m.parsedContent = nil
+}
+
+func (m *Message) SetStringContent(content string) {
+	m.Content = content
+	m.parsedContent = nil
+}
+
+func (m *Message) SetMediaContent(content []MediaContent) {
+	m.Content = content
+	m.parsedContent = content
+}
+
+func (m *Message) IsStringContent() bool {
+	_, ok := m.Content.(string)
+	if ok {
+		return true
+	}
+	return false
+}
+
+func (m *Message) ParseContent() []MediaContent {
+	if m.Content == nil {
+		return nil
+	}
+	if len(m.parsedContent) > 0 {
+		return m.parsedContent
+	}
+
+	var contentList []MediaContent
+	// 先尝试解析为字符串
+	content, ok := m.Content.(string)
+	if ok {
+		contentList = []MediaContent{{
+			Type: ContentTypeText,
+			Text: content,
+		}}
+		m.parsedContent = contentList
+		return contentList
+	}
+
+	// 尝试解析为数组
+	//var arrayContent []map[string]interface{}
+
+	arrayContent, ok := m.Content.([]any)
+	if !ok {
+		return contentList
+	}
+
+	for _, contentItemAny := range arrayContent {
+		mediaItem, ok := contentItemAny.(MediaContent)
+		if ok {
+			contentList = append(contentList, mediaItem)
+			continue
+		}
+
+		contentItem, ok := contentItemAny.(map[string]any)
+		if !ok {
+			continue
+		}
+		contentType, ok := contentItem["type"].(string)
+		if !ok {
+			continue
+		}
+
+		switch contentType {
+		case ContentTypeText:
+			if text, ok := contentItem["text"].(string); ok {
+				contentList = append(contentList, MediaContent{
+					Type: ContentTypeText,
+					Text: text,
+				})
+			}
+
+		case ContentTypeImageURL:
+			imageUrl := contentItem["image_url"]
+			temp := &MessageImageUrl{
+				Detail: "high",
+			}
+			switch v := imageUrl.(type) {
+			case string:
+				temp.Url = v
+			case map[string]interface{}:
+				url, ok1 := v["url"].(string)
+				detail, ok2 := v["detail"].(string)
+				if ok2 {
+					temp.Detail = detail
+				}
+				if ok1 {
+					temp.Url = url
+				}
+			}
+			contentList = append(contentList, MediaContent{
+				Type:     ContentTypeImageURL,
+				ImageUrl: temp,
+			})
+
+		case ContentTypeInputAudio:
+			if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok {
+				data, ok1 := audioData["data"].(string)
+				format, ok2 := audioData["format"].(string)
+				if ok1 && ok2 {
+					temp := &MessageInputAudio{
+						Data:   data,
+						Format: format,
+					}
+					contentList = append(contentList, MediaContent{
+						Type:       ContentTypeInputAudio,
+						InputAudio: temp,
+					})
+				}
+			}
+		case ContentTypeFile:
+			if fileData, ok := contentItem["file"].(map[string]interface{}); ok {
+				fileId, ok3 := fileData["file_id"].(string)
+				if ok3 {
+					contentList = append(contentList, MediaContent{
+						Type: ContentTypeFile,
+						File: &MessageFile{
+							FileId: fileId,
+						},
+					})
+				} else {
+					fileName, ok1 := fileData["filename"].(string)
+					fileDataStr, ok2 := fileData["file_data"].(string)
+					if ok1 && ok2 {
+						contentList = append(contentList, MediaContent{
+							Type: ContentTypeFile,
+							File: &MessageFile{
+								FileName: fileName,
+								FileData: fileDataStr,
+							},
+						})
+					}
+				}
+			}
+		case ContentTypeVideoUrl:
+			if videoUrl, ok := contentItem["video_url"].(string); ok {
+				contentList = append(contentList, MediaContent{
+					Type: ContentTypeVideoUrl,
+					VideoUrl: &MessageVideoUrl{
+						Url: videoUrl,
+					},
+				})
+			}
+		}
+	}
+
+	if len(contentList) > 0 {
+		m.parsedContent = contentList
+	}
+	return contentList
+}
+
+// old code
+/*func (m *Message) StringContent() string {
 	if m.parsedStringContent != nil {
 		return *m.parsedStringContent
 	}
@@ -383,7 +592,7 @@ func (m *Message) ParseContent() []MediaContent {
 		m.parsedContent = contentList
 	}
 	return contentList
-}
+}*/
 
 type WebSearchOptions struct {
 	SearchContextSize string          `json:"search_context_size,omitempty"`

+ 3 - 3
go.mod

@@ -11,7 +11,6 @@ require (
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.11
 	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
 	github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
-	github.com/bytedance/sonic v1.11.6
 	github.com/gin-contrib/cors v1.7.2
 	github.com/gin-contrib/gzip v0.0.6
 	github.com/gin-contrib/sessions v0.0.5
@@ -25,10 +24,10 @@ require (
 	github.com/gorilla/websocket v1.5.0
 	github.com/joho/godotenv v1.5.1
 	github.com/pkg/errors v0.9.1
-	github.com/pkoukk/tiktoken-go v0.1.7
 	github.com/samber/lo v1.39.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
 	github.com/shopspring/decimal v1.4.0
+	github.com/tiktoken-go/tokenizer v0.6.2
 	golang.org/x/crypto v0.35.0
 	golang.org/x/image v0.23.0
 	golang.org/x/net v0.35.0
@@ -43,12 +42,13 @@ require (
 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
 	github.com/aws/smithy-go v1.20.2 // indirect
+	github.com/bytedance/sonic v1.11.6 // indirect
 	github.com/bytedance/sonic/loader v0.1.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cloudwego/base64x v0.1.4 // indirect
 	github.com/cloudwego/iasm v0.2.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
-	github.com/dlclark/regexp2 v1.11.0 // indirect
+	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
 	github.com/gin-contrib/sse v0.1.0 // indirect

+ 4 - 4
go.sum

@@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
-github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
-github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
@@ -167,8 +167,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
-github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -197,6 +195,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
+github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
 github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
 github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
 github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=

+ 1 - 1
makefile

@@ -7,7 +7,7 @@ all: build-frontend start-backend
 
 build-frontend:
 	@echo "Building frontend..."
-	@cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) npm run build
+	@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
 
 start-backend:
 	@echo "Starting backend dev server..."

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

+ 41 - 0
middleware/stats.go

@@ -0,0 +1,41 @@
+package middleware
+
+import (
+	"sync/atomic"
+
+	"github.com/gin-gonic/gin"
+)
+
+// HTTPStats 存储HTTP统计信息
+type HTTPStats struct {
+	activeConnections int64
+}
+
+var globalStats = &HTTPStats{}
+
+// StatsMiddleware 统计中间件
+func StatsMiddleware() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		// 增加活跃连接数
+		atomic.AddInt64(&globalStats.activeConnections, 1)
+		
+		// 确保在请求结束时减少连接数
+		defer func() {
+			atomic.AddInt64(&globalStats.activeConnections, -1)
+		}()
+		
+		c.Next()
+	}
+}
+
+// StatsInfo 统计信息结构
+type StatsInfo struct {
+	ActiveConnections int64 `json:"active_connections"`
+}
+
+// GetStats 获取统计信息
+func GetStats() StatsInfo {
+	return StatsInfo{
+		ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
+	}
+} 

+ 3 - 0
model/option.go

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

+ 1 - 1
model/token_cache.go

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

+ 3 - 2
model/user_cache.go

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

+ 5 - 1
relay/channel/ali/adaptor.go

@@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 	switch info.RelayMode {
 	case constant.RelayModeEmbeddings:
 		fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl)
+	case constant.RelayModeRerank:
+		fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl)
 	case constant.RelayModeImagesGenerations:
 		fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
 	case constant.RelayModeCompletions:
@@ -76,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 }
 
 func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
-	return nil, errors.New("not implemented")
+	return ConvertRerankRequest(request), nil
 }
 
 func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
@@ -103,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
 		err, usage = aliImageHandler(c, resp, info)
 	case constant.RelayModeEmbeddings:
 		err, usage = aliEmbeddingHandler(c, resp)
+	case constant.RelayModeRerank:
+		err, usage = RerankHandler(c, resp, info)
 	default:
 		if info.IsStream {
 			err, usage = openai.OaiStreamHandler(c, resp, info)

+ 1 - 0
relay/channel/ali/constants.go

@@ -8,6 +8,7 @@ var ModelList = []string{
 	"qwq-32b",
 	"qwen3-235b-a22b",
 	"text-embedding-v1",
+	"gte-rerank-v2",
 }
 
 var ChannelName = "ali"

+ 27 - 0
relay/channel/ali/dto.go

@@ -1,5 +1,7 @@
 package ali
 
+import "one-api/dto"
+
 type AliMessage struct {
 	Content string `json:"content"`
 	Role    string `json:"role"`
@@ -97,3 +99,28 @@ type AliImageRequest struct {
 	} `json:"parameters,omitempty"`
 	ResponseFormat string `json:"response_format,omitempty"`
 }
+
+type AliRerankParameters struct {
+	TopN            *int  `json:"top_n,omitempty"`
+	ReturnDocuments *bool `json:"return_documents,omitempty"`
+}
+
+type AliRerankInput struct {
+	Query     string `json:"query"`
+	Documents []any  `json:"documents"`
+}
+
+type AliRerankRequest struct {
+	Model      string              `json:"model"`
+	Input      AliRerankInput      `json:"input"`
+	Parameters AliRerankParameters `json:"parameters,omitempty"`
+}
+
+type AliRerankResponse struct {
+	Output struct {
+		Results []dto.RerankResponseResult `json:"results"`
+	} `json:"output"`
+	Usage     AliUsage `json:"usage"`
+	RequestId string   `json:"request_id"`
+	AliError
+}

+ 83 - 0
relay/channel/ali/rerank.go

@@ -0,0 +1,83 @@
+package ali
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"one-api/dto"
+	relaycommon "one-api/relay/common"
+	"one-api/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest {
+	returnDocuments := request.ReturnDocuments
+	if returnDocuments == nil {
+		t := true
+		returnDocuments = &t
+	}
+	return &AliRerankRequest{
+		Model: request.Model,
+		Input: AliRerankInput{
+			Query:     request.Query,
+			Documents: request.Documents,
+		},
+		Parameters: AliRerankParameters{
+			TopN:            &request.TopN,
+			ReturnDocuments: returnDocuments,
+		},
+	}
+}
+
+func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
+	responseBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
+	}
+	err = resp.Body.Close()
+	if err != nil {
+		return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
+	}
+
+	var aliResponse AliRerankResponse
+	err = json.Unmarshal(responseBody, &aliResponse)
+	if err != nil {
+		return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
+	}
+
+	if aliResponse.Code != "" {
+		return &dto.OpenAIErrorWithStatusCode{
+			Error: dto.OpenAIError{
+				Message: aliResponse.Message,
+				Type:    aliResponse.Code,
+				Param:   aliResponse.RequestId,
+				Code:    aliResponse.Code,
+			},
+			StatusCode: resp.StatusCode,
+		}, nil
+	}
+
+	usage := dto.Usage{
+		PromptTokens:     aliResponse.Usage.TotalTokens,
+		CompletionTokens: 0,
+		TotalTokens:      aliResponse.Usage.TotalTokens,
+	}
+	rerankResponse := dto.RerankResponse{
+		Results: aliResponse.Output.Results,
+		Usage:   usage,
+	}
+
+	jsonResponse, err := json.Marshal(rerankResponse)
+	if err != nil {
+		return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
+	}
+	c.Writer.Header().Set("Content-Type", "application/json")
+	c.Writer.WriteHeader(resp.StatusCode)
+	_, err = c.Writer.Write(jsonResponse)
+	if err != nil {
+		return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil
+	}
+
+	return nil, &usage
+}

+ 10 - 9
relay/channel/ali/text.go

@@ -3,7 +3,6 @@ package ali
 import (
 	"bufio"
 	"encoding/json"
-	"github.com/gin-gonic/gin"
 	"io"
 	"net/http"
 	"one-api/common"
@@ -11,6 +10,8 @@ import (
 	"one-api/relay/helper"
 	"one-api/service"
 	"strings"
+
+	"github.com/gin-gonic/gin"
 )
 
 // https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r
@@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
 }
 
 func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
-	if request.Model == "" {
-		request.Model = "text-embedding-v1"
-	}
 	return &AliEmbeddingRequest{
 		Model: request.Model,
 		Input: struct {
@@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
 		}, nil
 	}
 
-	fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse)
+	model := c.GetString("model")
+	if model == "" {
+		model = "text-embedding-v4"
+	}
+	fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model)
 	jsonResponse, err := json.Marshal(fullTextResponse)
 	if err != nil {
 		return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
@@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
 	return nil, &fullTextResponse.Usage
 }
 
-func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse {
+func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
 	openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
 		Object: "list",
 		Data:   make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
-		Model:  "text-embedding-v1",
+		Model:  model,
 		Usage:  dto.Usage{TotalTokens: response.Usage.TotalTokens},
 	}
 
@@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe
 }
 
 func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
-	content, _ := json.Marshal(response.Output.Text)
 	choice := dto.OpenAITextResponseChoice{
 		Index: 0,
 		Message: dto.Message{
 			Role:    "assistant",
-			Content: content,
+			Content: response.Output.Text,
 		},
 		FinishReason: response.Output.FinishReason,
 	}

+ 63 - 15
relay/channel/api_request.go

@@ -109,6 +109,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
 
 	gopool.Go(func() {
 		defer func() {
+			// 增加panic恢复处理
+			if r := recover(); r != nil {
+				if common2.DebugEnabled {
+					println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r))
+				}
+			}
 			if common2.DebugEnabled {
 				println("SSE ping goroutine stopped.")
 			}
@@ -119,19 +125,32 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
 		}
 
 		ticker := time.NewTicker(pingInterval)
-		// 退出时清理 ticker
-		defer ticker.Stop()
+		// 确保在任何情况下都清理ticker
+		defer func() {
+			ticker.Stop()
+			if common2.DebugEnabled {
+				println("SSE ping ticker stopped")
+			}
+		}()
 
 		var pingMutex sync.Mutex
 		if common2.DebugEnabled {
 			println("SSE ping goroutine started")
 		}
 
+		// 增加超时控制,防止goroutine长时间运行
+		maxPingDuration := 120 * time.Minute // 最大ping持续时间
+		pingTimeout := time.NewTimer(maxPingDuration)
+		defer pingTimeout.Stop()
+
 		for {
 			select {
 			// 发送 ping 数据
 			case <-ticker.C:
 				if err := sendPingData(c, &pingMutex); err != nil {
+					if common2.DebugEnabled {
+						println("SSE ping error, stopping goroutine:", err.Error())
+					}
 					return
 				}
 			// 收到退出信号
@@ -140,6 +159,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
 			// request 结束
 			case <-c.Request.Context().Done():
 				return
+			// 超时保护,防止goroutine无限运行
+			case <-pingTimeout.C:
+				if common2.DebugEnabled {
+					println("SSE ping goroutine timeout, stopping")
+				}
+				return
 			}
 		}
 	})
@@ -148,19 +173,34 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
 }
 
 func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
-	mutex.Lock()
-	defer mutex.Unlock()
+	// 增加超时控制,防止锁死等待
+	done := make(chan error, 1)
+	go func() {
+		mutex.Lock()
+		defer mutex.Unlock()
 
-	err := helper.PingData(c)
-	if err != nil {
-		common2.LogError(c, "SSE ping error: "+err.Error())
-		return err
-	}
+		err := helper.PingData(c)
+		if err != nil {
+			common2.LogError(c, "SSE ping error: "+err.Error())
+			done <- err
+			return
+		}
 
-	if common2.DebugEnabled {
-		println("SSE ping data sent.")
+		if common2.DebugEnabled {
+			println("SSE ping data sent.")
+		}
+		done <- nil
+	}()
+
+	// 设置发送ping数据的超时时间
+	select {
+	case err := <-done:
+		return err
+	case <-time.After(10 * time.Second):
+		return errors.New("SSE ping data send timeout")
+	case <-c.Request.Context().Done():
+		return errors.New("request context cancelled during ping")
 	}
-	return nil
 }
 
 func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
@@ -175,15 +215,23 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
 		client = service.GetHttpClient()
 	}
 
+	var stopPinger context.CancelFunc
 	if info.IsStream {
 		helper.SetEventStreamHeaders(c)
-
 		// 处理流式请求的 ping 保活
 		generalSettings := operation_setting.GetGeneralSetting()
 		if generalSettings.PingIntervalEnabled {
 			pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
-			stopPinger := startPingKeepAlive(c, pingInterval)
-			defer stopPinger()
+			stopPinger = startPingKeepAlive(c, pingInterval)
+			// 使用defer确保在任何情况下都能停止ping goroutine
+			defer func() {
+				if stopPinger != nil {
+					stopPinger()
+					if common2.DebugEnabled {
+						println("SSE ping goroutine stopped by defer")
+					}
+				}
+			}()
 		}
 	}
 

+ 1 - 2
relay/channel/baidu/relay-baidu.go

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

+ 5 - 8
relay/channel/claude/relay-claude.go

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

+ 1 - 2
relay/channel/cohere/relay-cohere.go

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

+ 1 - 1
relay/channel/coze/dto.go

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

+ 1 - 2
relay/channel/dify/relay-dify.go

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

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

@@ -1,5 +1,7 @@
 package gemini
 
+import "encoding/json"
+
 type GeminiChatRequest struct {
 	Contents           []GeminiChatContent        `json:"contents"`
 	SafetySettings     []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
@@ -22,19 +24,38 @@ type GeminiInlineData struct {
 	Data     string `json:"data"`
 }
 
+// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
+func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
+	type Alias GeminiInlineData // Use type alias to avoid recursion
+	var aux struct {
+		Alias
+		MimeTypeSnake string `json:"mime_type"`
+	}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	*g = GeminiInlineData(aux.Alias) // Copy other fields if any in future
+
+	// Prioritize snake_case if present
+	if aux.MimeTypeSnake != "" {
+		g.MimeType = aux.MimeTypeSnake
+	} else if aux.MimeType != "" { // Fallback to camelCase from Alias
+		g.MimeType = aux.MimeType
+	}
+	// g.Data would be populated by aux.Alias.Data
+	return nil
+}
+
 type FunctionCall struct {
 	FunctionName string `json:"name"`
 	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 {
@@ -63,6 +84,33 @@ type GeminiPart struct {
 	CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
 }
 
+// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData
+func (p *GeminiPart) UnmarshalJSON(data []byte) error {
+	// Alias to avoid recursion during unmarshalling
+	type Alias GeminiPart
+	var aux struct {
+		Alias
+		InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
+	}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	// Assign fields from alias
+	*p = GeminiPart(aux.Alias)
+
+	// Prioritize snake_case for InlineData if present
+	if aux.InlineDataSnake != nil {
+		p.InlineData = aux.InlineDataSnake
+	} else if aux.InlineData != nil { // Fallback to camelCase from Alias
+		p.InlineData = aux.InlineData
+	}
+	// Other fields like Text, FunctionCall etc. are already populated via aux.Alias
+
+	return nil
+}
+
 type GeminiChatContent struct {
 	Role  string       `json:"role,omitempty"`
 	Parts []GeminiPart `json:"parts"`
@@ -117,10 +165,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
 }

+ 93 - 37
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),
+				}
 			}
 		}
 	}
@@ -137,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 		// common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools))
 		// json_data, _ := json.Marshal(geminiRequest.Tools)
 		// common.SysLog("tools_json: " + string(json_data))
-	} else if textRequest.Functions != nil {
-		//geminiRequest.Tools = []GeminiChatTool{
-		//	{
-		//		FunctionDeclarations: textRequest.Functions,
-		//	},
-		//}
 	}
 
 	if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
@@ -173,17 +205,27 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 			} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
 				name = val
 			}
-			content := common.StrToMap(message.StringContent())
-			functionResp := &FunctionResponse{
-				Name: name,
-				Response: GeminiFunctionResponseContent{
-					Name:    name,
-					Content: content,
-				},
+			var contentMap map[string]interface{}
+			contentStr := message.StringContent()
+
+			// 1. 尝试解析为 JSON 对象
+			if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil {
+				// 2. 如果失败,尝试解析为 JSON 数组
+				var contentSlice []interface{}
+				if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
+					// 如果是数组,包装成对象
+					contentMap = map[string]interface{}{"result": contentSlice}
+				} else {
+					// 3. 如果再次失败,作为纯文本处理
+					contentMap = map[string]interface{}{"content": contentStr}
+				}
 			}
-			if content == nil {
-				functionResp.Response.Content = message.StringContent()
+
+			functionResp := &FunctionResponse{
+				Name:     name,
+				Response: contentMap,
 			}
+
 			*parts = append(*parts, GeminiPart{
 				FunctionResponse: functionResp,
 			})
@@ -280,13 +322,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,
 					},
 				})
@@ -576,14 +618,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp
 		Created: common.GetTimestamp(),
 		Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
 	}
-	content, _ := json.Marshal("")
 	isToolCall := false
 	for _, candidate := range response.Candidates {
 		choice := dto.OpenAITextResponseChoice{
 			Index: int(candidate.Index),
 			Message: dto.Message{
 				Role:    "assistant",
-				Content: content,
+				Content: "",
 			},
 			FinishReason: constant.FinishReasonStop,
 		}
@@ -738,6 +779,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 +860,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 {

+ 42 - 0
relay/channel/mistral/text.go

@@ -1,13 +1,55 @@
 package mistral
 
 import (
+	"one-api/common"
 	"one-api/dto"
+	"regexp"
 )
 
+var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$")
+
 func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
 	messages := make([]dto.Message, 0, len(request.Messages))
+	idMap := make(map[string]string)
 	for _, message := range request.Messages {
+		// 1. tool_calls.id
+		toolCalls := message.ParseToolCalls()
+		if toolCalls != nil {
+			for i := range toolCalls {
+				if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) {
+					if newId, ok := idMap[toolCalls[i].ID]; ok {
+						toolCalls[i].ID = newId
+					} else {
+						newId, err := common.GenerateRandomCharsKey(9)
+						if err == nil {
+							idMap[toolCalls[i].ID] = newId
+							toolCalls[i].ID = newId
+						}
+					}
+				}
+			}
+			message.SetToolCalls(toolCalls)
+		}
+
+		// 2. tool_call_id
+		if message.ToolCallId != "" {
+			if newId, ok := idMap[message.ToolCallId]; ok {
+				message.ToolCallId = newId
+			} else {
+				if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) {
+					newId, err := common.GenerateRandomCharsKey(9)
+					if err == nil {
+						idMap[message.ToolCallId] = newId
+						message.ToolCallId = newId
+					}
+				}
+			}
+		}
+
 		mediaMessages := message.ParseContent()
+		if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" {
+			mediaMessages = []dto.MediaContent{}
+		}
 		for j, mediaMessage := range mediaMessages {
 			if mediaMessage.Type == dto.ContentTypeImageURL {
 				imageUrl := mediaMessage.GetImageMedia()

+ 1 - 2
relay/channel/palm/relay-palm.go

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

+ 1 - 2
relay/channel/tencent/relay-tencent.go

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

+ 1 - 2
relay/channel/xunfei/relay-xunfei.go

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

+ 1 - 2
relay/channel/zhipu/relay-zhipu.go

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

+ 128 - 25
relay/helper/stream_scanner.go

@@ -3,6 +3,7 @@ package helper
 import (
 	"bufio"
 	"context"
+	"fmt"
 	"io"
 	"net/http"
 	"one-api/common"
@@ -19,8 +20,8 @@ import (
 )
 
 const (
-	InitialScannerBufferSize = 1 << 20  // 1MB (1*1024*1024)
-	MaxScannerBufferSize     = 10 << 20 // 10MB (10*1024*1024)
+	InitialScannerBufferSize = 64 << 10  // 64KB (64*1024)
+	MaxScannerBufferSize     = 10 << 20  // 10MB (10*1024*1024)
 	DefaultPingInterval      = 10 * time.Second
 )
 
@@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 		return
 	}
 
-	defer resp.Body.Close()
+	// 确保响应体总是被关闭
+	defer func() {
+		if resp.Body != nil {
+			resp.Body.Close()
+		}
+	}()
 
 	streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second
 	if strings.HasPrefix(info.UpstreamModelName, "o") {
@@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 	}
 
 	var (
-		stopChan   = make(chan bool, 2)
+		stopChan   = make(chan bool, 3) // 增加缓冲区避免阻塞
 		scanner    = bufio.NewScanner(resp.Body)
 		ticker     = time.NewTicker(streamingTimeout)
 		pingTicker *time.Ticker
 		writeMutex sync.Mutex // Mutex to protect concurrent writes
+		wg         sync.WaitGroup // 用于等待所有 goroutine 退出
 	)
 
 	generalSettings := operation_setting.GetGeneralSetting()
@@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 		pingTicker = time.NewTicker(pingInterval)
 	}
 
+	// 改进资源清理,确保所有 goroutine 正确退出
 	defer func() {
+		// 通知所有 goroutine 停止
+		common.SafeSendBool(stopChan, true)
+		
 		ticker.Stop()
 		if pingTicker != nil {
 			pingTicker.Stop()
 		}
+		
+		// 等待所有 goroutine 退出,最多等待5秒
+		done := make(chan struct{})
+		go func() {
+			wg.Wait()
+			close(done)
+		}()
+		
+		select {
+		case <-done:
+		case <-time.After(5 * time.Second):
+			common.LogError(c, "timeout waiting for goroutines to exit")
+		}
+		
 		close(stopChan)
 	}()
+	
 	scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize)
 	scanner.Split(bufio.ScanLines)
 	SetEventStreamHeaders(c)
@@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 
 	ctx = context.WithValue(ctx, "stop_chan", stopChan)
 
-	// Handle ping data sending
+	// Handle ping data sending with improved error handling
 	if pingEnabled && pingTicker != nil {
+		wg.Add(1)
 		gopool.Go(func() {
+			defer func() {
+				wg.Done()
+				if r := recover(); r != nil {
+					common.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r))
+					common.SafeSendBool(stopChan, true)
+				}
+				if common.DebugEnabled {
+					println("ping goroutine exited")
+				}
+			}()
+			
+			// 添加超时保护,防止 goroutine 无限运行
+			maxPingDuration := 30 * time.Minute // 最大 ping 持续时间
+			pingTimeout := time.NewTimer(maxPingDuration)
+			defer pingTimeout.Stop()
+			
 			for {
 				select {
 				case <-pingTicker.C:
-					writeMutex.Lock() // Lock before writing
-					err := PingData(c)
-					writeMutex.Unlock() // Unlock after writing
-					if err != nil {
-						common.LogError(c, "ping data error: "+err.Error())
-						common.SafeSendBool(stopChan, true)
+					// 使用超时机制防止写操作阻塞
+					done := make(chan error, 1)
+					go func() {
+						writeMutex.Lock()
+						defer writeMutex.Unlock()
+						done <- PingData(c)
+					}()
+					
+					select {
+					case err := <-done:
+						if err != nil {
+							common.LogError(c, "ping data error: "+err.Error())
+							return
+						}
+						if common.DebugEnabled {
+							println("ping data sent")
+						}
+					case <-time.After(10 * time.Second):
+						common.LogError(c, "ping data send timeout")
+						return
+					case <-ctx.Done():
+						return
+					case <-stopChan:
 						return
-					}
-					if common.DebugEnabled {
-						println("ping data sent")
 					}
 				case <-ctx.Done():
-					if common.DebugEnabled {
-						println("ping data goroutine stopped")
-					}
+					return
+				case <-stopChan:
+					return
+				case <-c.Request.Context().Done():
+					// 监听客户端断开连接
+					return
+				case <-pingTimeout.C:
+					common.LogError(c, "ping goroutine max duration reached")
 					return
 				}
 			}
 		})
 	}
 
+	// Scanner goroutine with improved error handling
+	wg.Add(1)
 	common.RelayCtxGo(ctx, func() {
+		defer func() {
+			wg.Done()
+			if r := recover(); r != nil {
+				common.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r))
+			}
+			common.SafeSendBool(stopChan, true)
+			if common.DebugEnabled {
+				println("scanner goroutine exited")
+			}
+		}()
+		
 		for scanner.Scan() {
+			// 检查是否需要停止
+			select {
+			case <-stopChan:
+				return
+			case <-ctx.Done():
+				return
+			case <-c.Request.Context().Done():
+				return
+			default:
+			}
+			
 			ticker.Reset(streamingTimeout)
 			data := scanner.Text()
 			if common.DebugEnabled {
@@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 			data = strings.TrimSuffix(data, "\r")
 			if !strings.HasPrefix(data, "[DONE]") {
 				info.SetFirstResponseTime()
-				writeMutex.Lock() // Lock before writing
-				success := dataHandler(data)
-				writeMutex.Unlock() // Unlock after writing
-				if !success {
-					break
+				
+				// 使用超时机制防止写操作阻塞
+				done := make(chan bool, 1)
+				go func() {
+					writeMutex.Lock()
+					defer writeMutex.Unlock()
+					done <- dataHandler(data)
+				}()
+				
+				select {
+				case success := <-done:
+					if !success {
+						return
+					}
+				case <-time.After(10 * time.Second):
+					common.LogError(c, "data handler timeout")
+					return
+				case <-ctx.Done():
+					return
+				case <-stopChan:
+					return
 				}
 			}
 		}
@@ -133,17 +235,18 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 				common.LogError(c, "scanner error: "+err.Error())
 			}
 		}
-
-		common.SafeSendBool(stopChan, true)
 	})
 
+	// 主循环等待完成或超时
 	select {
 	case <-ticker.C:
 		// 超时处理逻辑
 		common.LogError(c, "streaming timeout")
-		common.SafeSendBool(stopChan, true)
 	case <-stopChan:
 		// 正常结束
 		common.LogInfo(c, "streaming finished")
+	case <-c.Request.Context().Done():
+		// 客户端断开连接
+		common.LogInfo(c, "client disconnected")
 	}
 }

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

+ 2 - 0
router/api-router.go

@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/setup", controller.GetSetup)
 		apiRouter.POST("/setup", controller.PostSetup)
 		apiRouter.GET("/status", controller.GetStatus)
+		apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
 		apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
 		apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
 		apiRouter.GET("/notice", controller.GetNotice)
@@ -105,6 +106,7 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
 			channelRoute.POST("/fetch_models", controller.FetchModels)
 			channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
+			channelRoute.GET("/tag/models", controller.GetTagModels)
 		}
 		tokenRoute := apiRouter.Group("/token")
 		tokenRoute.Use(middleware.UserAuth())

+ 1 - 0
router/relay-router.go

@@ -11,6 +11,7 @@ import (
 func SetRelayRouter(router *gin.Engine) {
 	router.Use(middleware.CORS())
 	router.Use(middleware.DecompressRequestMiddleware())
+	router.Use(middleware.StatsMiddleware())
 	// https://platform.openai.com/docs/api-reference/introduction
 	modelsRouter := router.Group("/v1/models")
 	modelsRouter.Use(middleware.TokenAuth())

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

+ 49 - 58
service/token_counter.go

@@ -4,6 +4,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/tiktoken-go/tokenizer"
+	"github.com/tiktoken-go/tokenizer/codec"
 	"image"
 	"log"
 	"math"
@@ -11,78 +13,63 @@ import (
 	"one-api/constant"
 	"one-api/dto"
 	relaycommon "one-api/relay/common"
-	"one-api/setting/operation_setting"
 	"strings"
+	"sync"
 	"unicode/utf8"
-
-	"github.com/pkoukk/tiktoken-go"
 )
 
 // tokenEncoderMap won't grow after initialization
-var tokenEncoderMap = map[string]*tiktoken.Tiktoken{}
-var defaultTokenEncoder *tiktoken.Tiktoken
-var o200kTokenEncoder *tiktoken.Tiktoken
+var defaultTokenEncoder tokenizer.Codec
+
+// tokenEncoderMap is used to store token encoders for different models
+var tokenEncoderMap = make(map[string]tokenizer.Codec)
+
+// tokenEncoderMutex protects tokenEncoderMap for concurrent access
+var tokenEncoderMutex sync.RWMutex
 
 func InitTokenEncoders() {
 	common.SysLog("initializing token encoders")
-	cl100TokenEncoder, err := tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE)
-	if err != nil {
-		common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error()))
-	}
-	defaultTokenEncoder = cl100TokenEncoder
-	o200kTokenEncoder, err = tiktoken.GetEncoding(tiktoken.MODEL_O200K_BASE)
-	if err != nil {
-		common.FatalLog(fmt.Sprintf("failed to get gpt-4o token encoder: %s", err.Error()))
-	}
-	for model, _ := range operation_setting.GetDefaultModelRatioMap() {
-		if strings.HasPrefix(model, "gpt-3.5") {
-			tokenEncoderMap[model] = cl100TokenEncoder
-		} else if strings.HasPrefix(model, "gpt-4") {
-			if strings.HasPrefix(model, "gpt-4o") {
-				tokenEncoderMap[model] = o200kTokenEncoder
-			} else {
-				tokenEncoderMap[model] = defaultTokenEncoder
-			}
-		} else if strings.HasPrefix(model, "o") {
-			tokenEncoderMap[model] = o200kTokenEncoder
-		} else {
-			tokenEncoderMap[model] = defaultTokenEncoder
-		}
-	}
+	defaultTokenEncoder = codec.NewCl100kBase()
 	common.SysLog("token encoders initialized")
 }
 
-func getModelDefaultTokenEncoder(model string) *tiktoken.Tiktoken {
-	if strings.HasPrefix(model, "gpt-4o") || strings.HasPrefix(model, "chatgpt-4o") || strings.HasPrefix(model, "o1") {
-		return o200kTokenEncoder
+func getTokenEncoder(model string) tokenizer.Codec {
+	// First, try to get the encoder from cache with read lock
+	tokenEncoderMutex.RLock()
+	if encoder, exists := tokenEncoderMap[model]; exists {
+		tokenEncoderMutex.RUnlock()
+		return encoder
 	}
-	return defaultTokenEncoder
-}
+	tokenEncoderMutex.RUnlock()
+
+	// If not in cache, create new encoder with write lock
+	tokenEncoderMutex.Lock()
+	defer tokenEncoderMutex.Unlock()
 
-func getTokenEncoder(model string) *tiktoken.Tiktoken {
-	tokenEncoder, ok := tokenEncoderMap[model]
-	if ok && tokenEncoder != nil {
-		return tokenEncoder
+	// Double-check if another goroutine already created the encoder
+	if encoder, exists := tokenEncoderMap[model]; exists {
+		return encoder
 	}
-	// 如果ok(即model在tokenEncoderMap中),但是tokenEncoder为nil,说明可能是自定义模型
-	if ok {
-		tokenEncoder, err := tiktoken.EncodingForModel(model)
-		if err != nil {
-			common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error()))
-			tokenEncoder = getModelDefaultTokenEncoder(model)
-		}
-		tokenEncoderMap[model] = tokenEncoder
-		return tokenEncoder
+
+	// Create new encoder
+	modelCodec, err := tokenizer.ForModel(tokenizer.Model(model))
+	if err != nil {
+		// Cache the default encoder for this model to avoid repeated failures
+		tokenEncoderMap[model] = defaultTokenEncoder
+		return defaultTokenEncoder
 	}
-	// 如果model不在tokenEncoderMap中,直接返回默认的tokenEncoder
-	return getModelDefaultTokenEncoder(model)
+
+	// Cache the new encoder
+	tokenEncoderMap[model] = modelCodec
+	return modelCodec
 }
 
-func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {
+func getTokenNum(tokenEncoder tokenizer.Codec, text string) int {
 	if text == "" {
 		return 0
 	}
-	return len(tokenEncoder.Encode(text, nil, nil))
+	tkm, _ := tokenEncoder.Count(text)
+	return tkm
 }
 
 func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) {
@@ -261,12 +248,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream
 					//}
 					tokenNum += 1000
 				case "tool_use":
-					tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
-					inputJSON, _ := json.Marshal(mediaMessage.Input)
-					tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
+					if mediaMessage.Input != nil {
+						tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
+						inputJSON, _ := json.Marshal(mediaMessage.Input)
+						tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
+					}
 				case "tool_result":
-					contentJSON, _ := json.Marshal(mediaMessage.Content)
-					tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
+					if mediaMessage.Content != nil {
+						contentJSON, _ := json.Marshal(mediaMessage.Content)
+						tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
+					}
 				}
 			}
 		}
@@ -386,7 +377,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod
 	for _, message := range messages {
 		tokenNum += tokensPerMessage
 		tokenNum += getTokenNum(tokenEncoder, message.Role)
-		if len(message.Content) > 0 {
+		if message.Content != nil {
 			if message.Name != nil {
 				tokenNum += tokensPerName
 				tokenNum += getTokenNum(tokenEncoder, *message.Name)

+ 327 - 0
setting/console.go

@@ -0,0 +1,327 @@
+package setting
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"one-api/common"
+	"regexp"
+	"sort"
+	"strings"
+	"time"
+)
+
+// ValidateConsoleSettings 验证控制台设置信息格式
+func ValidateConsoleSettings(settingsStr string, settingType string) error {
+	if settingsStr == "" {
+		return nil // 空字符串是合法的
+	}
+	
+	switch settingType {
+	case "ApiInfo":
+		return validateApiInfo(settingsStr)
+	case "Announcements":
+		return validateAnnouncements(settingsStr)
+	case "FAQ":
+		return validateFAQ(settingsStr)
+	default:
+		return fmt.Errorf("未知的设置类型:%s", settingType)
+	}
+}
+
+// validateApiInfo 验证API信息格式
+func validateApiInfo(apiInfoStr string) error {
+	var apiInfoList []map[string]interface{}
+	if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
+		return fmt.Errorf("API信息格式错误:%s", err.Error())
+	}
+	
+	// 验证数组长度
+	if len(apiInfoList) > 50 {
+		return fmt.Errorf("API信息数量不能超过50个")
+	}
+	
+	// 允许的颜色值
+	validColors := map[string]bool{
+		"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
+		"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
+		"light-green": true, "teal": true, "light-blue": true, "indigo": true,
+		"violet": true, "grey": true,
+	}
+	
+	// URL正则表达式,支持域名和IP地址格式
+	// 域名格式:https://example.com 或 https://sub.example.com:8080
+	// IP地址格式:https://192.168.1.1 或 https://192.168.1.1:8080
+	urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?::[0-9]{1,5})?(?:/.*)?$`)
+	
+	for i, apiInfo := range apiInfoList {
+		// 检查必填字段
+		urlStr, ok := apiInfo["url"].(string)
+		if !ok || urlStr == "" {
+			return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
+		}
+		
+		route, ok := apiInfo["route"].(string)
+		if !ok || route == "" {
+			return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
+		}
+		
+		description, ok := apiInfo["description"].(string)
+		if !ok || description == "" {
+			return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
+		}
+		
+		color, ok := apiInfo["color"].(string)
+		if !ok || color == "" {
+			return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
+		}
+		
+		// 验证URL格式
+		if !urlRegex.MatchString(urlStr) {
+			return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
+		}
+		
+		// 验证URL可解析性
+		if _, err := url.Parse(urlStr); err != nil {
+			return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
+		}
+		
+		// 验证字段长度
+		if len(urlStr) > 500 {
+			return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
+		}
+		
+		if len(route) > 100 {
+			return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
+		}
+		
+		if len(description) > 200 {
+			return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
+		}
+		
+		// 验证颜色值
+		if !validColors[color] {
+			return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
+		}
+		
+		// 检查并过滤危险字符(防止XSS)
+		dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
+		for _, dangerous := range dangerousChars {
+			if strings.Contains(strings.ToLower(description), dangerous) {
+				return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
+			}
+			if strings.Contains(strings.ToLower(route), dangerous) {
+				return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
+			}
+		}
+	}
+	
+	return nil
+}
+
+// ValidateApiInfo 保持向后兼容的函数
+func ValidateApiInfo(apiInfoStr string) error {
+	return validateApiInfo(apiInfoStr)
+}
+
+// GetApiInfo 获取API信息列表
+func GetApiInfo() []map[string]interface{} {
+	// 从OptionMap中获取API信息,如果不存在则返回空数组
+	common.OptionMapRWMutex.RLock()
+	apiInfoStr, exists := common.OptionMap["ApiInfo"]
+	common.OptionMapRWMutex.RUnlock()
+	
+	if !exists || apiInfoStr == "" {
+		// 如果没有配置,返回空数组
+		return []map[string]interface{}{}
+	}
+	
+	// 解析存储的API信息
+	var apiInfo []map[string]interface{}
+	if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
+		// 如果解析失败,返回空数组
+		return []map[string]interface{}{}
+	}
+	
+	return apiInfo
+}
+
+// validateAnnouncements 验证系统公告格式
+func validateAnnouncements(announcementsStr string) error {
+	var announcementsList []map[string]interface{}
+	if err := json.Unmarshal([]byte(announcementsStr), &announcementsList); err != nil {
+		return fmt.Errorf("系统公告格式错误:%s", err.Error())
+	}
+	
+	// 验证数组长度
+	if len(announcementsList) > 100 {
+		return fmt.Errorf("系统公告数量不能超过100个")
+	}
+	
+	// 允许的类型值
+	validTypes := map[string]bool{
+		"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
+	}
+	
+	for i, announcement := range announcementsList {
+		// 检查必填字段
+		content, ok := announcement["content"].(string)
+		if !ok || content == "" {
+			return fmt.Errorf("第%d个公告缺少内容字段", i+1)
+		}
+		
+		// 检查发布日期字段
+		publishDate, exists := announcement["publishDate"]
+		if !exists {
+			return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
+		}
+		
+		publishDateStr, ok := publishDate.(string)
+		if !ok || publishDateStr == "" {
+			return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
+		}
+		
+		// 验证ISO日期格式
+		if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
+			return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
+		}
+		
+		// 验证可选字段
+		if announcementType, exists := announcement["type"]; exists {
+			if typeStr, ok := announcementType.(string); ok {
+				if !validTypes[typeStr] {
+					return fmt.Errorf("第%d个公告的类型值不合法", i+1)
+				}
+			}
+		}
+		
+		// 验证字段长度
+		if len(content) > 500 {
+			return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
+		}
+		
+		if extra, exists := announcement["extra"]; exists {
+			if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
+				return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
+			}
+		}
+		
+		// 检查并过滤危险字符(防止XSS)
+		dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
+		for _, dangerous := range dangerousChars {
+			if strings.Contains(strings.ToLower(content), dangerous) {
+				return fmt.Errorf("第%d个公告的内容包含不允许的内容", i+1)
+			}
+		}
+	}
+	
+	return nil
+}
+
+// validateFAQ 验证常见问答格式
+func validateFAQ(faqStr string) error {
+	var faqList []map[string]interface{}
+	if err := json.Unmarshal([]byte(faqStr), &faqList); err != nil {
+		return fmt.Errorf("常见问答格式错误:%s", err.Error())
+	}
+	
+	// 验证数组长度
+	if len(faqList) > 100 {
+		return fmt.Errorf("常见问答数量不能超过100个")
+	}
+	
+	for i, faq := range faqList {
+		// 检查必填字段
+		title, ok := faq["title"].(string)
+		if !ok || title == "" {
+			return fmt.Errorf("第%d个问答缺少标题字段", i+1)
+		}
+		
+		content, ok := faq["content"].(string)
+		if !ok || content == "" {
+			return fmt.Errorf("第%d个问答缺少内容字段", i+1)
+		}
+		
+		// 验证字段长度
+		if len(title) > 200 {
+			return fmt.Errorf("第%d个问答的标题长度不能超过200字符", i+1)
+		}
+		
+		if len(content) > 1000 {
+			return fmt.Errorf("第%d个问答的内容长度不能超过1000字符", i+1)
+		}
+		
+		// 检查并过滤危险字符(防止XSS)
+		dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
+		for _, dangerous := range dangerousChars {
+			if strings.Contains(strings.ToLower(title), dangerous) {
+				return fmt.Errorf("第%d个问答的标题包含不允许的内容", i+1)
+			}
+			if strings.Contains(strings.ToLower(content), dangerous) {
+				return fmt.Errorf("第%d个问答的内容包含不允许的内容", i+1)
+			}
+		}
+	}
+	
+	return nil
+}
+
+// GetAnnouncements 获取系统公告列表(返回最新的前20条)
+func GetAnnouncements() []map[string]interface{} {
+	common.OptionMapRWMutex.RLock()
+	announcementsStr, exists := common.OptionMap["Announcements"]
+	common.OptionMapRWMutex.RUnlock()
+	
+	if !exists || announcementsStr == "" {
+		return []map[string]interface{}{}
+	}
+	
+	var announcements []map[string]interface{}
+	if err := json.Unmarshal([]byte(announcementsStr), &announcements); err != nil {
+		return []map[string]interface{}{}
+	}
+	
+	// 按发布日期降序排序(最新的在前)
+	sort.Slice(announcements, func(i, j int) bool {
+		dateI, okI := announcements[i]["publishDate"].(string)
+		dateJ, okJ := announcements[j]["publishDate"].(string)
+		
+		if !okI || !okJ {
+			return false
+		}
+		
+		timeI, errI := time.Parse(time.RFC3339, dateI)
+		timeJ, errJ := time.Parse(time.RFC3339, dateJ)
+		
+		if errI != nil || errJ != nil {
+			return false
+		}
+		
+		return timeI.After(timeJ)
+	})
+	
+	// 限制返回前20条
+	if len(announcements) > 20 {
+		announcements = announcements[:20]
+	}
+	
+	return announcements
+}
+
+// GetFAQ 获取常见问答列表
+func GetFAQ() []map[string]interface{} {
+	common.OptionMapRWMutex.RLock()
+	faqStr, exists := common.OptionMap["FAQ"]
+	common.OptionMapRWMutex.RUnlock()
+	
+	if !exists || faqStr == "" {
+		return []map[string]interface{}{}
+	}
+	
+	var faq []map[string]interface{}
+	if err := json.Unmarshal([]byte(faqStr), &faq); err != nil {
+		return []map[string]interface{}{}
+	}
+	
+	return faq
+} 

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

BIN
web/bun.lockb


+ 23 - 4
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"
+    "sse.js": "^2.6.0",
+    "unist-util-visit": "^5.0.0",
+    "use-debounce": "^10.0.4"
   },
   "scripts": {
     "dev": "vite",
@@ -54,9 +69,13 @@
     ]
   },
   "devDependencies": {
+    "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
     "@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/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 - 449
web/src/components/RedemptionsTable.js

@@ -1,449 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import {
-  API,
-  copy,
-  showError,
-  showSuccess,
-  timestamp2string,
-} from '../helpers';
-
-import { ITEMS_PER_PAGE } from '../constants';
-import { renderQuota } from '../helpers/render';
-import {
-  Button,
-  Divider,
-  Form,
-  Modal,
-  Popconfirm,
-  Popover,
-  Table,
-  Tag,
-} from '@douyinfe/semi-ui';
-import EditRedemption from '../pages/Redemption/EditRedemption';
-import { useTranslation } from 'react-i18next';
-
-function renderTimestamp(timestamp) {
-  return <>{timestamp2string(timestamp)}</>;
-}
-
-const RedemptionsTable = () => {
-  const { t } = useTranslation();
-
-  const renderStatus = (status) => {
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' size='large'>
-            {t('未使用')}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' size='large'>
-            {t('已禁用')}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='grey' size='large'>
-            {t('已使用')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='black' size='large'>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const columns = [
-    {
-      title: t('ID'),
-      dataIndex: 'id',
-    },
-    {
-      title: t('名称'),
-      dataIndex: 'name',
-    },
-    {
-      title: t('状态'),
-      dataIndex: 'status',
-      key: 'status',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text)}</div>;
-      },
-    },
-    {
-      title: t('额度'),
-      dataIndex: 'quota',
-      render: (text, record, index) => {
-        return <div>{renderQuota(parseInt(text))}</div>;
-      },
-    },
-    {
-      title: t('创建时间'),
-      dataIndex: 'created_time',
-      render: (text, record, index) => {
-        return <div>{renderTimestamp(text)}</div>;
-      },
-    },
-    {
-      title: t('兑换人ID'),
-      dataIndex: 'used_user_id',
-      render: (text, record, index) => {
-        return <div>{text === 0 ? t('无') : text}</div>;
-      },
-    },
-    {
-      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);
-              });
-            }}
-          >
-            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
-              {t('删除')}
-            </Button>
-          </Popconfirm>
-          {record.status === 1 ? (
-            <Button
-              theme='light'
-              type='warning'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageRedemption(record.id, 'disable', record);
-              }}
-            >
-              {t('禁用')}
-            </Button>
-          ) : (
-            <Button
-              theme='light'
-              type='secondary'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageRedemption(record.id, 'enable', record);
-              }}
-              disabled={record.status === 3}
-            >
-              {t('启用')}
-            </Button>
-          )}
-          <Button
-            theme='light'
-            type='tertiary'
-            style={{ marginRight: 1 }}
-            onClick={() => {
-              setEditingRedemption(record);
-              setShowEdit(true);
-            }}
-            disabled={record.status !== 1}
-          >
-            {t('编辑')}
-          </Button>
-        </div>
-      ),
-    },
-  ];
-
-  const [redemptions, setRedemptions] = useState([]);
-  const [loading, setLoading] = useState(true);
-  const [activePage, setActivePage] = useState(1);
-  const [searchKeyword, setSearchKeyword] = useState('');
-  const [searching, setSearching] = useState(false);
-  const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
-  const [selectedKeys, setSelectedKeys] = useState([]);
-  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [editingRedemption, setEditingRedemption] = useState({
-    id: undefined,
-  });
-  const [showEdit, setShowEdit] = useState(false);
-
-  const closeEdit = () => {
-    setShowEdit(false);
-  };
-
-  const setRedemptionFormat = (redeptions) => {
-    setRedemptions(redeptions);
-  };
-
-  const loadRedemptions = async (startIdx, pageSize) => {
-    const res = await API.get(
-      `/api/redemption/?p=${startIdx}&page_size=${pageSize}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setTokenCount(data.total);
-      setRedemptionFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setLoading(false);
-  };
-
-  const removeRecord = (key) => {
-    let newDataSource = [...redemptions];
-    if (key != null) {
-      let idx = newDataSource.findIndex((data) => data.key === key);
-
-      if (idx > -1) {
-        newDataSource.splice(idx, 1);
-        setRedemptions(newDataSource);
-      }
-    }
-  };
-
-  const copyText = async (text) => {
-    if (await copy(text)) {
-      showSuccess(t('已复制到剪贴板!'));
-    } else {
-      // setSearchKeyword(text);
-      Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
-    }
-  };
-
-  const onPaginationChange = (e, { activePage }) => {
-    (async () => {
-      if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
-        await loadRedemptions(activePage - 1, pageSize);
-      }
-      setActivePage(activePage);
-    })();
-  };
-
-  useEffect(() => {
-    loadRedemptions(0, pageSize)
-      .then()
-      .catch((reason) => {
-        showError(reason);
-      });
-  }, []);
-
-  const refresh = async () => {
-    await loadRedemptions(activePage - 1, pageSize);
-  };
-
-  const manageRedemption = async (id, action, record) => {
-    let data = { id };
-    let res;
-    switch (action) {
-      case 'delete':
-        res = await API.delete(`/api/redemption/${id}/`);
-        break;
-      case 'enable':
-        data.status = 1;
-        res = await API.put('/api/redemption/?status_only=true', data);
-        break;
-      case 'disable':
-        data.status = 2;
-        res = await API.put('/api/redemption/?status_only=true', data);
-        break;
-    }
-    const { success, message } = res.data;
-    if (success) {
-      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;
-      }
-      setRedemptions(newRedemptions);
-    } else {
-      showError(message);
-    }
-  };
-
-  const searchRedemptions = async (keyword, page, pageSize) => {
-    if (searchKeyword === '') {
-      await loadRedemptions(page, pageSize);
-      return;
-    }
-    setSearching(true);
-    const res = await API.get(
-      `/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`,
-    );
-    const { success, message, data } = res.data;
-    if (success) {
-      const newPageData = data.items;
-      setActivePage(data.page);
-      setTokenCount(data.total);
-      setRedemptionFormat(newPageData);
-    } else {
-      showError(message);
-    }
-    setSearching(false);
-  };
-
-  const handleKeywordChange = async (value) => {
-    setSearchKeyword(value.trim());
-  };
-
-  const sortRedemption = (key) => {
-    if (redemptions.length === 0) return;
-    setLoading(true);
-    let sortedRedemptions = [...redemptions];
-    sortedRedemptions.sort((a, b) => {
-      return ('' + a[key]).localeCompare(b[key]);
-    });
-    if (sortedRedemptions[0].id === redemptions[0].id) {
-      sortedRedemptions.reverse();
-    }
-    setRedemptions(sortedRedemptions);
-    setLoading(false);
-  };
-
-  const handlePageChange = (page) => {
-    setActivePage(page);
-    if (searchKeyword === '') {
-      loadRedemptions(page, pageSize).then();
-    } else {
-      searchRedemptions(searchKeyword, page, pageSize).then();
-    }
-  };
-
-  let pageData = redemptions;
-  const rowSelection = {
-    onSelect: (record, selected) => {},
-    onSelectAll: (selected, selectedRows) => {},
-    onChange: (selectedRowKeys, selectedRows) => {
-      setSelectedKeys(selectedRows);
-    },
-  };
-
-  const handleRow = (record, index) => {
-    if (record.status !== 1) {
-      return {
-        style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
-      };
-    } else {
-      return {};
-    }
-  };
-
-  return (
-    <>
-      <EditRedemption
-        refresh={refresh}
-        editingRedemption={editingRedemption}
-        visiable={showEdit}
-        handleClose={closeEdit}
-      ></EditRedemption>
-      <Form
-        onSubmit={() => {
-          searchRedemptions(searchKeyword, activePage, pageSize).then();
-        }}
-      >
-        <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);
-          }}
-        >
-          {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>
-    </>
-  );
-};
-
-export default RedemptionsTable;

+ 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;

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

@@ -0,0 +1,524 @@
+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';
+
+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-gray-800'>{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="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
+      <div className="w-full max-w-sm">
+        {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;

+ 14 - 20
web/src/components/OAuth2Callback.js → web/src/components/auth/OAuth2Callback.js

@@ -1,16 +1,16 @@
 import React, { useContext, useEffect, useState } from 'react';
-import { Dimmer, Loader, Segment } from 'semantic-ui-react';
 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 { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
+import { UserContext } from '../../context/User';
+import Loading from '../common/Loading';
 
 const OAuth2Callback = (props) => {
+  const { t } = useTranslation();
   const [searchParams, setSearchParams] = useSearchParams();
 
   const [userState, userDispatch] = useContext(UserContext);
-  const [prompt, setPrompt] = useState('处理中...');
-  const [processing, setProcessing] = useState(true);
+  const [prompt, setPrompt] = useState(t('处理中...'));
 
   let navigate = useNavigate();
 
@@ -21,25 +21,25 @@ const OAuth2Callback = (props) => {
     const { success, message, data } = res.data;
     if (success) {
       if (message === 'bind') {
-        showSuccess('绑定成功!');
-        navigate('/setting');
+        showSuccess(t('绑定成功!'));
+        navigate('/console/setting');
       } else {
         userDispatch({ type: 'login', payload: data });
         localStorage.setItem('user', JSON.stringify(data));
         setUserData(data);
         updateAPI();
-        showSuccess('登录成功!');
-        navigate('/token');
+        showSuccess(t('登录成功!'));
+        navigate('/console/token');
       }
     } else {
       showError(message);
       if (count === 0) {
-        setPrompt(`操作失败,重定向至登录界面中...`);
-        navigate('/setting'); // in case this is failed to bind GitHub
+        setPrompt(t('操作失败,重定向至登录界面中...'));
+        navigate('/console/setting'); // in case this is failed to bind GitHub
         return;
       }
       count++;
-      setPrompt(`出现错误,第 ${count} 次重试中...`);
+      setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
       await new Promise((resolve) => setTimeout(resolve, count * 2000));
       await sendCode(code, state, count);
     }
@@ -51,13 +51,7 @@ const OAuth2Callback = (props) => {
     sendCode(code, state, 0).then();
   }, []);
 
-  return (
-    <Segment style={{ minHeight: '300px' }}>
-      <Dimmer active inverted>
-        <Loader size='large'>{prompt}</Loader>
-      </Dimmer>
-    </Segment>
-  );
+  return <Loading prompt={prompt} />;
 };
 
 export default OAuth2Callback;

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

@@ -0,0 +1,172 @@
+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, Banner } from '@douyinfe/semi-ui';
+import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+
+const { Text, Title } = Typography;
+
+const PasswordResetConfirm = () => {
+  const { t } = useTranslation();
+  const [inputs, setInputs] = useState({
+    email: '',
+    token: '',
+  });
+  const { email, token } = inputs;
+  const isValidResetLink = email && token;
+
+  const [loading, setLoading] = useState(false);
+  const [disableButton, setDisableButton] = useState(false);
+  const [countdown, setCountdown] = useState(30);
+  const [newPassword, setNewPassword] = useState('');
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [formApi, setFormApi] = useState(null);
+
+  const logo = getLogo();
+  const systemName = getSystemName();
+
+  useEffect(() => {
+    let token = searchParams.get('token');
+    let email = searchParams.get('email');
+    setInputs({
+      token: token || '',
+      email: email || '',
+    });
+    if (formApi) {
+      formApi.setValues({
+        email: email || '',
+        newPassword: newPassword || ''
+      });
+    }
+  }, [searchParams, newPassword, formApi]);
+
+  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) {
+      showError(t('无效的重置链接,请重新发起密码重置请求'));
+      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="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
+      <div className="w-full max-w-sm">
+        <div className="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-gray-800'>{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">
+                {!isValidResetLink && (
+                  <Banner
+                    type="danger"
+                    description={t('无效的重置链接,请重新发起密码重置请求')}
+                    className="mb-4 !rounded-lg"
+                    closeIcon={null}
+                  />
+                )}
+                <Form
+                  getFormApi={(api) => setFormApi(api)}
+                  initValues={{ email: email || '', newPassword: newPassword || '' }}
+                  className="space-y-4"
+                >
+                  <Form.Input
+                    field="email"
+                    label={t('邮箱')}
+                    name="email"
+                    size="large"
+                    className="!rounded-md"
+                    disabled={true}
+                    prefix={<IconMail />}
+                    placeholder={email ? '' : t('等待获取邮箱信息...')}
+                  />
+
+                  {newPassword && (
+                    <Form.Input
+                      field="newPassword"
+                      label={t('新密码')}
+                      name="newPassword"
+                      size="large"
+                      className="!rounded-md"
+                      disabled={true}
+                      prefix={<IconLock />}
+                      suffix={
+                        <Button
+                          icon={<IconCopy />}
+                          type="tertiary"
+                          theme="borderless"
+                          onClick={async () => {
+                            await copy(newPassword);
+                            showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
+                          }}
+                        >
+                          {t('复制')}
+                        </Button>
+                      }
+                    />
+                  )}
+
+                  <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 || !isValidResetLink}
+                    >
+                      {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;

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

@@ -0,0 +1,147 @@
+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';
+
+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) {
+      showError(t('请输入邮箱地址'));
+      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="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
+      <div className="w-full max-w-sm">
+        <div className="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-gray-800'>{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;

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

@@ -0,0 +1,566 @@
+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';
+
+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-gray-800'>{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-gray-800'>{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="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
+      <div className="w-full max-w-sm">
+        {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 };
-}

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

@@ -0,0 +1,112 @@
+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">{t('关于项目')}</a>
+                <a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
+                <a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{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">{t('快速开始')}</a>
+                <a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
+                <a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{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">One API</a>
+                <a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
+                <a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
+                <a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">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">new-api-horizon</a>
+                {/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">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>
+
+        <div className="text-sm">
+          <span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
+          <a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
+          <span className="!text-semi-color-text-1"> & </span>
+          <a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</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-2">
+        <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; 

+ 32 - 34
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',
@@ -137,23 +134,24 @@ const PageLayout = () => {
           <Content
             style={{
               flex: '1 0 auto',
-              overflowY: styleState.isMobile ? 'visible' : 'auto',
+              overflowY: styleState.isMobile ? 'visible' : 'hidden',
               WebkitOverflowScrolling: 'touch',
-              padding: styleState.shouldInnerPadding ? '24px' : '0',
+              padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '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; 

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor