Selaa lähdekoodia

Merge remote-tracking branch 'origin/main' into nightly

# Conflicts:
#	web/src/helpers/render.jsx
#	web/src/hooks/usage-logs/useUsageLogsData.jsx
#	web/src/i18n/locales/en.json
CaIon 1 kuukausi sitten
vanhempi
commit
4d2993e4cc
100 muutettua tiedostoa jossa 5258 lisäystä ja 1365 poistoa
  1. 2 0
      .env.example
  2. 28 0
      .github/PULL_REQUEST_TEMPLATE.md
  3. 0 29
      .github/PULL_REQUEST_TEMPLATE/pull_request_template.md
  4. 33 0
      .github/workflows/pr-check.yml
  5. 1 0
      common/constants.go
  6. 15 4
      common/email.go
  7. 26 0
      controller/pricing.go
  8. 1 1
      controller/relay.go
  9. 23 0
      controller/token.go
  10. 15 0
      controller/usedata.go
  11. 10 0
      dto/audio.go
  12. 24 30
      dto/claude.go
  13. 13 11
      dto/gemini.go
  14. 73 0
      dto/gemini_isstream_test.go
  15. 53 47
      dto/openai_request.go
  16. 10 10
      electron/package-lock.json
  17. 1 1
      electron/package.json
  18. 5 5
      go.mod
  19. 10 10
      go.sum
  20. 1 0
      i18n/keys.go
  21. 1 0
      i18n/locales/en.yaml
  22. 1 0
      i18n/locales/zh-CN.yaml
  23. 1 0
      i18n/locales/zh-TW.yaml
  24. 10 4
      middleware/performance.go
  25. 12 1
      middleware/request-id.go
  26. 4 1
      model/option.go
  27. 8 0
      model/token.go
  28. 10 0
      model/usedata.go
  29. 19 1
      relay/channel/claude/message_delta_usage_patch_test.go
  30. 61 26
      relay/channel/claude/relay-claude.go
  31. 125 0
      relay/channel/claude/relay_claude_test.go
  32. 6 38
      relay/channel/gemini/relay-gemini.go
  33. 7 1
      relay/channel/minimax/adaptor.go
  34. 137 0
      relay/channel/minimax/adaptor_test.go
  35. 4 0
      relay/channel/minimax/constants.go
  36. 213 0
      relay/channel/minimax/image.go
  37. 2 0
      relay/channel/minimax/relay-minimax.go
  38. 2 9
      relay/channel/ollama/relay-ollama.go
  39. 1 1
      relay/channel/openai/adaptor.go
  40. 3 3
      relay/channel/task/ali/adaptor.go
  41. 93 36
      relay/channel/task/doubao/adaptor.go
  42. 15 0
      relay/channel/task/doubao/constants.go
  43. 3 0
      relay/channel/zhipu_4v/adaptor.go
  44. 16 0
      relay/common/relay_info.go
  45. 3 1
      relay/common/relay_utils.go
  46. 29 15
      relay/helper/price.go
  47. 2 0
      router/api-router.go
  48. 17 4
      service/channel_affinity.go
  49. 1 1
      service/channel_affinity_template_test.go
  50. 37 15
      service/convert.go
  51. 9 0
      service/file_decoder.go
  52. 168 32
      service/file_service.go
  53. 29 13
      service/image.go
  54. 20 4
      service/task_billing.go
  55. 0 3
      service/token_counter.go
  56. 2 1
      setting/operation_setting/channel_affinity_setting.go
  57. 7 7
      setting/ratio_setting/model_ratio.go
  58. 127 126
      types/file_source.go
  59. 4 5
      types/request_meta.go
  60. 5 4
      web/bun.lock
  61. 14 35
      web/src/components/common/DocumentRenderer/index.jsx
  62. 52 0
      web/src/components/common/ErrorBoundary.jsx
  63. 13 6
      web/src/components/dashboard/ApiInfoPanel.jsx
  64. 16 1
      web/src/components/dashboard/ChartsPanel.jsx
  65. 15 0
      web/src/components/dashboard/index.jsx
  66. 4 1
      web/src/components/layout/PageLayout.jsx
  67. 9 7
      web/src/components/layout/headerbar/ThemeToggle.jsx
  68. 13 7
      web/src/components/playground/ParameterControl.jsx
  69. 4 0
      web/src/components/playground/configStorage.js
  70. 4 9
      web/src/components/settings/RatioSetting.jsx
  71. 11 0
      web/src/components/settings/SystemSetting.jsx
  72. 14 3
      web/src/components/table/tokens/TokensColumnDefs.jsx
  73. 3 0
      web/src/components/table/tokens/TokensTable.jsx
  74. 8 0
      web/src/components/table/tokens/modals/EditTokenModal.jsx
  75. 52 6
      web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  76. 12 1
      web/src/helpers/api.js
  77. 55 0
      web/src/helpers/dashboard.jsx
  78. 12 0
      web/src/helpers/render.jsx
  79. 14 0
      web/src/helpers/token.js
  80. 131 6
      web/src/hooks/dashboard/useDashboardCharts.jsx
  81. 22 0
      web/src/hooks/dashboard/useDashboardData.js
  82. 8 1
      web/src/hooks/playground/usePlaygroundState.js
  83. 23 6
      web/src/hooks/tokens/useTokensData.jsx
  84. 5 1
      web/src/hooks/usage-logs/useUsageLogsData.jsx
  85. 208 105
      web/src/i18n/locales/en.json
  86. 209 113
      web/src/i18n/locales/fr.json
  87. 206 109
      web/src/i18n/locales/ja.json
  88. 204 111
      web/src/i18n/locales/ru.json
  89. 206 102
      web/src/i18n/locales/vi.json
  90. 23 1
      web/src/i18n/locales/zh-CN.json
  91. 261 85
      web/src/i18n/locales/zh-TW.json
  92. 67 35
      web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
  93. 133 30
      web/src/pages/Setting/Operation/SettingsGeneral.jsx
  94. 0 1
      web/src/pages/Setting/Performance/SettingsPerformance.jsx
  95. 570 83
      web/src/pages/Setting/Ratio/GroupRatioSettings.jsx
  96. 50 0
      web/src/pages/Setting/Ratio/ModelPricingCombined.jsx
  97. 169 0
      web/src/pages/Setting/Ratio/components/AutoGroupList.jsx
  98. 287 0
      web/src/pages/Setting/Ratio/components/GroupGroupRatioRules.jsx
  99. 351 0
      web/src/pages/Setting/Ratio/components/GroupSpecialUsableRules.jsx
  100. 242 0
      web/src/pages/Setting/Ratio/components/GroupTable.jsx

+ 2 - 0
.env.example

@@ -19,6 +19,8 @@
 # HOSTNAME=your-hostname
 
 # 数据库相关配置
+# 启用错误日志记录
+# ERROR_LOG_ENABLED=true
 # 数据库连接字符串
 # SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
 # 日志数据库连接字符串

+ 28 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,28 @@
+# ⚠️ 提交说明 / PR Notice
+> [!IMPORTANT]
+>
+> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
+
+## 📝 变更描述 / Description
+(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
+
+## 🚀 变更类型 / Type of change
+- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
+- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
+- [ ] ⚡ 性能优化 / 重构 (Refactor)
+- [ ] 📝 文档更新 (Documentation)
+
+## 🔗 关联任务 / Related Issue
+- Closes # (如有)
+
+## ✅ 提交前检查项 / Checklist
+- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
+- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
+- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
+- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
+- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
+- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
+- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
+
+## 📸 运行证明 / Proof of Work
+(请在此粘贴截图、关键日志或测试报告,以证明变更生效)

+ 0 - 29
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md

@@ -1,29 +0,0 @@
-# ⚠️ 提交警告 / PR Warning
-> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
-
----
-
-## 💡 沟通提示 / Pre-submission
-> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
-
-## 📝 变更描述 / Description
-(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
-
-## 🚀 变更类型 / Type of change
-- [ ] 🐛 Bug 修复 (Bug fix)
-- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
-- [ ] ⚡ 性能优化 / 重构 (Refactor)
-- [ ] 📝 文档更新 (Documentation)
-
-## 🔗 关联任务 / Related Issue
-- Closes # (如有)
-
-## ✅ 提交前检查项 / Checklist
-- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
-- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
-- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
-- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
-- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
-
-## 📸 运行证明 / Proof of Work
-(请在此粘贴截图、关键日志或测试报告,以证明变更生效)

+ 33 - 0
.github/workflows/pr-check.yml

@@ -0,0 +1,33 @@
+name: PR Check
+
+permissions:
+  contents: read
+  issues: read
+  pull-requests: read
+
+on:
+  pull_request_target:
+    types: [opened, reopened]
+
+jobs:
+  pr-quality:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: peakoss/anti-slop@v0.2.1
+        with:
+          max-failures: 4
+          require-description: true
+
+          # require-linked-issue: false
+          blocked-terms:  |
+            🤖 Generated with Claude Code
+
+          require-pr-template: true
+          strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
+
+          detect-spam-usernames: true
+          min-account-age: 30
+
+          failure-add-pr-labels: "pr-check-failed"
+          failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
+          close-pr: true

+ 1 - 0
common/constants.go

@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
 var SMTPServer = ""
 var SMTPPort = 587
 var SMTPSSLEnabled = false
+var SMTPForceAuthLogin = false
 var SMTPAccount = ""
 var SMTPFrom = ""
 var SMTPToken = ""

+ 15 - 4
common/email.go

@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
 	return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
 }
 
+func shouldUseSMTPLoginAuth() bool {
+	if SMTPForceAuthLogin {
+		return true
+	}
+	return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
+}
+
+func getSMTPAuth() smtp.Auth {
+	if shouldUseSMTPLoginAuth() {
+		return LoginAuth(SMTPAccount, SMTPToken)
+	}
+	return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
+}
+
 func SendEmail(subject string, receiver string, content string) error {
 	if SMTPFrom == "" { // for compatibility
 		SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
 		"Message-ID: %s\r\n"+ // 添加 Message-ID 头
 		"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
 		receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
-	auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
+	auth := getSMTPAuth()
 	addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
 	to := strings.Split(receiver, ";")
 	var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
 		if err != nil {
 			return err
 		}
-	} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
-		auth = LoginAuth(SMTPAccount, SMTPToken)
-		err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
 	} else {
 		err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
 	}

+ 26 - 0
controller/pricing.go

@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
+	if len(pricing) == 0 {
+		return pricing
+	}
+	if len(usableGroup) == 0 {
+		return []model.Pricing{}
+	}
+
+	filtered := make([]model.Pricing, 0, len(pricing))
+	for _, item := range pricing {
+		if common.StringsContains(item.EnableGroup, "all") {
+			filtered = append(filtered, item)
+			continue
+		}
+		for _, group := range item.EnableGroup {
+			if _, ok := usableGroup[group]; ok {
+				filtered = append(filtered, item)
+				break
+			}
+		}
+	}
+	return filtered
+}
+
 func GetPricing(c *gin.Context) {
 	pricing := model.GetPricing()
 	userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
 	}
 
 	usableGroup = service.GetUserUsableGroups(group)
+	pricing = filterPricingByUsableGroups(pricing, usableGroup)
 	// check groupRatio contains usableGroup
 	for group := range ratio_setting.GetGroupRatioCopy() {
 		if _, ok := usableGroup[group]; !ok {

+ 1 - 1
controller/relay.go

@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
 			ModelRatio:      relayInfo.PriceData.ModelRatio,
 			OtherRatios:     relayInfo.PriceData.OtherRatios,
 			OriginModelName: relayInfo.OriginModelName,
-			PerCallBilling:  common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
+			PerCallBilling:  common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
 		}
 		task.Quota = result.Quota
 		task.Data = result.TaskData

+ 23 - 0
controller/token.go

@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
 		"data":    count,
 	})
 }
+
+func GetTokenKeysBatch(c *gin.Context) {
+	tokenBatch := TokenBatch{}
+	if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
+		common.ApiErrorI18n(c, i18n.MsgInvalidParams)
+		return
+	}
+	if len(tokenBatch.Ids) > 100 {
+		common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
+		return
+	}
+	userId := c.GetInt("id")
+	tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	keysMap := make(map[int]string)
+	for _, t := range tokens {
+		keysMap[t.Id] = t.GetFullKey()
+	}
+	common.ApiSuccess(c, gin.H{"keys": keysMap})
+}

+ 15 - 0
controller/usedata.go

@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
 	return
 }
 
+func GetQuotaDatesByUser(c *gin.Context) {
+	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+	endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+	dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    dates,
+	})
+}
+
 func GetUserQuotaDates(c *gin.Context) {
 	userId := c.GetInt("id")
 	startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)

+ 10 - 0
dto/audio.go

@@ -18,6 +18,16 @@ type AudioRequest struct {
 	Speed          *float64        `json:"speed,omitempty"`
 	StreamFormat   string          `json:"stream_format,omitempty"`
 	Metadata       json.RawMessage `json:"metadata,omitempty"`
+	// vllm-omini
+	TaskType                json.RawMessage `json:"task_type,omitempty"`
+	Language                json.RawMessage `json:"language,omitempty"`
+	RefAudio                json.RawMessage `json:"ref_audio,omitempty"`
+	RefText                 json.RawMessage `json:"ref_text,omitempty"`
+	XVectorOnlyMode         json.RawMessage `json:"x_vector_only_mode,omitempty"`
+	MaxNewTokens            json.RawMessage `json:"max_new_tokens,omitempty"`
+	InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
+	// TODO:ensure that the logic remains correct after the stream is started.
+	//Stream                  json.RawMessage `json:"stream,omitempty"`
 }
 
 func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {

+ 24 - 30
dto/claude.go

@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
 	return mediaContent
 }
 
+func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
+	if m.Source == nil {
+		return nil
+	}
+	data := m.Source.Url
+	if data == "" {
+		data = common.Interface2String(m.Source.Data)
+	}
+	if data == "" {
+		return nil
+	}
+	return types.NewFileSourceFromData(data, m.Source.MediaType)
+}
+
 type ClaudeMessageSource struct {
 	Type      string `json:"type"`
 	MediaType string `json:"media_type,omitempty"`
@@ -223,14 +237,6 @@ type OutputConfigForEffort struct {
 	Effort string `json:"effort,omitempty"`
 }
 
-// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
-func createClaudeFileSource(data string) *types.FileSource {
-	if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
-		return types.NewURLFileSource(data)
-	}
-	return types.NewBase64FileSource(data, "")
-}
-
 func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
 	maxTokens := 0
 	if c.MaxTokens != nil {
@@ -258,17 +264,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
 				case "text":
 					texts = append(texts, media.GetText())
 				case "image":
-					if media.Source != nil {
-						data := media.Source.Url
-						if data == "" {
-							data = common.Interface2String(media.Source.Data)
-						}
-						if data != "" {
-							fileMeta = append(fileMeta, &types.FileMeta{
-								FileType: types.FileTypeImage,
-								Source:   createClaudeFileSource(data),
-							})
-						}
+					if source := media.ToFileSource(); source != nil {
+						fileMeta = append(fileMeta, &types.FileMeta{
+							FileType: types.FileTypeImage,
+							Source:   source,
+						})
 					}
 				}
 			}
@@ -293,17 +293,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
 			case "text":
 				texts = append(texts, media.GetText())
 			case "image":
-				if media.Source != nil {
-					data := media.Source.Url
-					if data == "" {
-						data = common.Interface2String(media.Source.Data)
-					}
-					if data != "" {
-						fileMeta = append(fileMeta, &types.FileMeta{
-							FileType: types.FileTypeImage,
-							Source:   createClaudeFileSource(data),
-						})
-					}
+				if source := media.ToFileSource(); source != nil {
+					fileMeta = append(fileMeta, &types.FileMeta{
+						FileType: types.FileTypeImage,
+						Source:   source,
+					})
 				}
 			case "tool_use":
 				if media.Name != "" {

+ 13 - 11
dto/gemini.go

@@ -64,14 +64,6 @@ type LatLng struct {
 	Longitude *float64 `json:"longitude,omitempty"`
 }
 
-// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
-func createGeminiFileSource(data string, mimeType string) *types.FileSource {
-	if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
-		return types.NewURLFileSource(data)
-	}
-	return types.NewBase64FileSource(data, mimeType)
-}
-
 func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
 	var files []*types.FileMeta = make([]*types.FileMeta, 0)
 
@@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
 			if part.Text != "" {
 				inputTexts = append(inputTexts, part.Text)
 			}
-			if part.InlineData != nil && part.InlineData.Data != "" {
+			if source := part.InlineData.ToFileSource(); source != nil {
 				mimeType := part.InlineData.MimeType
-				source := createGeminiFileSource(part.InlineData.Data, mimeType)
 				var fileType types.FileType
 				if strings.HasPrefix(mimeType, "image/") {
 					fileType = types.FileTypeImage
@@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
 				files = append(files, &types.FileMeta{
 					FileType: fileType,
 					Source:   source,
-					MimeType: mimeType,
 				})
 			}
 		}
@@ -121,6 +111,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
 	if c.Query("alt") == "sse" {
 		return true
 	}
+	// Native Gemini API uses URL action to indicate streaming:
+	// /v1beta/models/{model}:streamGenerateContent
+	if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
+		return true
+	}
 	return false
 }
 
@@ -210,6 +205,13 @@ type GeminiInlineData struct {
 	Data     string `json:"data"`
 }
 
+func (d *GeminiInlineData) ToFileSource() types.FileSource {
+	if d == nil || d.Data == "" {
+		return nil
+	}
+	return types.NewFileSourceFromData(d.Data, d.MimeType)
+}
+
 // 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

+ 73 - 0
dto/gemini_isstream_test.go

@@ -0,0 +1,73 @@
+package dto
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGeminiChatRequest_IsStream(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	tests := []struct {
+		name     string
+		path     string
+		query    string
+		expected bool
+	}{
+		{
+			name:     "streamGenerateContent without alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
+			query:    "key=sk-xxx",
+			expected: true,
+		},
+		{
+			name:     "streamGenerateContent with alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
+			query:    "alt=sse&key=sk-xxx",
+			expected: true,
+		},
+		{
+			name:     "generateContent without alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:generateContent",
+			query:    "key=sk-xxx",
+			expected: false,
+		},
+		{
+			name:     "generateContent with alt=sse",
+			path:     "/v1beta/models/gemini-2.0-flash:generateContent",
+			query:    "alt=sse",
+			expected: true,
+		},
+		{
+			name:     "GenerateContent capitalized",
+			path:     "/v1beta/models/gemini-2.0-flash:GenerateContent",
+			query:    "key=sk-xxx",
+			expected: false,
+		},
+		{
+			name:     "embedding path",
+			path:     "/v1beta/models/gemini-2.0-flash:embedContent",
+			query:    "",
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			url := tt.path
+			if tt.query != "" {
+				url += "?" + tt.query
+			}
+			c.Request, _ = http.NewRequest("POST", url, nil)
+
+			req := &GeminiChatRequest{}
+			assert.Equal(t, tt.expected, req.IsStream(c))
+		})
+	}
+}

+ 53 - 47
dto/openai_request.go

@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
 	ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
 }
 
-// createFileSource 根据数据内容创建正确类型的 FileSource
-func createFileSource(data string) *types.FileSource {
-	if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
-		return types.NewURLFileSource(data)
-	}
-	return types.NewBase64FileSource(data, "")
-}
-
 func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
 	var tokenCountMeta types.TokenCountMeta
 	var texts = make([]string, 0)
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
 			}
 			arrayContent := message.ParseContent()
 			for _, m := range arrayContent {
-				if m.Type == ContentTypeImageURL {
-					imageUrl := m.GetImageMedia()
-					if imageUrl != nil && imageUrl.Url != "" {
-						source := createFileSource(imageUrl.Url)
-						fileMeta = append(fileMeta, &types.FileMeta{
-							FileType: types.FileTypeImage,
-							Source:   source,
-							Detail:   imageUrl.Detail,
-						})
-					}
-				} else if m.Type == ContentTypeInputAudio {
-					inputAudio := m.GetInputAudio()
-					if inputAudio != nil && inputAudio.Data != "" {
-						source := createFileSource(inputAudio.Data)
-						fileMeta = append(fileMeta, &types.FileMeta{
-							FileType: types.FileTypeAudio,
-							Source:   source,
-						})
-					}
-				} else if m.Type == ContentTypeFile {
-					file := m.GetFile()
-					if file != nil && file.FileData != "" {
-						source := createFileSource(file.FileData)
-						fileMeta = append(fileMeta, &types.FileMeta{
-							FileType: types.FileTypeFile,
-							Source:   source,
-						})
-					}
-				} else if m.Type == ContentTypeVideoUrl {
-					videoUrl := m.GetVideoUrl()
-					if videoUrl != nil && videoUrl.Url != "" {
-						source := createFileSource(videoUrl.Url)
-						fileMeta = append(fileMeta, &types.FileMeta{
-							FileType: types.FileTypeVideo,
-							Source:   source,
-						})
+				source := m.ToFileSource()
+				if source != nil {
+					meta := &types.FileMeta{Source: source}
+					switch m.Type {
+					case ContentTypeImageURL:
+						meta.FileType = types.FileTypeImage
+						if img := m.GetImageMedia(); img != nil {
+							meta.Detail = img.Detail
+						}
+					case ContentTypeInputAudio:
+						meta.FileType = types.FileTypeAudio
+					case ContentTypeFile:
+						meta.FileType = types.FileTypeFile
+					case ContentTypeVideoUrl:
+						meta.FileType = types.FileTypeVideo
 					}
-				} else {
+					fileMeta = append(fileMeta, meta)
+				} else if m.Type == ContentTypeText {
 					texts = append(texts, m.Text)
 				}
 			}
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
 	return nil
 }
 
+func (m *MediaContent) ToFileSource() types.FileSource {
+	switch m.Type {
+	case ContentTypeImageURL:
+		img := m.GetImageMedia()
+		if img == nil || img.Url == "" {
+			return nil
+		}
+		return types.NewFileSourceFromData(img.Url, img.MimeType)
+	case ContentTypeInputAudio:
+		audio := m.GetInputAudio()
+		if audio == nil || audio.Data == "" {
+			return nil
+		}
+		mimeType := ""
+		if audio.Format != "" {
+			mimeType = "audio/" + audio.Format
+		}
+		return types.NewFileSourceFromData(audio.Data, mimeType)
+	case ContentTypeFile:
+		file := m.GetFile()
+		if file == nil || file.FileData == "" {
+			return nil
+		}
+		return types.NewFileSourceFromData(file.FileData, "")
+	case ContentTypeVideoUrl:
+		video := m.GetVideoUrl()
+		if video == nil || video.Url == "" {
+			return nil
+		}
+		return types.NewFileSourceFromData(video.Url, "")
+	}
+	return nil
+}
+
 type MessageImageUrl struct {
 	Url      string `json:"url"`
 	Detail   string `json:"detail,omitempty"`
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
 				if input.ImageUrl != "" {
 					fileMeta = append(fileMeta, &types.FileMeta{
 						FileType: types.FileTypeImage,
-						Source:   createFileSource(input.ImageUrl),
+						Source:   types.NewFileSourceFromData(input.ImageUrl, ""),
 						Detail:   input.Detail,
 					})
 				}
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
 				if input.FileUrl != "" {
 					fileMeta = append(fileMeta, &types.FileMeta{
 						FileType: types.FileTypeFile,
-						Source:   createFileSource(input.FileUrl),
+						Source:   types.NewFileSourceFromData(input.FileUrl, ""),
 					})
 				}
 			} else {

+ 10 - 10
electron/package-lock.json

@@ -9,7 +9,7 @@
       "version": "1.0.0",
       "devDependencies": {
         "cross-env": "^7.0.3",
-        "electron": "35.7.5",
+        "electron": "39.8.5",
         "electron-builder": "^26.7.0"
       }
     },
@@ -777,9 +777,9 @@
       }
     },
     "node_modules/@xmldom/xmldom": {
-      "version": "0.8.11",
-      "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
-      "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
+      "version": "0.8.12",
+      "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
+      "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -2145,9 +2145,9 @@
       }
     },
     "node_modules/electron": {
-      "version": "35.7.5",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
-      "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
+      "version": "39.8.5",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
+      "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
       "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
@@ -3279,9 +3279,9 @@
       "license": "MIT"
     },
     "node_modules/lodash": {
-      "version": "4.17.23",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
-      "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
       "dev": true,
       "license": "MIT"
     },

+ 1 - 1
electron/package.json

@@ -25,7 +25,7 @@
   },
   "devDependencies": {
     "cross-env": "^7.0.3",
-    "electron": "35.7.5",
+    "electron": "39.8.5",
     "electron-builder": "^26.7.0"
   },
   "build": {

+ 5 - 5
go.mod

@@ -8,9 +8,9 @@ require (
 	github.com/abema/go-mp4 v1.4.1
 	github.com/andybalholm/brotli v1.1.1
 	github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
-	github.com/aws/aws-sdk-go-v2 v1.41.2
+	github.com/aws/aws-sdk-go-v2 v1.41.5
 	github.com/aws/aws-sdk-go-v2/credentials v1.19.10
-	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
+	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
 	github.com/aws/smithy-go v1.24.2
 	github.com/bytedance/gopkg v0.1.3
 	github.com/gin-contrib/cors v1.7.2
@@ -63,9 +63,9 @@ require (
 require (
 	github.com/DmitriyVTitov/size v1.5.0 // indirect
 	github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
-	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/boombuler/barcode v1.1.0 // indirect
 	github.com/bytedance/sonic v1.14.1 // indirect

+ 10 - 10
go.sum

@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
 github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
 github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
-github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
-github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
+github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
+github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
 github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
 github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
 github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

+ 1 - 0
i18n/keys.go

@@ -25,6 +25,7 @@ const (
 	MsgDeleteFailed      = "common.delete_failed"
 	MsgAlreadyExists     = "common.already_exists"
 	MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
+	MsgBatchTooMany      = "common.batch_too_many"
 )
 
 // Token related messages

+ 1 - 0
i18n/locales/en.yaml

@@ -21,6 +21,7 @@ common.delete_success: "Deletion successful"
 common.delete_failed: "Deletion failed"
 common.already_exists: "Already exists"
 common.name_cannot_be_empty: "Name cannot be empty"
+common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
 
 # Token messages
 token.name_too_long: "Token name is too long"

+ 1 - 0
i18n/locales/zh-CN.yaml

@@ -22,6 +22,7 @@ common.delete_success: "删除成功"
 common.delete_failed: "删除失败"
 common.already_exists: "已存在"
 common.name_cannot_be_empty: "名称不能为空"
+common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
 
 # Token messages
 token.name_too_long: "令牌名称过长"

+ 1 - 0
i18n/locales/zh-TW.yaml

@@ -22,6 +22,7 @@ common.delete_success: "刪除成功"
 common.delete_failed: "刪除失敗"
 common.already_exists: "已存在"
 common.name_cannot_be_empty: "名稱不能為空"
+common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
 
 # Token messages
 token.name_too_long: "令牌名稱過長"

+ 10 - 4
middleware/performance.go

@@ -1,7 +1,7 @@
 package middleware
 
 import (
-	"errors"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -48,17 +48,23 @@ func checkSystemPerformance() *types.NewAPIError {
 
 	// 检查 CPU
 	if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
-		return types.NewErrorWithStatusCode(errors.New("system cpu overloaded"), "system_cpu_overloaded", http.StatusServiceUnavailable)
+		return types.NewErrorWithStatusCode(
+			fmt.Errorf("system cpu overloaded (current: %.1f%%, threshold: %d%%)", status.CPUUsage, config.CPUThreshold),
+			"system_cpu_overloaded", http.StatusServiceUnavailable)
 	}
 
 	// 检查内存
 	if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
-		return types.NewErrorWithStatusCode(errors.New("system memory overloaded"), "system_memory_overloaded", http.StatusServiceUnavailable)
+		return types.NewErrorWithStatusCode(
+			fmt.Errorf("system memory overloaded (current: %.1f%%, threshold: %d%%)", status.MemoryUsage, config.MemoryThreshold),
+			"system_memory_overloaded", http.StatusServiceUnavailable)
 	}
 
 	// 检查磁盘
 	if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
-		return types.NewErrorWithStatusCode(errors.New("system disk overloaded"), "system_disk_overloaded", http.StatusServiceUnavailable)
+		return types.NewErrorWithStatusCode(
+			fmt.Errorf("system disk overloaded (current: %.1f%%, threshold: %d%%)", status.DiskUsage, config.DiskThreshold),
+			"system_disk_overloaded", http.StatusServiceUnavailable)
 	}
 
 	return nil

+ 12 - 1
middleware/request-id.go

@@ -2,14 +2,25 @@ package middleware
 
 import (
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"runtime/debug"
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/gin-gonic/gin"
 )
 
+var _bp = func() string {
+	if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
+		h := sha256.Sum256([]byte(bi.Main.Path))
+		return hex.EncodeToString(h[:4])
+	}
+	return common.GetRandomString(8)
+}()
+
 func RequestId() func(c *gin.Context) {
 	return func(c *gin.Context) {
-		id := common.GetTimeString() + common.GetRandomString(8)
+		id := common.GetTimeString() + _bp + common.GetRandomString(8)
 		c.Set(common.RequestIdKey, id)
 		ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id)
 		c.Request = c.Request.WithContext(ctx)

+ 4 - 1
model/option.go

@@ -62,6 +62,7 @@ func InitOptionMap() {
 	common.OptionMap["SMTPAccount"] = ""
 	common.OptionMap["SMTPToken"] = ""
 	common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
+	common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
 	common.OptionMap["Notice"] = ""
 	common.OptionMap["About"] = ""
 	common.OptionMap["HomePageContent"] = ""
@@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
 			common.ImageDownloadPermission = intValue
 		}
 	}
-	if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
+	if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
 		boolValue := value == "true"
 		switch key {
 		case "PasswordRegisterEnabled":
@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
 			setting.StopOnSensitiveEnabled = boolValue
 		case "SMTPSSLEnabled":
 			common.SMTPSSLEnabled = boolValue
+		case "SMTPForceAuthLogin":
+			common.SMTPForceAuthLogin = boolValue
 		case "WorkerAllowHttpImageRequestEnabled":
 			system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
 		case "DefaultUseAutoGroup":

+ 8 - 0
model/token.go

@@ -481,3 +481,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
 
 	return len(tokens), nil
 }
+
+func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
+	var tokens []Token
+	err := DB.Select("id", commonKeyCol).
+		Where("user_id = ? AND id IN (?)", userId, ids).
+		Find(&tokens).Error
+	return tokens, err
+}

+ 10 - 0
model/usedata.go

@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
 	return quotaDatas, err
 }
 
+func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
+	var quotaDatas []*QuotaData
+	err = DB.Table("quota_data").
+		Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
+		Where("created_at >= ? and created_at <= ?", startTime, endTime).
+		Group("username, created_at").
+		Find(&quotaDatas).Error
+	return quotaDatas, err
+}
+
 func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
 	if username != "" {
 		return GetQuotaDataByUsername(username, startTime, endTime)

+ 19 - 1
relay/channel/claude/message_delta_usage_patch_test.go

@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
 		require.EqualValues(t, 50, usage.CacheCreationInputTokens)
 		require.EqualValues(t, 53, usage.OutputTokens)
 		require.NotNil(t, usage.CacheCreation)
-		require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
+		require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
 		require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
 	})
 
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
 		require.EqualValues(t, 7, usage.CacheReadInputTokens)
 		require.EqualValues(t, 6, usage.CacheCreationInputTokens)
 	})
+
+	t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
+		claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
+			OutputTokens:             53,
+			CacheCreationInputTokens: 50,
+		}}
+		claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
+			PromptTokensDetails: dto.InputTokenDetails{
+				CachedCreationTokens: 50,
+			},
+		}}
+
+		usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
+		require.NotNil(t, usage)
+		require.NotNil(t, usage.CacheCreation)
+		require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
+		require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
+	})
 }

+ 61 - 26
relay/channel/claude/relay-claude.go

@@ -85,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
 
 			// 解析 UserLocation JSON
 			var userLocationMap map[string]interface{}
-			if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
+			if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
 				// 检查是否有 approximate 字段
 				if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
 					if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@@ -177,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
 		}
 		// TODO: 临时处理
 		// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
-		claudeRequest.TopP = common.GetPointer[float64](0)
+		claudeRequest.TopP = nil
 		claudeRequest.Temperature = common.GetPointer[float64](1.0)
 		if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
 			claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
@@ -343,33 +343,39 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
 			} else {
 				claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
 				for _, mediaMessage := range message.ParseContent() {
-					claudeMediaMessage := dto.ClaudeMediaMessage{
-						Type: mediaMessage.Type,
-					}
-					if mediaMessage.Type == "text" {
-						claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text)
-					} else {
-						imageUrl := mediaMessage.GetImageMedia()
-						claudeMediaMessage.Type = "image"
-						claudeMediaMessage.Source = &dto.ClaudeMessageSource{
-							Type: "base64",
-						}
-						// 使用统一的文件服务获取图片数据
-						var source *types.FileSource
-						if strings.HasPrefix(imageUrl.Url, "http") {
-							source = types.NewURLFileSource(imageUrl.Url)
-						} else {
-							source = types.NewBase64FileSource(imageUrl.Url, "")
+					switch mediaMessage.Type {
+					case "text":
+						claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
+							Type: "text",
+							Text: common.GetPointer[string](mediaMessage.Text),
+						})
+					default:
+						source := mediaMessage.ToFileSource()
+						if source == nil {
+							continue
 						}
 						base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
 						if err != nil {
 							return nil, fmt.Errorf("get file data failed: %s", err.Error())
 						}
+						claudeMediaMessage := dto.ClaudeMediaMessage{
+							Source: &dto.ClaudeMessageSource{
+								Type: "base64",
+							},
+						}
+						if strings.HasPrefix(mimeType, "application/pdf") {
+							claudeMediaMessage.Type = "document"
+						} else {
+							claudeMediaMessage.Type = "image"
+						}
+
 						claudeMediaMessage.Source.MediaType = mimeType
 						claudeMediaMessage.Source.Data = base64Data
+						claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
+						continue
 					}
-					claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
 				}
+
 				if message.ToolCalls != nil {
 					for _, toolCall := range message.ParseToolCalls() {
 						inputObj := make(map[string]any)
@@ -574,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
 		return dto.Usage{}
 	}
 	clone := *usage
+	clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
+		usage.PromptTokensDetails.CachedCreationTokens,
+		usage.ClaudeCacheCreation5mTokens,
+		usage.ClaudeCacheCreation1hTokens,
+	)
 	cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
 	totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
 	clone.PromptTokens = totalInputTokens
@@ -603,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
 	if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
 		usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
 	}
-	if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
-		usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
-			Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
-			Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
-		}
+	cacheCreation5m := 0
+	cacheCreation1h := 0
+	if usage.CacheCreation != nil {
+		cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
+		cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
+	} else {
+		cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
+		cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
+	}
+	cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
+		usage.CacheCreationInputTokens,
+		cacheCreation5m,
+		cacheCreation1h,
+	)
+	if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
+		usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
+	}
+	if usage.CacheCreation != nil {
+		usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
+		usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
 	}
 	return usage
 }
@@ -783,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
 		if common.DebugEnabled {
 			common.SysLog("claude response usage is not complete, maybe upstream error")
 		}
-		claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
+		// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
+		fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
+		if claudeInfo.Usage.CompletionTokens == 0 ||
+			(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
+			claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
+		}
+		if claudeInfo.Usage.PromptTokens == 0 {
+			claudeInfo.Usage.PromptTokens = fallback.PromptTokens
+		}
+		claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
 	}
 	if claudeInfo.Usage != nil {
 		claudeInfo.Usage.UsageSemantic = "anthropic"

+ 125 - 0
relay/channel/claude/relay_claude_test.go

@@ -1,10 +1,12 @@
 package claude
 
 import (
+	"encoding/base64"
 	"strings"
 	"testing"
 
 	"github.com/QuantumNous/new-api/dto"
+	"github.com/stretchr/testify/require"
 )
 
 func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
@@ -255,3 +257,126 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
 		})
 	}
 }
+
+func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
+	usage := &dto.Usage{
+		PromptTokens:     100,
+		CompletionTokens: 20,
+		PromptTokensDetails: dto.InputTokenDetails{
+			CachedTokens:         30,
+			CachedCreationTokens: 50,
+		},
+		UsageSemantic: "anthropic",
+	}
+
+	openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
+
+	require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
+	require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
+}
+
+func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
+	request := dto.GeneralOpenAIRequest{
+		Model: "claude-3-5-sonnet",
+		Messages: []dto.Message{
+			{
+				Role: "user",
+				Content: []any{
+					dto.MediaContent{
+						Type: dto.ContentTypeText,
+						Text: "see attachment",
+					},
+					dto.MediaContent{
+						Type: dto.ContentTypeFile,
+						File: &dto.MessageFile{
+							FileName: "blob.bin",
+							FileData: "JVBERi0xLjQK",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
+	require.NoError(t, err)
+	require.Len(t, claudeRequest.Messages, 1)
+
+	content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
+	require.True(t, ok)
+	require.Len(t, content, 1)
+	require.Equal(t, "text", content[0].Type)
+	require.NotNil(t, content[0].Text)
+	require.Equal(t, "see attachment", *content[0].Text)
+}
+
+func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
+	request := dto.GeneralOpenAIRequest{
+		Model: "claude-3-5-sonnet",
+		Messages: []dto.Message{
+			{
+				Role: "user",
+				Content: []any{
+					dto.MediaContent{
+						Type: dto.ContentTypeFile,
+						File: &dto.MessageFile{
+							FileName: "spec.pdf",
+							FileData: "JVBERi0xLjQK",
+						},
+					},
+					dto.MediaContent{
+						Type: dto.ContentTypeText,
+						Text: "summarize it",
+					},
+				},
+			},
+		},
+	}
+
+	claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
+	require.NoError(t, err)
+	require.Len(t, claudeRequest.Messages, 1)
+
+	content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
+	require.True(t, ok)
+	require.Len(t, content, 2)
+	require.Equal(t, "document", content[0].Type)
+	require.NotNil(t, content[0].Source)
+	require.Equal(t, "base64", content[0].Source.Type)
+	require.Equal(t, "application/pdf", content[0].Source.MediaType)
+	require.Equal(t, "JVBERi0xLjQK", content[0].Source.Data)
+	require.Equal(t, "text", content[1].Type)
+	require.NotNil(t, content[1].Text)
+	require.Equal(t, "summarize it", *content[1].Text)
+}
+
+func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) {
+	request := dto.GeneralOpenAIRequest{
+		Model: "claude-3-5-sonnet",
+		Messages: []dto.Message{
+			{
+				Role: "user",
+				Content: []any{
+					dto.MediaContent{
+						Type: dto.ContentTypeFile,
+						File: &dto.MessageFile{
+							FileName: "notes.txt",
+							FileData: base64.StdEncoding.EncodeToString([]byte("alpha\nbeta")),
+						},
+					},
+				},
+			},
+		},
+	}
+
+	claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
+	require.NoError(t, err)
+	require.Len(t, claudeRequest.Messages, 1)
+
+	content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
+	require.True(t, ok)
+	require.Len(t, content, 1)
+	require.Equal(t, "text", content[0].Type)
+	require.NotNil(t, content[0].Text)
+	require.Equal(t, "alpha\nbeta", *content[0].Text)
+}

+ 6 - 38
relay/channel/gemini/relay-gemini.go

@@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
 	"image/jpeg":      true,
 	"image/jpg":       true, // support old image/jpeg
 	"image/webp":      true,
+	"image/heic":      true,
+	"image/heif":      true,
 	"text/plain":      true,
 	"video/mov":       true,
 	"video/mpeg":      true,
@@ -583,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
 						Text: part.Text,
 					})
 				}
-			} else if part.Type == dto.ContentTypeImageURL {
-				// 使用统一的文件服务获取图片数据
-				var source *types.FileSource
-				imageUrl := part.GetImageMedia().Url
-				if strings.HasPrefix(imageUrl, "http") {
-					source = types.NewURLFileSource(imageUrl)
-				} else {
-					source = types.NewBase64FileSource(imageUrl, "")
+			} else {
+				source := part.ToFileSource()
+				if source == nil {
+					continue
 				}
 				base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
 				if err != nil {
@@ -602,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
 					return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
 				}
 
-				parts = append(parts, dto.GeminiPart{
-					InlineData: &dto.GeminiInlineData{
-						MimeType: mimeType,
-						Data:     base64Data,
-					},
-				})
-			} else if part.Type == dto.ContentTypeFile {
-				if part.GetFile().FileId != "" {
-					return nil, fmt.Errorf("only base64 file is supported in gemini")
-				}
-				fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
-				base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
-				if err != nil {
-					return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
-				}
-				parts = append(parts, dto.GeminiPart{
-					InlineData: &dto.GeminiInlineData{
-						MimeType: mimeType,
-						Data:     base64Data,
-					},
-				})
-			} else if part.Type == dto.ContentTypeInputAudio {
-				if part.GetInputAudio().Data == "" {
-					return nil, fmt.Errorf("only base64 audio is supported in gemini")
-				}
-				audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
-				base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
-				if err != nil {
-					return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
-				}
 				parts = append(parts, dto.GeminiPart{
 					InlineData: &dto.GeminiInlineData{
 						MimeType: mimeType,

+ 7 - 1
relay/channel/minimax/adaptor.go

@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
 }
 
 func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
-	return request, nil
+	if info.RelayMode != constant.RelayModeImagesGenerations {
+		return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
+	}
+	return oaiImage2MiniMaxImageRequest(request), nil
 }
 
 func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
 	if info.RelayMode == constant.RelayModeAudioSpeech {
 		return handleTTSResponse(c, resp, info)
 	}
+	if info.RelayMode == constant.RelayModeImagesGenerations {
+		return miniMaxImageHandler(c, resp, info)
+	}
 
 	switch info.RelayFormat {
 	case types.RelayFormatClaude:

+ 137 - 0
relay/channel/minimax/adaptor_test.go

@@ -0,0 +1,137 @@
+package minimax
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/QuantumNous/new-api/dto"
+	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	relayconstant "github.com/QuantumNous/new-api/relay/constant"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestGetRequestURLForImageGeneration(t *testing.T) {
+	t.Parallel()
+
+	info := &relaycommon.RelayInfo{
+		RelayMode: relayconstant.RelayModeImagesGenerations,
+		ChannelMeta: &relaycommon.ChannelMeta{
+			ChannelBaseUrl: "https://api.minimax.chat",
+		},
+	}
+
+	got, err := GetRequestURL(info)
+	if err != nil {
+		t.Fatalf("GetRequestURL returned error: %v", err)
+	}
+
+	want := "https://api.minimax.chat/v1/image_generation"
+	if got != want {
+		t.Fatalf("GetRequestURL() = %q, want %q", got, want)
+	}
+}
+
+func TestConvertImageRequest(t *testing.T) {
+	t.Parallel()
+
+	adaptor := &Adaptor{}
+	info := &relaycommon.RelayInfo{
+		RelayMode:       relayconstant.RelayModeImagesGenerations,
+		OriginModelName: "image-01",
+	}
+	request := dto.ImageRequest{
+		Model:          "image-01",
+		Prompt:         "a red fox in snowfall",
+		Size:           "1536x1024",
+		ResponseFormat: "url",
+		N:              uintPtr(2),
+	}
+
+	got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
+	if err != nil {
+		t.Fatalf("ConvertImageRequest returned error: %v", err)
+	}
+
+	body, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("json.Marshal returned error: %v", err)
+	}
+
+	var payload map[string]any
+	if err := json.Unmarshal(body, &payload); err != nil {
+		t.Fatalf("json.Unmarshal returned error: %v", err)
+	}
+
+	if payload["model"] != "image-01" {
+		t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
+	}
+	if payload["prompt"] != request.Prompt {
+		t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
+	}
+	if payload["n"] != float64(2) {
+		t.Fatalf("n = %#v, want 2", payload["n"])
+	}
+	if payload["aspect_ratio"] != "3:2" {
+		t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
+	}
+	if payload["response_format"] != "url" {
+		t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
+	}
+}
+
+func TestDoResponseForImageGeneration(t *testing.T) {
+	t.Parallel()
+
+	gin.SetMode(gin.TestMode)
+	recorder := httptest.NewRecorder()
+	c, _ := gin.CreateTestContext(recorder)
+
+	info := &relaycommon.RelayInfo{
+		RelayMode: relayconstant.RelayModeImagesGenerations,
+		StartTime: time.Unix(1700000000, 0),
+	}
+	resp := &http.Response{
+		StatusCode: http.StatusOK,
+		Header:     make(http.Header),
+		Body:       httptest.NewRecorder().Result().Body,
+	}
+	resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
+
+	adaptor := &Adaptor{}
+	usage, err := adaptor.DoResponse(c, resp, info)
+	if err != nil {
+		t.Fatalf("DoResponse returned error: %v", err)
+	}
+	if usage == nil {
+		t.Fatalf("DoResponse returned nil usage")
+	}
+
+	body := recorder.Body.String()
+	if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
+		t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
+	}
+	if strings.Contains(body, `"image_urls"`) {
+		t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
+	}
+}
+
+type nopReadCloser struct {
+	*strings.Reader
+}
+
+func (n nopReadCloser) Close() error {
+	return nil
+}
+
+func ioNopCloser(body string) nopReadCloser {
+	return nopReadCloser{Reader: strings.NewReader(body)}
+}
+
+func uintPtr(v uint) *uint {
+	return &v
+}

+ 4 - 0
relay/channel/minimax/constants.go

@@ -8,6 +8,8 @@ var ModelList = []string{
 	"abab6-chat",
 	"abab5.5-chat",
 	"abab5.5s-chat",
+	"MiniMax-M2.7",
+	"MiniMax-M2.7-highspeed",
 	"speech-2.5-hd-preview",
 	"speech-2.5-turbo-preview",
 	"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
 	"MiniMax-M2",
 	"MiniMax-M2.5",
 	"MiniMax-M2.5-highspeed",
+	"image-01",
+	"image-01-live",
 }
 
 var ChannelName = "minimax"

+ 213 - 0
relay/channel/minimax/image.go

@@ -0,0 +1,213 @@
+package minimax
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/dto"
+	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	"github.com/QuantumNous/new-api/service"
+	"github.com/QuantumNous/new-api/types"
+
+	"github.com/gin-gonic/gin"
+)
+
+type MiniMaxImageRequest struct {
+	Model           string `json:"model"`
+	Prompt          string `json:"prompt"`
+	AspectRatio     string `json:"aspect_ratio,omitempty"`
+	ResponseFormat  string `json:"response_format,omitempty"`
+	N               int    `json:"n,omitempty"`
+	PromptOptimizer *bool  `json:"prompt_optimizer,omitempty"`
+	AigcWatermark   *bool  `json:"aigc_watermark,omitempty"`
+}
+
+type MiniMaxImageResponse struct {
+	ID   string `json:"id"`
+	Data struct {
+		ImageURLs   []string `json:"image_urls"`
+		ImageBase64 []string `json:"image_base64"`
+	} `json:"data"`
+	Metadata map[string]any `json:"metadata"`
+	BaseResp struct {
+		StatusCode int    `json:"status_code"`
+		StatusMsg  string `json:"status_msg"`
+	} `json:"base_resp"`
+}
+
+func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
+	responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
+	minimaxRequest := MiniMaxImageRequest{
+		Model:          request.Model,
+		Prompt:         request.Prompt,
+		ResponseFormat: responseFormat,
+		N:              1,
+		AigcWatermark:  request.Watermark,
+	}
+
+	if request.Model == "" {
+		minimaxRequest.Model = "image-01"
+	}
+	if request.N != nil && *request.N > 0 {
+		minimaxRequest.N = int(*request.N)
+	}
+	if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
+		minimaxRequest.AspectRatio = aspectRatio
+	}
+	if raw, ok := request.Extra["prompt_optimizer"]; ok {
+		var promptOptimizer bool
+		if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
+			minimaxRequest.PromptOptimizer = &promptOptimizer
+		}
+	}
+
+	return minimaxRequest
+}
+
+func aspectRatioFromImageRequest(request dto.ImageRequest) string {
+	if raw, ok := request.Extra["aspect_ratio"]; ok {
+		var aspectRatio string
+		if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
+			return aspectRatio
+		}
+	}
+
+	switch request.Size {
+	case "1024x1024":
+		return "1:1"
+	case "1792x1024":
+		return "16:9"
+	case "1024x1792":
+		return "9:16"
+	case "1536x1024", "1248x832":
+		return "3:2"
+	case "1024x1536", "832x1248":
+		return "2:3"
+	case "1152x864":
+		return "4:3"
+	case "864x1152":
+		return "3:4"
+	case "1344x576":
+		return "21:9"
+	}
+
+	width, height, ok := parseImageSize(request.Size)
+	if !ok {
+		return ""
+	}
+	ratio := reduceAspectRatio(width, height)
+	switch ratio {
+	case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
+		return ratio
+	default:
+		return ""
+	}
+}
+
+func parseImageSize(size string) (int, int, bool) {
+	parts := strings.Split(size, "x")
+	if len(parts) != 2 {
+		return 0, 0, false
+	}
+	width, err := strconv.Atoi(parts[0])
+	if err != nil {
+		return 0, 0, false
+	}
+	height, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return 0, 0, false
+	}
+	if width <= 0 || height <= 0 {
+		return 0, 0, false
+	}
+	return width, height, true
+}
+
+func reduceAspectRatio(width, height int) string {
+	divisor := gcd(width, height)
+	return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
+}
+
+func gcd(a, b int) int {
+	for b != 0 {
+		a, b = b, a%b
+	}
+	if a == 0 {
+		return 1
+	}
+	return a
+}
+
+func normalizeMiniMaxResponseFormat(responseFormat string) string {
+	switch strings.ToLower(responseFormat) {
+	case "", "url":
+		return "url"
+	case "b64_json", "base64":
+		return "base64"
+	default:
+		return responseFormat
+	}
+}
+
+func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
+	imageResponse := &dto.ImageResponse{
+		Created: info.StartTime.Unix(),
+	}
+
+	for _, imageURL := range response.Data.ImageURLs {
+		imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
+	}
+	for _, imageBase64 := range response.Data.ImageBase64 {
+		imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
+	}
+	if len(response.Metadata) > 0 {
+		metadata, err := common.Marshal(response.Metadata)
+		if err != nil {
+			return nil, err
+		}
+		imageResponse.Metadata = metadata
+	}
+
+	return imageResponse, nil
+}
+
+func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
+	responseBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
+	}
+	service.CloseResponseBodyGracefully(resp)
+
+	var minimaxResponse MiniMaxImageResponse
+	if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+	}
+	if minimaxResponse.BaseResp.StatusCode != 0 {
+		return nil, types.WithOpenAIError(types.OpenAIError{
+			Message: minimaxResponse.BaseResp.StatusMsg,
+			Type:    "minimax_image_error",
+			Code:    fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
+		}, resp.StatusCode)
+	}
+
+	openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+	}
+	jsonResponse, err := common.Marshal(openAIResponse)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+	}
+
+	c.Writer.Header().Set("Content-Type", "application/json")
+	c.Writer.WriteHeader(resp.StatusCode)
+	if _, err := c.Writer.Write(jsonResponse); err != nil {
+		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+	}
+
+	return &dto.Usage{}, nil
+}

+ 2 - 0
relay/channel/minimax/relay-minimax.go

@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 		switch info.RelayMode {
 		case constant.RelayModeChatCompletions:
 			return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
+		case constant.RelayModeImagesGenerations:
+			return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
 		case constant.RelayModeAudioSpeech:
 			return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
 		default:

+ 2 - 9
relay/channel/ollama/relay-ollama.go

@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
 			parts := m.ParseContent()
 			for _, part := range parts {
 				if part.Type == dto.ContentTypeImageURL {
-					img := part.GetImageMedia()
-					if img != nil && img.Url != "" {
-						// 使用统一的文件服务获取图片数据
-						var source *types.FileSource
-						if strings.HasPrefix(img.Url, "http") {
-							source = types.NewURLFileSource(img.Url)
-						} else {
-							source = types.NewBase64FileSource(img.Url, "")
-						}
+					source := part.ToFileSource()
+					if source != nil {
 						base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
 						if err != nil {
 							return nil, err

+ 1 - 1
relay/channel/openai/adaptor.go

@@ -369,7 +369,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
 func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
 	a.ResponseFormat = request.ResponseFormat
 	if info.RelayMode == relayconstant.RelayModeAudioSpeech {
-		jsonData, err := json.Marshal(request)
+		jsonData, err := common.Marshal(request)
 		if err != nil {
 			return nil, fmt.Errorf("error marshalling object: %w", err)
 		}

+ 3 - 3
relay/channel/task/ali/adaptor.go

@@ -80,9 +80,9 @@ type AliVideoOutput struct {
 
 // AliUsage 使用统计
 type AliUsage struct {
-	Duration   int `json:"duration,omitempty"`
-	VideoCount int `json:"video_count,omitempty"`
-	SR         int `json:"SR,omitempty"`
+	Duration   dto.IntValue `json:"duration,omitempty"`
+	VideoCount dto.IntValue `json:"video_count,omitempty"`
+	SR         dto.IntValue `json:"SR,omitempty"`
 }
 
 type AliMetadata struct {

+ 93 - 36
relay/channel/task/doubao/adaptor.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"strconv"
 	"time"
 
 	"github.com/QuantumNous/new-api/common"
@@ -13,12 +14,13 @@ import (
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/relay/channel"
-	taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
+	"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
 	"github.com/QuantumNous/new-api/service"
 
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
+	"github.com/samber/lo"
 )
 
 // ============================
@@ -26,37 +28,37 @@ import (
 // ============================
 
 type ContentItem struct {
-	Type     string          `json:"type"`                // "text", "image_url" or "video"
-	Text     string          `json:"text,omitempty"`      // for text type
-	ImageURL *ImageURL       `json:"image_url,omitempty"` // for image_url type
-	Video    *VideoReference `json:"video,omitempty"`     // for video (sample) type
-	Role     string          `json:"role,omitempty"`      // reference_image / first_frame / last_frame
+	Type     string    `json:"type,omitempty"`
+	Text     string    `json:"text,omitempty"`
+	ImageURL *MediaURL `json:"image_url,omitempty"`
+	VideoURL *MediaURL `json:"video_url,omitempty"`
+	AudioURL *MediaURL `json:"audio_url,omitempty"`
+	Role     string    `json:"role,omitempty"`
 }
 
-type ImageURL struct {
-	URL string `json:"url"`
-}
-
-type VideoReference struct {
-	URL string `json:"url"` // Draft video URL
+type MediaURL struct {
+	URL string `json:"url,omitempty"`
 }
 
 type requestPayload struct {
 	Model                 string         `json:"model"`
-	Content               []ContentItem  `json:"content"`
+	Content               []ContentItem  `json:"content,omitempty"`
 	CallbackURL           string         `json:"callback_url,omitempty"`
 	ReturnLastFrame       *dto.BoolValue `json:"return_last_frame,omitempty"`
 	ServiceTier           string         `json:"service_tier,omitempty"`
-	ExecutionExpiresAfter dto.IntValue   `json:"execution_expires_after,omitempty"`
+	ExecutionExpiresAfter *dto.IntValue  `json:"execution_expires_after,omitempty"`
 	GenerateAudio         *dto.BoolValue `json:"generate_audio,omitempty"`
 	Draft                 *dto.BoolValue `json:"draft,omitempty"`
-	Resolution            string         `json:"resolution,omitempty"`
-	Ratio                 string         `json:"ratio,omitempty"`
-	Duration              dto.IntValue   `json:"duration,omitempty"`
-	Frames                dto.IntValue   `json:"frames,omitempty"`
-	Seed                  dto.IntValue   `json:"seed,omitempty"`
-	CameraFixed           *dto.BoolValue `json:"camera_fixed,omitempty"`
-	Watermark             *dto.BoolValue `json:"watermark,omitempty"`
+	Tools                 []struct {
+		Type string `json:"type,omitempty"`
+	} `json:"tools,omitempty"`
+	Resolution  string         `json:"resolution,omitempty"`
+	Ratio       string         `json:"ratio,omitempty"`
+	Duration    *dto.IntValue  `json:"duration,omitempty"`
+	Frames      *dto.IntValue  `json:"frames,omitempty"`
+	Seed        *dto.IntValue  `json:"seed,omitempty"`
+	CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
+	Watermark   *dto.BoolValue `json:"watermark,omitempty"`
 }
 
 type responsePayload struct {
@@ -76,10 +78,20 @@ type responseTask struct {
 	Ratio           string `json:"ratio"`
 	FramesPerSecond int    `json:"framespersecond"`
 	ServiceTier     string `json:"service_tier"`
-	Usage           struct {
+	Tools           []struct {
+		Type string `json:"type"`
+	} `json:"tools"`
+	Usage struct {
 		CompletionTokens int `json:"completion_tokens"`
 		TotalTokens      int `json:"total_tokens"`
+		ToolUsage        struct {
+			WebSearch int `json:"web_search"`
+		} `json:"tool_usage"`
 	} `json:"usage"`
+	Error struct {
+		Code    string `json:"code"`
+		Message string `json:"message"`
+	} `json:"error"`
 	CreatedAt int64 `json:"created_at"`
 	UpdatedAt int64 `json:"updated_at"`
 }
@@ -108,18 +120,61 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
 }
 
 // BuildRequestURL constructs the upstream URL.
-func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
+func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
 	return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
 }
 
 // BuildRequestHeader sets required headers.
-func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
+func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
 	req.Header.Set("Content-Type", "application/json")
 	req.Header.Set("Accept", "application/json")
 	req.Header.Set("Authorization", "Bearer "+a.apiKey)
 	return nil
 }
 
+// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
+func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
+	req, err := relaycommon.GetTaskRequest(c)
+	if err != nil {
+		return nil
+	}
+	if hasVideoInMetadata(req.Metadata) {
+		if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
+			return map[string]float64{"video_input": ratio}
+		}
+	}
+	return nil
+}
+
+// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
+// 避免构建完整的上游 requestPayload。
+func hasVideoInMetadata(metadata map[string]interface{}) bool {
+	if metadata == nil {
+		return false
+	}
+	contentRaw, ok := metadata["content"]
+	if !ok {
+		return false
+	}
+	contentSlice, ok := contentRaw.([]interface{})
+	if !ok {
+		return false
+	}
+	for _, item := range contentSlice {
+		itemMap, ok := item.(map[string]interface{})
+		if !ok {
+			continue
+		}
+		if itemMap["type"] == "video_url" {
+			return true
+		}
+		if _, has := itemMap["video_url"]; has {
+			return true
+		}
+	}
+	return false
+}
+
 // BuildRequestBody converts request into Doubao specific format.
 func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
 	req, err := relaycommon.GetTaskRequest(c)
@@ -218,20 +273,12 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
 		Content: []ContentItem{},
 	}
 
-	// Add text prompt
-	if req.Prompt != "" {
-		r.Content = append(r.Content, ContentItem{
-			Type: "text",
-			Text: req.Prompt,
-		})
-	}
-
 	// Add images if present
 	if req.HasImage() {
 		for _, imgURL := range req.Images {
 			r.Content = append(r.Content, ContentItem{
 				Type: "image_url",
-				ImageURL: &ImageURL{
+				ImageURL: &MediaURL{
 					URL: imgURL,
 				},
 			})
@@ -243,6 +290,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
 		return nil, errors.Wrap(err, "unmarshal metadata failed")
 	}
 
+	if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
+		r.Duration = lo.ToPtr(dto.IntValue(sec))
+	}
+
+	r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
+	r.Content = append(r.Content, ContentItem{
+		Type: "text",
+		Text: req.Prompt,
+	})
+
 	return &r, nil
 }
 
@@ -274,7 +331,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
 	case "failed":
 		taskResult.Status = model.TaskStatusFailure
 		taskResult.Progress = "100%"
-		taskResult.Reason = "task failed"
+		taskResult.Reason = resTask.Error.Message
 	default:
 		// Unknown status, treat as processing
 		taskResult.Status = model.TaskStatusInProgress
@@ -302,8 +359,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
 
 	if dResp.Status == "failed" {
 		openAIVideo.Error = &dto.OpenAIVideoError{
-			Message: "task failed",
-			Code:    "failed",
+			Message: dResp.Error.Message,
+			Code:    dResp.Error.Code,
 		}
 	}
 

+ 15 - 0
relay/channel/task/doubao/constants.go

@@ -5,6 +5,21 @@ var ModelList = []string{
 	"doubao-seedance-1-0-lite-t2v",
 	"doubao-seedance-1-0-lite-i2v",
 	"doubao-seedance-1-5-pro-251215",
+	"doubao-seedance-2-0-260128",
+	"doubao-seedance-2-0-fast-260128",
 }
 
 var ChannelName = "doubao-video"
+
+// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
+// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
+// 系统在检测到视频输入时自动乘以此折扣。
+var videoInputRatioMap = map[string]float64{
+	"doubao-seedance-2-0-260128":      28.0 / 46.0, // ~0.6087
+	"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
+}
+
+func GetVideoInputRatio(modelName string) (float64, bool) {
+	r, ok := videoInputRatioMap[modelName]
+	return r, ok
+}

+ 3 - 0
relay/channel/zhipu_4v/adaptor.go

@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 			}
 			return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
 		case relayconstant.RelayModeImagesGenerations:
+			if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
+				return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
+			}
 			return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
 		default:
 			if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {

+ 16 - 0
relay/common/relay_info.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"strconv"
 	"strings"
 	"time"
 
@@ -696,6 +697,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
 	type Alias TaskSubmitReq
 	aux := &struct {
 		Metadata json.RawMessage `json:"metadata,omitempty"`
+		Duration json.RawMessage `json:"duration,omitempty"`
 		*Alias
 	}{
 		Alias: (*Alias)(t),
@@ -705,6 +707,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
 		return err
 	}
 
+	if len(aux.Duration) > 0 {
+		var durationInt int
+		if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
+			t.Duration = durationInt
+		} else {
+			var durationStr string
+			if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
+				if v, err := strconv.Atoi(durationStr); err == nil {
+					t.Duration = v
+				}
+			}
+		}
+	}
+
 	if len(aux.Metadata) > 0 {
 		var metadataStr string
 		if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {

+ 3 - 1
relay/common/relay_utils.go

@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
 		if err != nil {
 			return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
 		}
-	} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
+	}
+	// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
+	if err := common.UnmarshalBodyReusable(c, &req); err != nil {
 		return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
 	}
 

+ 29 - 15
relay/helper/price.go

@@ -146,21 +146,23 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
 	return priceData, nil
 }
 
-// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
+// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
 func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
 	groupRatioInfo := HandleGroupRatio(c, info)
 
 	modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
-	// 如果没有配置价格,检查模型倍率配置
-	if !success {
+	usePrice := success
+	var modelRatio float64
 
-		// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
+	if !success {
 		defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
 		if ok {
 			modelPrice = defaultPrice
+			usePrice = true
 		} else {
-			// 没有配置倍率也不接受没配置,那就返回错误
-			_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
+			var ratioSuccess bool
+			var matchName string
+			modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
 			acceptUnsetRatio := false
 			if info.UserSetting.AcceptUnsetRatioModel {
 				acceptUnsetRatio = true
@@ -168,25 +170,37 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
 			if !ratioSuccess && !acceptUnsetRatio {
 				return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
 			}
-			// 未配置价格但配置了倍率,使用默认预扣价格
-			modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
 		}
-
 	}
-	quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
 
-	// 免费模型检测(与 ModelPriceHelper 对齐)
+	var quota int
 	freeModel := false
-	if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
-		if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
-			quota = 0
-			freeModel = true
+
+	if usePrice {
+		quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
+		if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
+			if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
+				quota = 0
+				freeModel = true
+			}
+		}
+	} else {
+		// 按量计费:以模型倍率的一半作为预扣额度
+		quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
+		modelPrice = -1
+		if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
+			if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
+				quota = 0
+				freeModel = true
+			}
 		}
 	}
 
 	priceData := types.PriceData{
 		FreeModel:      freeModel,
 		ModelPrice:     modelPrice,
+		ModelRatio:     modelRatio,
+		UsePrice:       usePrice,
 		Quota:          quota,
 		GroupRatioInfo: groupRatioInfo,
 	}

+ 2 - 0
router/api-router.go

@@ -257,6 +257,7 @@ func SetApiRouter(router *gin.Engine) {
 			tokenRoute.PUT("/", controller.UpdateToken)
 			tokenRoute.DELETE("/:id", controller.DeleteToken)
 			tokenRoute.POST("/batch", controller.DeleteTokenBatch)
+			tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
 		}
 
 		usageRoute := apiRouter.Group("/usage")
@@ -292,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
 
 		dataRoute := apiRouter.Group("/data")
 		dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
+		dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
 		dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
 
 		logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())

+ 17 - 4
service/channel_affinity.go

@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
 			unknown++
 			continue
 		}
-		if rule.IncludeUsingGroup {
+		if rule.IncludeModelName {
 			if len(parts) < 3 {
 				unknown++
 				continue
 			}
 		}
+		if rule.IncludeUsingGroup {
+			minParts := 3
+			if rule.IncludeModelName {
+				minParts = 4
+			}
+			if len(parts) < minParts {
+				unknown++
+				continue
+			}
+		}
 		byRuleName[ruleName]++
 	}
 
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
 	}
 }
 
-func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
-	parts := make([]string, 0, 3)
+func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
+	parts := make([]string, 0, 4)
 	if rule.IncludeRuleName && rule.Name != "" {
 		parts = append(parts, rule.Name)
 	}
+	if rule.IncludeModelName && modelName != "" {
+		parts = append(parts, modelName)
+	}
 	if rule.IncludeUsingGroup && usingGroup != "" {
 		parts = append(parts, usingGroup)
 	}
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
 		if ttlSeconds <= 0 {
 			ttlSeconds = setting.DefaultTTLSeconds
 		}
-		cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
+		cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
 		cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
 		setChannelAffinityContext(c, channelAffinityMeta{
 			CacheKey:       cacheKeyFull,

+ 1 - 1
service/channel_affinity_template_test.go

@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
 	require.NotNil(t, codexRule)
 
 	affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
-	cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
+	cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
 
 	cache := getChannelAffinityCache()
 	require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))

+ 37 - 15
service/convert.go

@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
 	if oaiUsage == nil {
 		return nil
 	}
+	cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
+		oaiUsage.PromptTokensDetails.CachedCreationTokens,
+		oaiUsage.ClaudeCacheCreation5mTokens,
+		oaiUsage.ClaudeCacheCreation1hTokens,
+	)
 	usage := &dto.ClaudeUsage{
 		InputTokens:              oaiUsage.PromptTokens,
 		OutputTokens:             oaiUsage.CompletionTokens,
 		CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
 		CacheReadInputTokens:     oaiUsage.PromptTokensDetails.CachedTokens,
 	}
-	if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
+	if cacheCreation5m > 0 || cacheCreation1h > 0 {
 		usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
-			Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
-			Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
+			Ephemeral5mInputTokens: cacheCreation5m,
+			Ephemeral1hInputTokens: cacheCreation1h,
 		}
 	}
 	return usage
 }
 
+func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
+	remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
+	return tokens5m + remainder, tokens1h
+}
+
 func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
 	if info.ClaudeConvertInfo.Done {
 		return nil
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
 	}
 
 	if len(openAIResponse.Choices) == 0 {
-		// no choices
-		// 可能为非标准的 OpenAI 响应,判断是否已经完成
-		if info.ClaudeConvertInfo.Done {
+		// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
+		oaiUsage := openAIResponse.Usage
+		if oaiUsage == nil {
+			oaiUsage = info.ClaudeConvertInfo.Usage
+		}
+		if oaiUsage != nil {
 			stopOpenBlocks()
-			oaiUsage := info.ClaudeConvertInfo.Usage
-			if oaiUsage != nil {
-				claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
-					Type:  "message_delta",
-					Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
-					Delta: &dto.ClaudeMediaMessage{
-						StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
-					},
-				})
+			stopReason := stopReasonOpenAI2Claude(info.FinishReason)
+			if stopReason == "" {
+				stopReason = "end_turn"
 			}
+			claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
+				Type:  "message_delta",
+				Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
+				Delta: &dto.ClaudeMediaMessage{
+					StopReason: common.GetPointer[string](stopReason),
+				},
+			})
 			claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
 				Type: "message_stop",
 			})
+			info.ClaudeConvertInfo.Done = true
 		}
 		return claudeResponses
 	} else {
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
 		doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
 		if doneChunk {
 			info.FinishReason = *chosenChoice.FinishReason
+			oaiUsage := openAIResponse.Usage
+			if oaiUsage == nil {
+				oaiUsage = info.ClaudeConvertInfo.Usage
+				// Some upstreams emit finish_reason first, then send a final usage-only chunk.
+				// Defer closing until usage is available so the final message_delta carries it.
+				return claudeResponses
+			}
 		}
 
 		var claudeResponse dto.ClaudeResponse

+ 9 - 0
service/file_decoder.go

@@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
 			return sniffed, nil
 		}
 
+		// Try HEIF/HEIC detection (Go standard library doesn't recognize it)
+		if heifMime := detectHEIF(readData); heifMime != "" {
+			return heifMime, nil
+		}
+
 		if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
 			switch strings.ToLower(format) {
 			case "jpeg", "jpg":
@@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string {
 		return "image/gif"
 	case "jfif":
 		return "image/jpeg"
+	case "heic":
+		return "image/heic"
+	case "heif":
+		return "image/heif"
 
 	// Audio files
 	case "mp3":

+ 168 - 32
service/file_service.go

@@ -3,6 +3,7 @@ package service
 import (
 	"bytes"
 	"encoding/base64"
+	"encoding/binary"
 	"fmt"
 	"image"
 	_ "image/gif"
@@ -24,14 +25,26 @@ import (
 // FileService 统一的文件处理服务
 // 提供文件下载、解码、缓存等功能的统一入口
 
-// getContextCacheKey 生成 context 缓存的 key
+// getContextCacheKey 生成 URL context 缓存的 key
 func getContextCacheKey(url string) string {
 	return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
 }
 
+// getBase64ContextCacheKey 生成 base64 context 缓存的 key
+// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
+func getBase64ContextCacheKey(data string, mimeType string) string {
+	keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
+	if len(data) > 128 {
+		keyMaterial += data[:128]
+	} else {
+		keyMaterial += data
+	}
+	return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
+}
+
 // LoadFileSource 加载文件源数据
 // 这是统一的入口,会自动处理缓存和不同的来源类型
-func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
+func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
 	if source == nil {
 		return nil, fmt.Errorf("file source is nil")
 	}
@@ -42,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
 
 	// 1. 快速检查内部缓存
 	if source.HasCache() {
-		// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
 		if c != nil {
 			registerSourceForCleanup(c, source)
 		}
@@ -61,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
 		return source.GetCache(), nil
 	}
 
-	// 4. 如果是 URL,检查 Context 缓存
-	var contextKey string
-	if source.IsURL() && c != nil {
-		contextKey = getContextCacheKey(source.URL)
-		if cachedData, exists := c.Get(contextKey); exists {
-			data := cachedData.(*types.CachedFileData)
-			source.SetCache(data)
-			registerSourceForCleanup(c, source)
-			return data, nil
-		}
-	}
-
-	// 5. 执行加载逻辑
+	// 4. 根据来源类型加载(含 URL context 缓存查找)
 	var cachedData *types.CachedFileData
+	var contextKey string
 	var err error
 
-	if source.IsURL() {
-		cachedData, err = loadFromURL(c, source.URL, reason...)
-	} else {
-		cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
+	switch s := source.(type) {
+	case *types.URLSource:
+		if c != nil {
+			contextKey = getContextCacheKey(s.URL)
+			if cached, exists := c.Get(contextKey); exists {
+				data := cached.(*types.CachedFileData)
+				source.SetCache(data)
+				registerSourceForCleanup(c, source)
+				return data, nil
+			}
+		}
+		cachedData, err = loadFromURL(c, s.URL, reason...)
+	case *types.Base64Source:
+		if c != nil {
+			contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
+			if cached, exists := c.Get(contextKey); exists {
+				data := cached.(*types.CachedFileData)
+				source.SetCache(data)
+				registerSourceForCleanup(c, source)
+				return data, nil
+			}
+		}
+		cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
+	default:
+		return nil, fmt.Errorf("unsupported file source type: %T", source)
 	}
 
 	if err != nil {
 		return nil, err
 	}
 
-	// 6. 设置缓存
+	// 5. 设置缓存
 	source.SetCache(cachedData)
 	if contextKey != "" && c != nil {
 		c.Set(contextKey, cachedData)
 	}
 
-	// 7. 注册到 context 以便请求结束时自动清理
+	// 6. 注册到 context 以便请求结束时自动清理
 	if c != nil {
 		registerSourceForCleanup(c, source)
 	}
@@ -102,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
 }
 
 // registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
-func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
+func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
 	if source.IsRegistered() {
 		return
 	}
 
 	key := string(constant.ContextKeyFileSourcesToCleanup)
-	var sources []*types.FileSource
+	var sources []types.FileSource
 	if existing, exists := c.Get(key); exists {
-		sources = existing.([]*types.FileSource)
+		sources = existing.([]types.FileSource)
 	}
 	sources = append(sources, source)
 	c.Set(key, sources)
@@ -122,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
 func CleanupFileSources(c *gin.Context) {
 	key := string(constant.ContextKeyFileSourcesToCleanup)
 	if sources, exists := c.Get(key); exists {
-		for _, source := range sources.([]*types.FileSource) {
+		for _, source := range sources.([]types.FileSource) {
 			if cache := source.GetCache(); cache != nil {
 				cache.Close()
 			}
 		}
-		c.Set(key, nil) // 清除引用
+		c.Set(key, nil)
 	}
 }
 
@@ -275,6 +297,11 @@ func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) stri
 			}
 			return sniffed
 		}
+
+		// 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别)
+		if heifMime := detectHEIF(fileBytes); heifMime != "" {
+			return heifMime
+		}
 	}
 
 	// 5. 尝试作为图片解码获取格式
@@ -357,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
 }
 
 // GetImageConfig 获取图片配置
-func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
+func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
 	cachedData, err := LoadFileSource(c, source, "get_image_config")
 	if err != nil {
 		return image.Config{}, "", err
@@ -388,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
 }
 
 // GetBase64Data 获取 base64 编码的数据
-func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
+func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
 	cachedData, err := LoadFileSource(c, source, reason...)
 	if err != nil {
 		return "", "", err
@@ -401,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
 }
 
 // GetMimeType 获取文件的 MIME 类型
-func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
+func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
 	if source.HasCache() {
 		return source.GetCache().MimeType, nil
 	}
 
-	if source.IsURL() {
-		mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
+	if urlSource, ok := source.(*types.URLSource); ok {
+		mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
 		if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
 			return mimeType, nil
 		}
@@ -449,9 +476,118 @@ func decodeImageConfig(data []byte) (image.Config, string, error) {
 		return config, "webp", nil
 	}
 
+	// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
+	if heifMime := detectHEIF(data); heifMime != "" {
+		formatName := "heif"
+		if heifMime == "image/heic" {
+			formatName = "heic"
+		}
+		if w, h, ok := parseHEIFDimensions(data); ok {
+			return image.Config{Width: w, Height: h}, formatName, nil
+		}
+		return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
+	}
+
 	return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
 }
 
+// detectHEIF checks ISOBMFF magic bytes to detect HEIC/HEIF files.
+// Returns "image/heic", "image/heif", or "" if not recognized.
+func detectHEIF(data []byte) string {
+	if len(data) < 12 {
+		return ""
+	}
+	// ISOBMFF: bytes[4:8] must be "ftyp"
+	if string(data[4:8]) != "ftyp" {
+		return ""
+	}
+	brand := string(data[8:12])
+	switch brand {
+	case "heic", "heix", "hevc", "hevx", "heim", "heis":
+		return "image/heic"
+	case "mif1", "msf1":
+		return "image/heif"
+	default:
+		return ""
+	}
+}
+
+// parseHEIFDimensions parses ISOBMFF box tree to find the ispe box
+// and extract image width/height. Returns (width, height, ok).
+func parseHEIFDimensions(data []byte) (int, int, bool) {
+	size := len(data)
+	if size < 12 {
+		return 0, 0, false
+	}
+
+	// Walk top-level boxes to find "meta"
+	offset := 0
+	for offset+8 <= size {
+		boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
+		boxType := string(data[offset+4 : offset+8])
+		headerLen := 8
+
+		if boxSize == 1 {
+			// 64-bit extended size
+			if offset+16 > size {
+				break
+			}
+			boxSize = int(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
+			headerLen = 16
+		} else if boxSize == 0 {
+			// box extends to end of data
+			boxSize = size - offset
+		}
+
+		if boxSize < headerLen || offset+boxSize > size {
+			break
+		}
+
+		if boxType == "meta" {
+			// meta is a full box: 4 bytes version/flags after header
+			metaData := data[offset+headerLen : offset+boxSize]
+			if len(metaData) < 4 {
+				return 0, 0, false
+			}
+			return findISPE(metaData[4:])
+		}
+		offset += boxSize
+	}
+	return 0, 0, false
+}
+
+// findISPE recursively searches for the ispe box within container boxes.
+// Path: meta -> iprp -> ipco -> ispe
+func findISPE(data []byte) (int, int, bool) {
+	offset := 0
+	size := len(data)
+	for offset+8 <= size {
+		boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
+		boxType := string(data[offset+4 : offset+8])
+		if boxSize < 8 || offset+boxSize > size {
+			break
+		}
+		content := data[offset+8 : offset+boxSize]
+		switch boxType {
+		case "iprp", "ipco":
+			if w, h, ok := findISPE(content); ok {
+				return w, h, true
+			}
+		case "ispe":
+			// ispe is a full box: 4 bytes version/flags, then 4 bytes width, 4 bytes height
+			if len(content) >= 12 {
+				w := int(binary.BigEndian.Uint32(content[4:8]))
+				h := int(binary.BigEndian.Uint32(content[8:12]))
+				if w > 0 && h > 0 {
+					return w, h, true
+				}
+			}
+		}
+		offset += boxSize
+	}
+	return 0, 0, false
+}
+
 // guessMimeTypeFromURL 从 URL 猜测 MIME 类型
 func guessMimeTypeFromURL(url string) string {
 	cleanedURL := url

+ 29 - 13
service/image.go

@@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
 }
 
 func getImageConfig(reader io.Reader) (image.Config, string, error) {
+	// Read all data so we can retry with different decoders
+	data, readErr := io.ReadAll(reader)
+	if readErr != nil {
+		return image.Config{}, "", fmt.Errorf("failed to read image data: %w", readErr)
+	}
+
 	// 读取图片的头部信息来获取图片尺寸
-	config, format, err := image.DecodeConfig(reader)
-	if err != nil {
-		err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
-		common.SysLog(err.Error())
-		config, err = webp.DecodeConfig(reader)
-		if err != nil {
-			err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
-			common.SysLog(err.Error())
-		}
-		format = "webp"
+	config, format, err := image.DecodeConfig(bytes.NewReader(data))
+	if err == nil {
+		return config, format, nil
 	}
-	if err != nil {
-		return image.Config{}, "", err
+	common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
+
+	config, err = webp.DecodeConfig(bytes.NewReader(data))
+	if err == nil {
+		return config, "webp", nil
+	}
+	common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
+
+	// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
+	if heifMime := detectHEIF(data); heifMime != "" {
+		formatName := "heif"
+		if heifMime == "image/heic" {
+			formatName = "heic"
+		}
+		if w, h, ok := parseHEIFDimensions(data); ok {
+			return image.Config{Width: w, Height: h}, formatName, nil
+		}
+		return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
 	}
-	return config, format, nil
+
+	return image.Config{}, "", err
 }

+ 20 - 4
service/task_billing.go

@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
 		}
 	}
 	other := make(map[string]interface{})
+	other["is_task"] = true
 	other["request_path"] = c.Request.URL.Path
 	other["model_price"] = info.PriceData.ModelPrice
+	if info.PriceData.ModelRatio > 0 {
+		other["model_ratio"] = info.PriceData.ModelRatio
+	}
 	other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
 	if info.PriceData.GroupRatioInfo.HasSpecialRatio {
 		other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
 	other := make(map[string]interface{})
 	if bc := task.PrivateData.BillingContext; bc != nil {
 		other["model_price"] = bc.ModelPrice
+		if bc.ModelRatio > 0 {
+			other["model_ratio"] = bc.ModelRatio
+		}
 		other["group_ratio"] = bc.GroupRatio
 		if len(bc.OtherRatios) > 0 {
 			for k, v := range bc.OtherRatios {
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
 	}
 	other := taskBillingOther(task)
 	other["task_id"] = task.TaskID
-	//other["reason"] = reason
 	other["pre_consumed_quota"] = preConsumedQuota
 	other["actual_quota"] = actualQuota
 	model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
 		finalGroupRatio = groupRatio
 	}
 
-	// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
-	actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
+	// 计算 OtherRatios 乘积(视频折扣、时长等)
+	otherMultiplier := 1.0
+	if bc := task.PrivateData.BillingContext; bc != nil {
+		for _, r := range bc.OtherRatios {
+			if r != 1.0 && r > 0 {
+				otherMultiplier *= r
+			}
+		}
+	}
+
+	// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
+	actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
 
-	reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
+	reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
 	RecalculateTaskQuota(ctx, task, actualQuota, reason)
 }

+ 0 - 3
service/token_counter.go

@@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
 	if err != nil {
 		return 0, err
 	}
-	fileMeta.MimeType = format
-
 	if config.Width == 0 || config.Height == 0 {
 		// not an image, but might be a valid file
 		if format != "" {
@@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
 				}
 				continue
 			}
-			file.MimeType = cachedData.MimeType
 			file.FileType = DetectFileType(cachedData.MimeType)
 		}
 	}

+ 2 - 1
setting/operation_setting/channel_affinity_setting.go

@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
 
 	ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
 
-	SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
+	SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
 
 	IncludeUsingGroup bool `json:"include_using_group"`
+	IncludeModelName  bool `json:"include_model_name"`
 	IncludeRuleName   bool `json:"include_rule_name"`
 }
 

+ 7 - 7
setting/ratio_setting/model_ratio.go

@@ -361,6 +361,10 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
 func GetModelPrice(name string, printErr bool) (float64, bool) {
 	name = FormatMatchingModelName(name)
 
+	if price, ok := modelPriceMap.Get(name); ok {
+		return price, true
+	}
+
 	if strings.HasSuffix(name, CompactModelSuffix) {
 		price, ok := modelPriceMap.Get(CompactWildcardModelKey)
 		if !ok {
@@ -372,14 +376,10 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
 		return price, true
 	}
 
-	price, ok := modelPriceMap.Get(name)
-	if !ok {
-		if printErr {
-			common.SysError("model price not found: " + name)
-		}
-		return -1, false
+	if printErr {
+		common.SysError("model price not found: " + name)
 	}
-	return price, true
+	return -1, false
 }
 
 func UpdateModelRatioByJSONString(jsonStr string) error {

+ 127 - 126
types/file_source.go

@@ -4,39 +4,144 @@ import (
 	"fmt"
 	"image"
 	"os"
+	"strings"
 	"sync"
 )
 
-// FileSourceType 文件来源类型
-type FileSourceType string
+// FileSource 统一的文件来源抽象接口
+// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
+type FileSource interface {
+	IsURL() bool
+	GetIdentifier() string
+	GetRawData() string
+	ClearRawData()
 
-const (
-	FileSourceTypeURL    FileSourceType = "url"    // URL 来源
-	FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
-)
+	SetCache(data *CachedFileData)
+	GetCache() *CachedFileData
+	HasCache() bool
+	ClearCache()
 
-// FileSource 统一的文件来源抽象
-// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
-type FileSource struct {
-	Type       FileSourceType `json:"type"`                  // 来源类型
-	URL        string         `json:"url,omitempty"`         // URL(当 Type 为 url 时)
-	Base64Data string         `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
-	MimeType   string         `json:"mime_type,omitempty"`   // MIME 类型(可选,会自动检测)
+	IsRegistered() bool
+	SetRegistered(registered bool)
+	Mu() *sync.Mutex
+}
 
-	// 内部缓存(不导出,不序列化)
+// baseFileSource 共享的缓存/锁/清理注册状态
+type baseFileSource struct {
 	cachedData  *CachedFileData
 	cacheLoaded bool
-	registered  bool       // 是否已注册到清理列表
-	mu          sync.Mutex // 保护加载过程
+	registered  bool
+	mu          sync.Mutex
+}
+
+func (b *baseFileSource) SetCache(data *CachedFileData) {
+	b.cachedData = data
+	b.cacheLoaded = true
+}
+
+func (b *baseFileSource) GetCache() *CachedFileData {
+	return b.cachedData
+}
+
+func (b *baseFileSource) HasCache() bool {
+	return b.cacheLoaded && b.cachedData != nil
+}
+
+func (b *baseFileSource) ClearCache() {
+	if b.cachedData != nil {
+		b.cachedData.Close()
+	}
+	b.cachedData = nil
+	b.cacheLoaded = false
+}
+
+func (b *baseFileSource) IsRegistered() bool {
+	return b.registered
+}
+
+func (b *baseFileSource) SetRegistered(registered bool) {
+	b.registered = registered
 }
 
-// Mu 获取内部锁
-func (f *FileSource) Mu() *sync.Mutex {
-	return &f.mu
+func (b *baseFileSource) Mu() *sync.Mutex {
+	return &b.mu
 }
 
-// CachedFileData 缓存的文件数据
-// 支持内存缓存和磁盘缓存两种模式
+// ---------------------------------------------------------------------------
+// URLSource — URL 来源的 FileSource 实现
+// ---------------------------------------------------------------------------
+
+type URLSource struct {
+	baseFileSource
+	URL string
+}
+
+func (u *URLSource) IsURL() bool { return true }
+
+func (u *URLSource) GetIdentifier() string {
+	if len(u.URL) > 100 {
+		return u.URL[:100] + "..."
+	}
+	return u.URL
+}
+
+func (u *URLSource) GetRawData() string { return u.URL }
+
+func (u *URLSource) ClearRawData() {}
+
+// ---------------------------------------------------------------------------
+// Base64Source — Base64 内联数据来源的 FileSource 实现
+// ---------------------------------------------------------------------------
+
+type Base64Source struct {
+	baseFileSource
+	Base64Data string
+	MimeType   string
+}
+
+func (b *Base64Source) IsURL() bool { return false }
+
+func (b *Base64Source) GetIdentifier() string {
+	if len(b.Base64Data) > 50 {
+		return "base64:" + b.Base64Data[:50] + "..."
+	}
+	return "base64:" + b.Base64Data
+}
+
+func (b *Base64Source) GetRawData() string { return b.Base64Data }
+
+func (b *Base64Source) ClearRawData() {
+	if len(b.Base64Data) > 1024 {
+		b.Base64Data = ""
+	}
+}
+
+// ---------------------------------------------------------------------------
+// Constructors
+// ---------------------------------------------------------------------------
+
+func NewURLFileSource(url string) *URLSource {
+	return &URLSource{URL: url}
+}
+
+func NewBase64FileSource(base64Data string, mimeType string) *Base64Source {
+	return &Base64Source{
+		Base64Data: base64Data,
+		MimeType:   mimeType,
+	}
+}
+
+func NewFileSourceFromData(data string, mimeType string) FileSource {
+	if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
+		return NewURLFileSource(data)
+	}
+	return NewBase64FileSource(data, mimeType)
+}
+
+// ---------------------------------------------------------------------------
+// CachedFileData — 缓存的文件数据(支持内存和磁盘两种模式)
+// ---------------------------------------------------------------------------
+
 type CachedFileData struct {
 	base64Data  string        // 内存中的 base64 数据(小文件)
 	MimeType    string        // MIME 类型
@@ -45,18 +150,15 @@ type CachedFileData struct {
 	ImageConfig *image.Config // 图片配置(如果是图片)
 	ImageFormat string        // 图片格式(如果是图片)
 
-	// 磁盘缓存相关
 	diskPath        string     // 磁盘缓存文件路径(大文件)
 	isDisk          bool       // 是否使用磁盘缓存
 	diskMu          sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
 	diskClosed      bool       // 是否已关闭/清理
 	statDecremented bool       // 是否已扣减统计
 
-	// 统计回调,避免循环依赖
 	OnClose func(size int64)
 }
 
-// NewMemoryCachedData 创建内存缓存的数据
 func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
 	return &CachedFileData{
 		base64Data: base64Data,
@@ -66,7 +168,6 @@ func NewMemoryCachedData(base64Data string, mimeType string, size int64) *Cached
 	}
 }
 
-// NewDiskCachedData 创建磁盘缓存的数据
 func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
 	return &CachedFileData{
 		diskPath: diskPath,
@@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile
 	}
 }
 
-// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
 func (c *CachedFileData) GetBase64Data() (string, error) {
 	if !c.isDisk {
 		return c.base64Data, nil
@@ -89,7 +189,6 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
 		return "", fmt.Errorf("disk cache already closed")
 	}
 
-	// 从磁盘读取
 	data, err := os.ReadFile(c.diskPath)
 	if err != nil {
 		return "", fmt.Errorf("failed to read from disk cache: %w", err)
@@ -97,22 +196,19 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
 	return string(data), nil
 }
 
-// SetBase64Data 设置 base64 数据(仅用于内存模式)
 func (c *CachedFileData) SetBase64Data(data string) {
 	if !c.isDisk {
 		c.base64Data = data
 	}
 }
 
-// IsDisk 是否使用磁盘缓存
 func (c *CachedFileData) IsDisk() bool {
 	return c.isDisk
 }
 
-// Close 关闭并清理资源
 func (c *CachedFileData) Close() error {
 	if !c.isDisk {
-		c.base64Data = "" // 释放内存
+		c.base64Data = ""
 		return nil
 	}
 
@@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error {
 	c.diskClosed = true
 	if c.diskPath != "" {
 		err := os.Remove(c.diskPath)
-		// 只有在删除成功且未扣减过统计时,才执行回调
 		if err == nil && !c.statDecremented && c.OnClose != nil {
 			c.OnClose(c.DiskSize)
 			c.statDecremented = true
@@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error {
 	}
 	return nil
 }
-
-// NewURLFileSource 创建 URL 来源的 FileSource
-func NewURLFileSource(url string) *FileSource {
-	return &FileSource{
-		Type: FileSourceTypeURL,
-		URL:  url,
-	}
-}
-
-// NewBase64FileSource 创建 base64 来源的 FileSource
-func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
-	return &FileSource{
-		Type:       FileSourceTypeBase64,
-		Base64Data: base64Data,
-		MimeType:   mimeType,
-	}
-}
-
-// IsURL 判断是否是 URL 来源
-func (f *FileSource) IsURL() bool {
-	return f.Type == FileSourceTypeURL
-}
-
-// IsBase64 判断是否是 base64 来源
-func (f *FileSource) IsBase64() bool {
-	return f.Type == FileSourceTypeBase64
-}
-
-// GetIdentifier 获取文件标识符(用于日志和错误追踪)
-func (f *FileSource) GetIdentifier() string {
-	if f.IsURL() {
-		if len(f.URL) > 100 {
-			return f.URL[:100] + "..."
-		}
-		return f.URL
-	}
-	if len(f.Base64Data) > 50 {
-		return "base64:" + f.Base64Data[:50] + "..."
-	}
-	return "base64:" + f.Base64Data
-}
-
-// GetRawData 获取原始数据(URL 或完整的 base64 字符串)
-func (f *FileSource) GetRawData() string {
-	if f.IsURL() {
-		return f.URL
-	}
-	return f.Base64Data
-}
-
-// SetCache 设置缓存数据
-func (f *FileSource) SetCache(data *CachedFileData) {
-	f.cachedData = data
-	f.cacheLoaded = true
-}
-
-// IsRegistered 是否已注册到清理列表
-func (f *FileSource) IsRegistered() bool {
-	return f.registered
-}
-
-// SetRegistered 设置注册状态
-func (f *FileSource) SetRegistered(registered bool) {
-	f.registered = registered
-}
-
-// GetCache 获取缓存数据
-func (f *FileSource) GetCache() *CachedFileData {
-	return f.cachedData
-}
-
-// HasCache 是否有缓存
-func (f *FileSource) HasCache() bool {
-	return f.cacheLoaded && f.cachedData != nil
-}
-
-// ClearCache 清除缓存,释放内存和磁盘文件
-func (f *FileSource) ClearCache() {
-	// 如果有缓存数据,先关闭它(会清理磁盘文件)
-	if f.cachedData != nil {
-		f.cachedData.Close()
-	}
-	f.cachedData = nil
-	f.cacheLoaded = false
-}
-
-// ClearRawData 清除原始数据,只保留必要的元信息
-// 用于在处理完成后释放大文件的内存
-func (f *FileSource) ClearRawData() {
-	// 保留 URL(通常很短),只清除大的 base64 数据
-	if f.IsBase64() && len(f.Base64Data) > 1024 {
-		f.Base64Data = ""
-	}
-}

+ 4 - 5
types/request_meta.go

@@ -32,13 +32,12 @@ type TokenCountMeta struct {
 
 type FileMeta struct {
 	FileType
-	MimeType string
-	Source   *FileSource // 统一的文件来源(URL 或 base64)
-	Detail   string      // 图片细节级别(low/high/auto)
+	Source FileSource // 统一的文件来源(URL 或 base64)
+	Detail string     // 图片细节级别(low/high/auto)
 }
 
 // NewFileMeta 创建新的 FileMeta
-func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
+func NewFileMeta(fileType FileType, source FileSource) *FileMeta {
 	return &FileMeta{
 		FileType: fileType,
 		Source:   source,
@@ -46,7 +45,7 @@ func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
 }
 
 // NewImageFileMeta 创建图片类型的 FileMeta
-func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
+func NewImageFileMeta(source FileSource, detail string) *FileMeta {
 	return &FileMeta{
 		FileType: FileTypeImage,
 		Source:   source,

+ 5 - 4
web/bun.lock

@@ -1,5 +1,6 @@
 {
   "lockfileVersion": 1,
+  "configVersion": 0,
   "workspaces": {
     "": {
       "name": "react-template",
@@ -10,7 +11,7 @@
         "@visactor/react-vchart": "~1.8.8",
         "@visactor/vchart": "~1.8.8",
         "@visactor/vchart-semi-theme": "~1.8.8",
-        "axios": "1.12.0",
+        "axios": "1.13.5",
         "clsx": "^2.1.1",
         "dayjs": "^1.11.11",
         "history": "^5.3.0",
@@ -776,7 +777,7 @@
 
     "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
 
-    "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
+    "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
 
     "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
 
@@ -1104,13 +1105,13 @@
 
     "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
 
-    "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
+    "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
 
     "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
 
     "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
 
-    "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
+    "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
 
     "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
 

+ 14 - 35
web/src/components/common/DocumentRenderer/index.jsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
 import { API, showError } from '../../../helpers';
 import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
 const { Title } = Typography;
@@ -28,7 +28,7 @@ import {
 import { useTranslation } from 'react-i18next';
 import MarkdownRenderer from '../markdown/MarkdownRenderer';
 
-// 检查是否为 URL
+// Check whether content is a URL.
 const isUrl = (content) => {
   try {
     new URL(content.trim());
@@ -38,27 +38,23 @@ const isUrl = (content) => {
   }
 };
 
-// 检查是否为 HTML 内容
+// Check whether content contains HTML.
 const isHtmlContent = (content) => {
   if (!content || typeof content !== 'string') return false;
 
-  // 检查是否包含HTML标签
   const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
   return htmlTagRegex.test(content);
 };
 
-// 安全地渲染HTML内容
+// Parse HTML content and extract inline styles.
 const sanitizeHtml = (html) => {
-  // 创建一个临时元素来解析HTML
   const tempDiv = document.createElement('div');
   tempDiv.innerHTML = html;
 
-  // 提取样式
   const styles = Array.from(tempDiv.querySelectorAll('style'))
     .map((style) => style.innerHTML)
     .join('\n');
 
-  // 提取body内容,如果没有body标签则使用全部内容
   const bodyContent = tempDiv.querySelector('body');
   const content = bodyContent ? bodyContent.innerHTML : html;
 
@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
   const { t } = useTranslation();
   const [content, setContent] = useState('');
   const [loading, setLoading] = useState(true);
-  const [htmlStyles, setHtmlStyles] = useState('');
-  const [processedHtmlContent, setProcessedHtmlContent] = useState('');
 
   const loadContent = async () => {
-    // 先从缓存中获取
     const cachedContent = localStorage.getItem(cacheKey) || '';
     if (cachedContent) {
       setContent(cachedContent);
-      processContent(cachedContent);
       setLoading(false);
     }
 
@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
       const { success, message, data } = res.data;
       if (success && data) {
         setContent(data);
-        processContent(data);
         localStorage.setItem(cacheKey, data);
       } else {
         if (!cachedContent) {
@@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
     }
   };
 
-  const processContent = (rawContent) => {
-    if (isHtmlContent(rawContent)) {
-      const { content: htmlContent, styles } = sanitizeHtml(rawContent);
-      setProcessedHtmlContent(htmlContent);
-      setHtmlStyles(styles);
-    } else {
-      setProcessedHtmlContent('');
-      setHtmlStyles('');
+  const htmlPayload = useMemo(() => {
+    if (!isHtmlContent(content)) {
+      return { content: '', styles: '' };
     }
-  };
+    return sanitizeHtml(content);
+  }, [content]);
 
   useEffect(() => {
     loadContent();
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
   // 处理HTML样式注入
   useEffect(() => {
     const styleId = `document-renderer-styles-${cacheKey}`;
+    const { styles } = htmlPayload;
 
-    if (htmlStyles) {
+    if (styles) {
       let styleEl = document.getElementById(styleId);
       if (!styleEl) {
         styleEl = document.createElement('style');
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
         styleEl.type = 'text/css';
         document.head.appendChild(styleEl);
       }
-      styleEl.innerHTML = htmlStyles;
+      styleEl.innerHTML = styles;
     } else {
       const el = document.getElementById(styleId);
       if (el) el.remove();
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
       const el = document.getElementById(styleId);
       if (el) el.remove();
     };
-  }, [htmlStyles, cacheKey]);
+  }, [cacheKey, htmlPayload]);
 
   // 显示加载状态
   if (loading) {
@@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
 
   // 如果是 HTML 内容,直接渲染
   if (isHtmlContent(content)) {
-    const { content: htmlContent, styles } = sanitizeHtml(content);
-
-    // 设置样式(如果有的话)
-    useEffect(() => {
-      if (styles && styles !== htmlStyles) {
-        setHtmlStyles(styles);
-      }
-    }, [content, styles, htmlStyles]);
-
     return (
       <div className='min-h-screen bg-gray-50'>
         <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
             </Title>
             <div
               className='prose prose-lg max-w-none'
-              dangerouslySetInnerHTML={{ __html: htmlContent }}
+              dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
             />
           </div>
         </div>

+ 52 - 0
web/src/components/common/ErrorBoundary.jsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import { Empty, Button } from '@douyinfe/semi-ui';
+import {
+  IllustrationFailure,
+  IllustrationFailureDark,
+} from '@douyinfe/semi-illustrations';
+import { withTranslation } from 'react-i18next';
+
+class ErrorBoundary extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { hasError: false };
+  }
+
+  static getDerivedStateFromError() {
+    return { hasError: true };
+  }
+
+  componentDidCatch(error, errorInfo) {
+    console.error('[ErrorBoundary]', error, errorInfo);
+  }
+
+  render() {
+    if (this.state.hasError) {
+      const { t } = this.props;
+      return (
+        <div className='flex flex-col justify-center items-center h-screen p-8'>
+          <Empty
+            image={
+              <IllustrationFailure style={{ width: 250, height: 250 }} />
+            }
+            darkModeImage={
+              <IllustrationFailureDark style={{ width: 250, height: 250 }} />
+            }
+            description={t('页面渲染出错,请刷新页面重试')}
+          />
+          <Button
+            theme='solid'
+            type='primary'
+            style={{ marginTop: 16 }}
+            onClick={() => window.location.reload()}
+          >
+            {t('刷新页面')}
+          </Button>
+        </div>
+      );
+    }
+    return this.props.children;
+  }
+}
+
+export default withTranslation()(ErrorBoundary);

+ 13 - 6
web/src/components/dashboard/ApiInfoPanel.jsx

@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
 
 import React from 'react';
 import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
-import { Server, Gauge, ExternalLink } from 'lucide-react';
+import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
 import {
   IllustrationConstruction,
   IllustrationConstructionDark,
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
                       </Tag>
                     </div>
                   </div>
-                  <div
-                    className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
-                    onClick={() => handleCopyUrl(api.url)}
-                  >
-                    {api.url}
+                  <div className='flex items-center gap-1 mb-1'>
+                    <span
+                      className='!text-semi-color-primary break-all cursor-pointer hover:underline'
+                      onClick={() => handleCopyUrl(api.url)}
+                    >
+                      {api.url}
+                    </span>
+                    <Copy
+                      size={14}
+                      className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
+                      onClick={() => handleCopyUrl(api.url)}
+                    />
                   </div>
                   <div className='text-gray-500'>{api.description}</div>
                 </div>

+ 16 - 1
web/src/components/dashboard/ChartsPanel.jsx

@@ -29,6 +29,9 @@ const ChartsPanel = ({
   spec_model_line,
   spec_pie,
   spec_rank_bar,
+  spec_user_rank,
+  spec_user_trend,
+  isAdminUser,
   CARD_PROPS,
   CHART_CONFIG,
   FLEX_CENTER_GAP2,
@@ -51,9 +54,15 @@ const ChartsPanel = ({
             onChange={setActiveChartTab}
           >
             <TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
-            <TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
+            <TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
             <TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
             <TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
+            {isAdminUser && (
+              <TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
+            )}
+            {isAdminUser && (
+              <TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
+            )}
           </Tabs>
         </div>
       }
@@ -72,6 +81,12 @@ const ChartsPanel = ({
         {activeChartTab === '4' && (
           <VChart spec={spec_rank_bar} option={CHART_CONFIG} />
         )}
+        {activeChartTab === '5' && isAdminUser && (
+          <VChart spec={spec_user_rank} option={CHART_CONFIG} />
+        )}
+        {activeChartTab === '6' && isAdminUser && (
+          <VChart spec={spec_user_trend} option={CHART_CONFIG} />
+        )}
       </div>
     </Card>
   );

+ 15 - 0
web/src/components/dashboard/index.jsx

@@ -86,12 +86,22 @@ const Dashboard = () => {
   );
 
   // ========== 数据处理 ==========
+  const loadUserData = async () => {
+    if (dashboardData.isAdminUser) {
+      const userData = await dashboardData.loadUserQuotaData();
+      if (userData && userData.length > 0) {
+        dashboardCharts.updateUserChartData(userData);
+      }
+    }
+  };
+
   const initChart = async () => {
     await dashboardData.loadQuotaData().then((data) => {
       if (data && data.length > 0) {
         dashboardCharts.updateChartData(data);
       }
     });
+    await loadUserData();
     await dashboardData.loadUptimeData();
   };
 
@@ -100,10 +110,12 @@ const Dashboard = () => {
     if (data && data.length > 0) {
       dashboardCharts.updateChartData(data);
     }
+    await loadUserData();
   };
 
   const handleSearchConfirm = async () => {
     await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
+    await loadUserData();
   };
 
   // ========== 数据准备 ==========
@@ -182,6 +194,9 @@ const Dashboard = () => {
             spec_model_line={dashboardCharts.spec_model_line}
             spec_pie={dashboardCharts.spec_pie}
             spec_rank_bar={dashboardCharts.spec_rank_bar}
+            spec_user_rank={dashboardCharts.spec_user_rank}
+            spec_user_trend={dashboardCharts.spec_user_trend}
+            isAdminUser={dashboardData.isAdminUser}
             CARD_PROPS={CARD_PROPS}
             CHART_CONFIG={CHART_CONFIG}
             FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}

+ 4 - 1
web/src/components/layout/PageLayout.jsx

@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
 import App from '../../App';
 import FooterBar from './Footer';
 import { ToastContainer } from 'react-toastify';
+import ErrorBoundary from '../common/ErrorBoundary';
 import React, { useContext, useEffect, useState } from 'react';
 import { useIsMobile } from '../../hooks/common/useIsMobile';
 import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
@@ -216,7 +217,9 @@ const PageLayout = () => {
               position: 'relative',
             }}
           >
-            <App />
+            <ErrorBoundary>
+              <App />
+            </ErrorBoundary>
           </Content>
           {!shouldHideFooter && (
             <Layout.Footer

+ 9 - 7
web/src/components/layout/headerbar/ThemeToggle.jsx

@@ -95,13 +95,15 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
         </Dropdown.Menu>
       }
     >
-      <Button
-        icon={currentButtonIcon}
-        aria-label={t('切换主题')}
-        theme='borderless'
-        type='tertiary'
-        className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
-      />
+      <span className='inline-flex'>
+        <Button
+          icon={currentButtonIcon}
+          aria-label={t('切换主题')}
+          theme='borderless'
+          type='tertiary'
+          className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
+        />
+      </span>
     </Dropdown>
   );
 };

+ 13 - 7
web/src/components/playground/ParameterControl.jsx

@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import React from 'react';
-import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
+import {
+  Input,
+  InputNumber,
+  Slider,
+  Typography,
+  Button,
+  Tag,
+} from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
 import {
   Hash,
@@ -241,15 +248,14 @@ const ParameterControl = ({
             disabled={disabled}
           />
         </div>
-        <Input
+        <InputNumber
           placeholder='MaxTokens'
           name='max_tokens'
-          required
-          autoComplete='new-password'
-          defaultValue={0}
           value={inputs.max_tokens}
-          onChange={(value) => onInputChange('max_tokens', value)}
-          className='!rounded-lg'
+          onNumberChange={(value) => onInputChange('max_tokens', value)}
+          min={0}
+          precision={0}
+          style={{ width: '100%' }}
           disabled={!parameterEnabled.max_tokens || disabled}
         />
       </div>

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

@@ -65,11 +65,15 @@ export const loadConfig = () => {
     const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
     if (savedConfig) {
       const parsedConfig = JSON.parse(savedConfig);
+      const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
 
       const mergedConfig = {
         inputs: {
           ...DEFAULT_CONFIG.inputs,
           ...parsedConfig.inputs,
+          max_tokens: Number.isNaN(parsedMaxTokens)
+            ? parsedConfig?.inputs?.max_tokens
+            : parsedMaxTokens,
         },
         parameterEnabled: {
           ...DEFAULT_CONFIG.parameterEnabled,

+ 4 - 9
web/src/components/settings/RatioSetting.jsx

@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
 
+import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
 import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
-import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
-import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
 import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
 import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
 import ToolPriceSettings from '../../pages/Setting/Ratio/ToolPriceSettings';
@@ -96,18 +95,14 @@ const RatioSetting = () => {
 
   return (
     <Spin spinning={loading} size='large'>
-      {/* 模型倍率设置以及价格编辑器 */}
       <Card style={{ marginTop: '10px' }}>
-        <Tabs type='card' defaultActiveKey='visual'>
-          <Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
-            <ModelRatioSettings options={inputs} refresh={onRefresh} />
+        <Tabs type='card' defaultActiveKey='pricing'>
+          <Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
+            <ModelPricingCombined options={inputs} refresh={onRefresh} />
           </Tabs.TabPane>
           <Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
             <GroupRatioSettings options={inputs} refresh={onRefresh} />
           </Tabs.TabPane>
-          <Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
-            <ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
-          </Tabs.TabPane>
           <Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
             <ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
           </Tabs.TabPane>

+ 11 - 0
web/src/components/settings/SystemSetting.jsx

@@ -91,6 +91,7 @@ const SystemSetting = () => {
     EmailDomainRestrictionEnabled: '',
     EmailAliasRestrictionEnabled: '',
     SMTPSSLEnabled: '',
+    SMTPForceAuthLogin: '',
     EmailDomainWhitelist: [],
     TelegramOAuthEnabled: '',
     TelegramBotToken: '',
@@ -182,6 +183,7 @@ const SystemSetting = () => {
           case 'EmailDomainRestrictionEnabled':
           case 'EmailAliasRestrictionEnabled':
           case 'SMTPSSLEnabled':
+          case 'SMTPForceAuthLogin':
           case 'LinuxDOOAuthEnabled':
           case 'discord.enabled':
           case 'oidc.enabled':
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
                       >
                         {t('启用SMTP SSL')}
                       </Form.Checkbox>
+                      <Form.Checkbox
+                        field='SMTPForceAuthLogin'
+                        noLabel
+                        onChange={(e) =>
+                          handleCheckboxChange('SMTPForceAuthLogin', e)
+                        }
+                      >
+                        {t('强制使用 AUTH LOGIN')}
+                      </Form.Checkbox>
                     </Col>
                   </Row>
                   <Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>

+ 14 - 3
web/src/components/table/tokens/TokensColumnDefs.jsx

@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
 };
 
 // Render group column
-const renderGroupColumn = (text, record, t) => {
+const renderGroupColumn = (text, record, t, groupRatios = {}) => {
   if (text === 'auto') {
     return (
       <Tooltip
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
       </Tooltip>
     );
   }
-  return renderGroup(text);
+  const ratio = groupRatios[text];
+  return (
+    <span className='flex items-center gap-1'>
+      {renderGroup(text)}
+      {ratio !== undefined && (
+        <Tag size='small' color='green' shape='circle'>
+          {ratio}x
+        </Tag>
+      )}
+    </span>
+  );
 };
 
 // Render token key column with show/hide and copy functionality
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
   setEditingToken,
   setShowEdit,
   refresh,
+  groupRatios = {},
 }) => {
   return [
     {
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
       title: t('分组'),
       dataIndex: 'group',
       key: 'group',
-      render: (text, record) => renderGroupColumn(text, record, t),
+      render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
     },
     {
       title: t('密钥'),

+ 3 - 0
web/src/components/table/tokens/TokensTable.jsx

@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
     setEditingToken,
     setShowEdit,
     refresh,
+    groupRatios,
     t,
   } = tokensData;
 
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
       setEditingToken,
       setShowEdit,
       refresh,
+      groupRatios,
     });
   }, [
     t,
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
     setEditingToken,
     setShowEdit,
     refresh,
+    groupRatios,
   ]);
 
   // Handle compact mode by removing fixed positioning

+ 8 - 0
web/src/components/table/tokens/modals/EditTokenModal.jsx

@@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
                         placeholder={t('令牌分组,默认为用户的分组')}
                         optionList={groups}
                         renderOptionItem={renderGroupOption}
+                        filter={(input, option) => {
+                          const q = input.toLowerCase();
+                          return (
+                            option.value?.toLowerCase().includes(q) ||
+                            (typeof option.label === 'string' &&
+                              option.label.toLowerCase().includes(q))
+                          );
+                        }}
                         showClear
                         style={{ width: '100%' }}
                       />

+ 52 - 6
web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx

@@ -36,7 +36,7 @@ import {
   renderTieredModelPriceSimple,
 } from '../../../helpers';
 import { IconHelpCircle } from '@douyinfe/semi-icons';
-import { Route, Sparkles } from 'lucide-react';
+import { CircleAlert, Route, Sparkles } from 'lucide-react';
 
 const colors = [
   'amber',
@@ -142,12 +142,58 @@ function renderType(type, t) {
   }
 }
 
-function renderIsStream(bool, t) {
+function buildStreamStatusTooltip(ss, t) {
+  if (!ss) return null;
+  const lines = [
+    t('流状态') + ':' + t('异常'),
+    (ss.end_reason || 'unknown'),
+  ];
+  if (ss.error_count > 0) {
+    lines.push(`${t('软错误')}: ${ss.error_count}`);
+  }
+  if (ss.end_error) {
+    lines.push(ss.end_error);
+  }
+  return (
+    <div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
+      {lines.map((line, i) => (
+        <div key={i}>{line}</div>
+      ))}
+    </div>
+  );
+}
+
+function renderIsStream(bool, t, streamStatus) {
+  const isError = streamStatus && streamStatus.status !== 'ok';
+
   if (bool) {
     return (
-      <Tag color='blue' shape='circle'>
-        {t('流')}
-      </Tag>
+      <span style={{ position: 'relative', display: 'inline-block' }}>
+        <Tag color='blue' shape='circle'>
+          {t('流')}
+        </Tag>
+        {isError && (
+          <Tooltip content={buildStreamStatusTooltip(streamStatus, t)}>
+            <span
+              style={{
+                position: 'absolute',
+                right: -4,
+                top: -4,
+                lineHeight: 1,
+                color: '#ef4444',
+                cursor: 'pointer',
+                userSelect: 'none',
+              }}
+            >
+              <CircleAlert
+                size={14}
+                strokeWidth={2.5}
+                color='currentColor'
+              />
+            </span>
+          </Tooltip>
+        )}
+      </span>
     );
   } else {
     return (
@@ -663,7 +709,7 @@ export const getLogsColumns = ({
               <Space>
                 {renderUseTime(text, t)}
                 {renderFirstUseTime(other?.frt, t)}
-                {renderIsStream(record.is_stream, t)}
+                {renderIsStream(record.is_stream, t, other?.stream_status)}
               </Space>
             </>
           );

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

@@ -150,7 +150,18 @@ export const buildApiPayload = (
     const value = inputs[param];
     const hasValue = value !== undefined && value !== null;
 
-    if (enabled && hasValue) {
+    if (!enabled) {
+      return;
+    }
+
+    if (param === 'max_tokens') {
+      if (typeof value === 'number') {
+        payload[param] = value;
+      }
+      return;
+    }
+
+    if (hasValue) {
       payload[param] = value;
     }
   });

+ 55 - 0
web/src/helpers/dashboard.jsx

@@ -387,3 +387,58 @@ export const generateChartTimePoints = (
 
   return chartTimePoints;
 };
+
+// ========== 用户维度数据处理 ==========
+export const processUserData = (data, dataExportDefaultTime, limit = 10) => {
+  const userQuotaTotal = new Map();
+  data.forEach((item) => {
+    const prev = userQuotaTotal.get(item.username) || 0;
+    userQuotaTotal.set(item.username, prev + item.quota);
+  });
+
+  const sorted = Array.from(userQuotaTotal.entries()).sort(
+    (a, b) => b[1] - a[1],
+  );
+  const topUsers = sorted.slice(0, limit).map(([u]) => u);
+  const topUserSet = new Set(topUsers);
+
+  const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({
+    User: username,
+    Quota: quota,
+  }));
+
+  const showYear = isDataCrossYear(data.map((item) => item.created_at));
+
+  const timeUserMap = new Map();
+  const allTimePoints = new Set();
+
+  data.forEach((item) => {
+    const timeKey = timestamp2string1(
+      item.created_at,
+      dataExportDefaultTime,
+      showYear,
+    );
+    allTimePoints.add(timeKey);
+    const user = topUserSet.has(item.username) ? item.username : null;
+    if (!user) return;
+    const key = `${timeKey}-${user}`;
+    const prev = timeUserMap.get(key) || { quota: 0 };
+    timeUserMap.set(key, { quota: prev.quota + item.quota });
+  });
+
+  const sortedTimePoints = Array.from(allTimePoints).sort();
+  const trendData = [];
+  sortedTimePoints.forEach((time) => {
+    topUsers.forEach((user) => {
+      const key = `${time}-${user}`;
+      const val = timeUserMap.get(key);
+      trendData.push({
+        Time: time,
+        User: user,
+        Quota: val?.quota || 0,
+      });
+    });
+  });
+
+  return { rankingData, trendData, topUsers };
+};

+ 12 - 0
web/src/helpers/render.jsx

@@ -1625,6 +1625,18 @@ function renderPriceSimpleCore({
   return result;
 }
 
+export function renderTaskBillingProcess(other, content) {
+  if (other?.task_id != null) {
+    return renderBillingArticle(
+      [content].filter(Boolean),
+      { showReferenceNote: false },
+    );
+  }
+  return renderBillingArticle([
+    buildBillingText('任务预扣费(将在任务完成后按实际token重算)'),
+  ]);
+}
+
 export function renderModelPrice(opts) {
   const {
     prompt_tokens: inputTokens = 0,

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

@@ -33,6 +33,20 @@ export async function fetchTokenKey(tokenId) {
   return data.key;
 }
 
+/**
+ * 批量获取多个令牌的真实 key
+ * @param {number[]} tokenIds
+ * @returns {Promise<Record<number, string>>} 返回 {id: key} map,key 不带 sk- 前缀
+ */
+export async function fetchTokenKeysBatch(tokenIds) {
+  const response = await API.post('/api/token/batch/keys', { ids: tokenIds });
+  const { success, data, message } = response.data || {};
+  if (!success || !data?.keys) {
+    throw new Error(message || 'Failed to fetch token keys');
+  }
+  return data.keys;
+}
+
 /**
  * 获取可用的 token keys
  * @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组

+ 131 - 6
web/src/hooks/dashboard/useDashboardCharts.jsx

@@ -34,8 +34,14 @@ import {
   updateChartSpec,
   updateMapValue,
   initializeMaps,
+  processUserData,
 } from '../../helpers/dashboard';
 
+const USER_COLORS = [
+  '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
+  '#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
+];
+
 export const useDashboardCharts = (
   dataExportDefaultTime,
   setTrendData,
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
     },
   });
 
-  // 模型消耗趋势折线图
   const [spec_model_line, setSpecModelLine] = useState({
     type: 'line',
     data: [
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
     },
     title: {
       visible: true,
-      text: t('模型消耗趋势'),
+      text: t('调用趋势'),
       subtext: '',
     },
     tooltip: {
@@ -215,7 +220,6 @@ export const useDashboardCharts = (
     },
   });
 
-  // 模型调用次数排行柱状图
   const [spec_rank_bar, setSpecRankBar] = useState({
     type: 'bar',
     data: [
@@ -259,6 +263,82 @@ export const useDashboardCharts = (
     },
   });
 
+  // ========== Admin: 用户消耗排行 ==========
+  const [spec_user_rank, setSpecUserRank] = useState({
+    type: 'bar',
+    data: [{ id: 'userRankData', values: [] }],
+    xField: 'rawQuota',
+    yField: 'User',
+    seriesField: 'User',
+    direction: 'horizontal',
+    legends: { visible: false },
+    title: {
+      visible: true,
+      text: t('用户消耗排行'),
+      subtext: '',
+    },
+    bar: {
+      state: { hover: { stroke: '#000', lineWidth: 1 } },
+    },
+    label: {
+      visible: true,
+      position: 'outside',
+      formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
+    },
+    axes: [{
+      orient: 'left',
+      type: 'band',
+      label: { visible: true },
+    }, {
+      orient: 'bottom',
+      type: 'linear',
+      visible: false,
+    }],
+    tooltip: {
+      mark: {
+        content: [{
+          key: (datum) => datum['User'],
+          value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
+        }],
+      },
+    },
+    color: { type: 'ordinal', range: USER_COLORS },
+  });
+
+  // ========== Admin: 用户消耗趋势 ==========
+  const [spec_user_trend, setSpecUserTrend] = useState({
+    type: 'area',
+    data: [{ id: 'userTrendData', values: [] }],
+    xField: 'Time',
+    yField: 'rawQuota',
+    seriesField: 'User',
+    stack: false,
+    legends: { visible: true, selectMode: 'single' },
+    title: {
+      visible: true,
+      text: t('用户消耗趋势'),
+      subtext: '',
+    },
+    axes: [{
+      orient: 'left',
+      label: {
+        formatMethod: (value) => renderQuota(value, 2),
+      },
+    }],
+    area: { style: { fillOpacity: 0.15 } },
+    line: { style: { lineWidth: 2 } },
+    point: { visible: false },
+    tooltip: {
+      mark: {
+        content: [{
+          key: (datum) => datum['User'],
+          value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
+        }],
+      },
+    },
+    color: { type: 'ordinal', range: USER_COLORS },
+  });
+
   // ========== 数据处理函数 ==========
   const generateModelColors = useCallback((uniqueModels, modelColors) => {
     const newModelColors = {};
@@ -426,6 +506,51 @@ export const useDashboardCharts = (
     ],
   );
 
+  // ========== 用户维度图表数据处理 ==========
+  const updateUserChartData = useCallback(
+    (data) => {
+      const { rankingData, trendData: userTrend } = processUserData(
+        data,
+        dataExportDefaultTime,
+        10,
+      );
+
+      const userRankValues = rankingData.map((item) => ({
+        User: item.User,
+        rawQuota: item.Quota,
+        Quota: getQuotaWithUnit(item.Quota, 4),
+      })).sort((a, b) => b.rawQuota - a.rawQuota);
+
+      const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
+
+      setSpecUserRank((prev) => ({
+        ...prev,
+        data: [{ id: 'userRankData', values: userRankValues }],
+        title: {
+          ...prev.title,
+          subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
+        },
+      }));
+
+      const userTrendValues = userTrend.map((item) => ({
+        Time: item.Time,
+        User: item.User,
+        rawQuota: item.Quota,
+        Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
+      }));
+
+      setSpecUserTrend((prev) => ({
+        ...prev,
+        data: [{ id: 'userTrendData', values: userTrendValues }],
+        title: {
+          ...prev.title,
+          subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
+        },
+      }));
+    },
+    [dataExportDefaultTime, t],
+  );
+
   // ========== 初始化图表主题 ==========
   useEffect(() => {
     initVChartSemiTheme({
@@ -434,14 +559,14 @@ export const useDashboardCharts = (
   }, []);
 
   return {
-    // 图表规格
     spec_pie,
     spec_line,
     spec_model_line,
     spec_rank_bar,
-
-    // 函数
+    spec_user_rank,
+    spec_user_trend,
     updateChartData,
+    updateUserChartData,
     generateModelColors,
   };
 };

+ 22 - 0
web/src/hooks/dashboard/useDashboardData.js

@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
     }
   }, [activeUptimeTab]);
 
+  const loadUserQuotaData = useCallback(async () => {
+    if (!isAdminUser) return [];
+    try {
+      const { start_timestamp, end_timestamp } = inputs;
+      const localStartTimestamp = Date.parse(start_timestamp) / 1000;
+      const localEndTimestamp = Date.parse(end_timestamp) / 1000;
+      const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        return data || [];
+      } else {
+        showError(message);
+        return [];
+      }
+    } catch (err) {
+      console.error(err);
+      return [];
+    }
+  }, [inputs, isAdminUser]);
+
   const getUserData = useCallback(async () => {
     let res = await API.get(`/api/user/self`);
     const { success, message, data } = res.data;
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
     showSearchModal,
     handleCloseModal,
     loadQuotaData,
+    loadUserQuotaData,
     loadUptimeData,
     getUserData,
     refresh,

+ 8 - 1
web/src/hooks/playground/usePlaygroundState.js

@@ -167,7 +167,14 @@ export const usePlaygroundState = () => {
   // 配置导入/重置
   const handleConfigImport = useCallback((importedConfig) => {
     if (importedConfig.inputs) {
-      setInputs((prev) => ({ ...prev, ...importedConfig.inputs }));
+      const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10);
+      setInputs((prev) => ({
+        ...prev,
+        ...importedConfig.inputs,
+        max_tokens: Number.isNaN(parsedMaxTokens)
+          ? importedConfig.inputs.max_tokens
+          : parsedMaxTokens,
+      }));
     }
     if (importedConfig.parameterEnabled) {
       setParameterEnabled((prev) => ({

+ 23 - 6
web/src/hooks/tokens/useTokensData.jsx

@@ -31,6 +31,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
 import { useTableCompactMode } from '../common/useTableCompactMode';
 import {
   fetchTokenKey as fetchTokenKeyById,
+  fetchTokenKeysBatch,
   getServerAddress,
   encodeChannelConnectionString,
 } from '../../helpers/token';
@@ -41,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
   // Basic state
   const [tokens, setTokens] = useState([]);
   const [loading, setLoading] = useState(true);
+  const [groupRatios, setGroupRatios] = useState({});
   const [activePage, setActivePage] = useState(1);
   const [tokenCount, setTokenCount] = useState(0);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@@ -408,14 +410,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
       return;
     }
     try {
-      const keys = await Promise.all(
-        selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
-      );
+      const ids = selectedKeys.map((token) => token.id);
+      const keysMap = await fetchTokenKeysBatch(ids);
+
+      setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
+
       let content = '';
-      for (let i = 0; i < selectedKeys.length; i++) {
-        const fullKey = keys[i];
+      for (const token of selectedKeys) {
+        const fullKey = keysMap[token.id];
+        if (!fullKey) continue;
         if (copyType === 'name+key') {
-          content += `${selectedKeys[i].name}    sk-${fullKey}\n`;
+          content += `${token.name}    sk-${fullKey}\n`;
         } else {
           content += `sk-${fullKey}\n`;
         }
@@ -433,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
       .catch((reason) => {
         showError(reason);
       });
+    API.get('/api/user/self/groups')
+      .then((res) => {
+        if (res.data.success && res.data.data) {
+          const ratios = {};
+          for (const [name, info] of Object.entries(res.data.data)) {
+            ratios[name] = info.ratio;
+          }
+          setGroupRatios(ratios);
+        }
+      })
+      .catch(() => {});
   }, [pageSize]);
 
   return {
@@ -443,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     tokenCount,
     pageSize,
     searching,
+    groupRatios,
 
     // Selection state
     selectedKeys,

+ 5 - 1
web/src/hooks/usage-logs/useUsageLogsData.jsx

@@ -37,6 +37,7 @@ import {
   renderClaudeModelPrice,
   renderModelPrice,
   renderTieredModelPrice,
+  renderTaskBillingProcess,
 } from '../../helpers';
 import { ITEMS_PER_PAGE } from '../../constants';
 import { useTableCompactMode } from '../common/useTableCompactMode';
@@ -475,7 +476,10 @@ export const useLogsData = () => {
             completion_tokens: logs[i].completion_tokens,
             displayMode: billingDisplayMode,
           };
-          if (other?.ws || other?.audio) {
+          const isTaskLog = other?.is_task === true || other?.task_id != null;
+          if (isTaskLog && other?.model_price === -1) {
+            content = renderTaskBillingProcess(other, logs[i].content);
+          } else if (other?.ws || other?.audio) {
             content = renderAudioModelPrice(logOpts);
           } else if (other?.claude) {
             content = renderClaudeModelPrice(logOpts);

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 208 - 105
web/src/i18n/locales/en.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 209 - 113
web/src/i18n/locales/fr.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 206 - 109
web/src/i18n/locales/ja.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 204 - 111
web/src/i18n/locales/ru.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 206 - 102
web/src/i18n/locales/vi.json


+ 23 - 1
web/src/i18n/locales/zh-CN.json

@@ -680,6 +680,7 @@
     "启用Gemini思考后缀适配": "启用Gemini思考后缀适配",
     "启用Ping间隔": "启用Ping间隔",
     "启用SMTP SSL": "启用SMTP SSL",
+    "强制使用 AUTH LOGIN": "强制使用 AUTH LOGIN",
     "启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)",
     "启用全部": "启用全部",
     "启用后可接入 io.net GPU 资源": "启用后可接入 io.net GPU 资源",
@@ -1985,6 +1986,19 @@
     "自定义请求体模式": "自定义请求体模式",
     "自定义货币": "自定义货币",
     "自定义货币符号": "自定义货币符号",
+    "自定义货币符号将显示在所有额度数值前,例如 €1.50": "自定义货币符号将显示在所有额度数值前,例如 €1.50",
+    "额度展示类型": "额度展示类型",
+    "站点所有额度将以美元 ($) 显示": "站点所有额度将以美元 ($) 显示",
+    "站点所有额度将按汇率换算为人民币 (¥) 显示": "站点所有额度将按汇率换算为人民币 (¥) 显示",
+    "站点所有额度将以原始 Token 数显示,不做货币换算": "站点所有额度将以原始 Token 数显示,不做货币换算",
+    "站点所有额度将按汇率换算为自定义货币显示": "站点所有额度将按汇率换算为自定义货币显示",
+    "汇率": "汇率",
+    "每美元对应 Token 数": "每美元对应 Token 数",
+    "预览效果": "预览效果",
+    "请输入汇率": "请输入汇率",
+    "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费",
+    "系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作",
+    "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费",
     "自定义镜像": "自定义镜像",
     "自用模式": "自用模式",
     "自适应列表": "自适应列表",
@@ -2301,6 +2315,10 @@
     "调用次数": "调用次数",
     "调用次数分布": "调用次数分布",
     "调用次数排行": "调用次数排行",
+    "调用趋势": "调用趋势",
+    "模型排行": "模型排行",
+    "用户消耗排行": "用户消耗排行",
+    "用户消耗趋势": "用户消耗趋势",
     "调试信息": "调试信息",
     "谨慎": "谨慎",
     "警告": "警告",
@@ -2559,6 +2577,8 @@
     "重置配置": "重置配置",
     "重要提醒": "重要提醒",
     "重试": "重试",
+    "不重试": "不重试",
+    "失败后是否重试": "失败后是否重试",
     "重试连接": "重试连接",
     "钱包管理": "钱包管理",
     "链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1",
@@ -3087,6 +3107,8 @@
     "从剪贴板粘贴配置": "从剪贴板粘贴配置",
     "剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
     "连接信息已填入": "连接信息已填入",
-    "无法读取剪贴板": "无法读取剪贴板"
+    "无法读取剪贴板": "无法读取剪贴板",
+    "页面渲染出错,请刷新页面重试": "页面渲染出错,请刷新页面重试",
+    "刷新页面": "刷新页面"
   }
 }

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 261 - 85
web/src/i18n/locales/zh-TW.json


+ 67 - 35
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx

@@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
     },
     "skip_retry_on_failure": false,
     "include_using_group": true,
+    "include_model_name": false,
     "include_rule_name": true
   }
 ]`;
@@ -191,6 +192,36 @@ const parseOptionalObjectJson = (jsonString, label) => {
   }
 };
 
+const buildChannelAffinityRulePayload = ({
+  values,
+  isEdit,
+  editingRuleId,
+  rulesLength,
+  modelRegex,
+  pathRegex,
+  keySources,
+  userAgentInclude,
+  paramOverrideTemplate,
+}) => ({
+  id: isEdit ? editingRuleId : rulesLength,
+  name: (values?.name || '').trim(),
+  model_regex: modelRegex,
+  path_regex: pathRegex,
+  key_sources: keySources,
+  value_regex: (values?.value_regex || '').trim(),
+  ttl_seconds: Number(values?.ttl_seconds || 0),
+  include_using_group: !!values?.include_using_group,
+  include_model_name: !!values?.include_model_name,
+  include_rule_name: !!values?.include_rule_name,
+  skip_retry_on_failure: !!values?.skip_retry_on_failure,
+  ...(userAgentInclude.length > 0
+    ? { user_agent_include: userAgentInclude }
+    : {}),
+  ...(paramOverrideTemplate
+    ? { param_override_template: paramOverrideTemplate }
+    : {}),
+});
+
 export default function SettingsChannelAffinity(props) {
   const { t } = useTranslation();
   const { Text } = Typography;
@@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
       ttl_seconds: Number(r.ttl_seconds || 0),
       skip_retry_on_failure: !!r.skip_retry_on_failure,
       include_using_group: r.include_using_group ?? true,
+      include_model_name: !!r.include_model_name,
       include_rule_name: r.include_rule_name ?? true,
       param_override_template_json: r.param_override_template
         ? stringifyPretty(r.param_override_template)
@@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
       const templates = [
         CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
         CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
-      ].map(
-        (tpl) => {
-          const baseTemplate = cloneChannelAffinityTemplate(tpl);
-          const name = makeUniqueName(existingNames, tpl.name);
-          existingNames.add(name);
-          return { ...baseTemplate, name };
-        },
-      );
+      ].map((tpl) => {
+        const baseTemplate = cloneChannelAffinityTemplate(tpl);
+        const name = makeUniqueName(existingNames, tpl.name);
+        existingNames.add(name);
+        return { ...baseTemplate, name };
+      });
 
       const next = [...(rules || []), ...templates].map((r, idx) => ({
         ...(r || {}),
@@ -540,11 +570,11 @@ export default function SettingsChannelAffinity(props) {
       render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
     },
     {
-      title: t('失败后重试'),
+      title: t('失败后是否重试'),
       dataIndex: 'skip_retry_on_failure',
       render: (value) => (
-        <Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}>
-          {value ? t('是') : t('否')}
+        <Tag color={value ? 'orange' : 'green'} style={{ marginRight: 4 }}>
+          {value ? t('不重试') : t('重试')}
         </Tag>
       ),
     },
@@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
       title: t('作用域'),
       render: (_, record) => {
         const tags = [];
-        if (record?.include_using_group) tags.push('分组');
-        if (record?.include_rule_name) tags.push('规则');
+        if (record?.include_using_group) tags.push(t('分组'));
+        if (record?.include_model_name) tags.push(t('模型'));
+        if (record?.include_rule_name) tags.push(t('规则'));
         if (tags.length === 0) return '-';
         return tags.map((x) => (
           <Tag key={x} style={{ marginRight: 4 }}>
@@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
       ttl_seconds: 0,
       skip_retry_on_failure: false,
       include_using_group: true,
+      include_model_name: false,
       include_rule_name: true,
     };
     setEditingRule(nextRule);
@@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
         return showError(t(paramTemplateValidation.message));
       }
 
-      const rulePayload = {
-        id: isEdit ? editingRule.id : rules.length,
-        name: (values.name || '').trim(),
-        model_regex: modelRegex,
-        path_regex: normalizeStringList(values.path_regex_text),
-        key_sources: keySourcesValidation.value,
-        value_regex: (values.value_regex || '').trim(),
-        ttl_seconds: Number(values.ttl_seconds || 0),
-        include_using_group: !!values.include_using_group,
-        include_rule_name: !!values.include_rule_name,
-        ...(values.skip_retry_on_failure
-          ? { skip_retry_on_failure: true }
-          : {}),
-        ...(userAgentInclude.length > 0
-          ? { user_agent_include: userAgentInclude }
-          : {}),
-        ...(paramTemplateValidation.value
-          ? { param_override_template: paramTemplateValidation.value }
-          : {}),
-      };
+      const rulePayload = buildChannelAffinityRulePayload({
+        values,
+        isEdit,
+        editingRuleId: editingRule?.id,
+        rulesLength: rules.length,
+        modelRegex,
+        pathRegex: normalizeStringList(values.path_regex_text),
+        keySources: keySourcesValidation.value,
+        userAgentInclude,
+        paramOverrideTemplate: paramTemplateValidation.value,
+      });
 
       if (!rulePayload.name) return showError(t('名称不能为空'));
 
@@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
               </Row>
 
               <Row gutter={16}>
-                <Col xs={24} sm={12}>
+                <Col xs={24} sm={8}>
                   <Form.Switch
                     field='include_using_group'
                     label={t('作用域:包含分组')}
@@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
                     )}
                   </Text>
                 </Col>
-                <Col xs={24} sm={12}>
+                <Col xs={24} sm={8}>
+                  <Form.Switch
+                    field='include_model_name'
+                    label={t('作用域:包含模型名称')}
+                  />
+                  <Text type='tertiary' size='small'>
+                    {t('开启后,模型名称会参与 cache key(不同模型隔离)。')}
+                  </Text>
+                </Col>
+                <Col xs={24} sm={8}>
                   <Form.Switch
                     field='include_rule_name'
                     label={t('作用域:包含规则名称')}

+ 133 - 30
web/src/pages/Setting/Operation/SettingsGeneral.jsx

@@ -26,9 +26,8 @@ import {
   Row,
   Spin,
   Modal,
-  Select,
-  InputGroup,
   Input,
+  Typography,
 } from '@douyinfe/semi-ui';
 import {
   compareObjects,
@@ -39,6 +38,8 @@ import {
 } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
 
+const { Text } = Typography;
+
 export default function GeneralSettings(props) {
   const { t } = useTranslation();
   const [loading, setLoading] = useState(false);
@@ -126,6 +127,77 @@ export default function GeneralSettings(props) {
     }
   };
 
+  const showTokensOption = useMemo(() => {
+    const initialType = props.options?.['general_setting.quota_display_type'];
+    const initialQuotaPerUnit = parseFloat(props.options?.QuotaPerUnit);
+    const legacyTokensMode =
+      initialType === undefined &&
+      props.options?.DisplayInCurrencyEnabled !== undefined &&
+      !props.options.DisplayInCurrencyEnabled;
+    return (
+      initialType === 'TOKENS' ||
+      legacyTokensMode ||
+      (!isNaN(initialQuotaPerUnit) && initialQuotaPerUnit !== 500000)
+    );
+  }, [props.options]);
+
+  const quotaDisplayType = inputs['general_setting.quota_display_type'];
+
+  const quotaDisplayTypeDesc = useMemo(() => {
+    const descMap = {
+      USD: t('站点所有额度将以美元 ($) 显示'),
+      CNY: t('站点所有额度将按汇率换算为人民币 (¥) 显示'),
+      TOKENS: t('站点所有额度将以原始 Token 数显示,不做货币换算'),
+      CUSTOM: t('站点所有额度将按汇率换算为自定义货币显示'),
+    };
+    return descMap[quotaDisplayType] || '';
+  }, [quotaDisplayType, t]);
+
+  const rateLabel = useMemo(() => {
+    if (quotaDisplayType === 'CNY') return t('汇率');
+    if (quotaDisplayType === 'TOKENS') return t('每美元对应 Token 数');
+    if (quotaDisplayType === 'CUSTOM') return t('汇率');
+    return '';
+  }, [quotaDisplayType, t]);
+
+  const rateSuffix = useMemo(() => {
+    if (quotaDisplayType === 'CNY') return 'CNY (¥)';
+    if (quotaDisplayType === 'TOKENS') return 'Tokens';
+    if (quotaDisplayType === 'CUSTOM')
+      return inputs['general_setting.custom_currency_symbol'] || '¤';
+    return '';
+  }, [quotaDisplayType, inputs]);
+
+  const rateExtraText = useMemo(() => {
+    if (quotaDisplayType === 'CNY')
+      return t(
+        '系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费',
+      );
+    if (quotaDisplayType === 'TOKENS')
+      return t(
+        '系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作',
+      );
+    if (quotaDisplayType === 'CUSTOM')
+      return t(
+        '系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费',
+      );
+    return '';
+  }, [quotaDisplayType, t]);
+
+  const previewText = useMemo(() => {
+    if (quotaDisplayType === 'USD') return '$1.00';
+    const rate = parseFloat(combinedRate);
+    if (!rate || isNaN(rate)) return t('请输入汇率');
+    if (quotaDisplayType === 'CNY') return `$1.00 → ¥${rate.toFixed(2)}`;
+    if (quotaDisplayType === 'TOKENS')
+      return `$1.00 → ${Number(rate).toLocaleString()} Tokens`;
+    if (quotaDisplayType === 'CUSTOM') {
+      const symbol = inputs['general_setting.custom_currency_symbol'] || '¤';
+      return `$1.00 → ${symbol}${rate.toFixed(2)}`;
+    }
+    return '';
+  }, [quotaDisplayType, combinedRate, inputs, t]);
+
   useEffect(() => {
     const currentInputs = {};
     for (let key in props.options) {
@@ -202,48 +274,79 @@ export default function GeneralSettings(props) {
                 />
               </Col>
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
-                <Form.Slot label={t('站点额度展示类型及汇率')}>
-                  <InputGroup style={{ width: '100%' }}>
+                <Form.Select
+                  field='general_setting.quota_display_type'
+                  label={t('额度展示类型')}
+                  extraText={quotaDisplayTypeDesc}
+                  onChange={handleFieldChange(
+                    'general_setting.quota_display_type',
+                  )}
+                >
+                  <Form.Select.Option value='USD'>
+                    USD ($)
+                  </Form.Select.Option>
+                  <Form.Select.Option value='CNY'>
+                    CNY (¥)
+                  </Form.Select.Option>
+                  {showTokensOption && (
+                    <Form.Select.Option value='TOKENS'>
+                      Tokens
+                    </Form.Select.Option>
+                  )}
+                  <Form.Select.Option value='CUSTOM'>
+                    {t('自定义货币')}
+                  </Form.Select.Option>
+                </Form.Select>
+              </Col>
+              {quotaDisplayType !== 'USD' && (
+                <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                  <Form.Slot label={rateLabel}>
                     <Input
-                      prefix={'1 USD = '}
-                      style={{ width: '50%' }}
+                      prefix='1 USD = '
+                      suffix={rateSuffix}
                       value={combinedRate}
                       onChange={onCombinedRateChange}
-                      disabled={
-                        inputs['general_setting.quota_display_type'] === 'USD'
-                      }
                     />
-                    <Select
-                      style={{ width: '50%' }}
-                      value={inputs['general_setting.quota_display_type']}
-                      onChange={handleFieldChange(
-                        'general_setting.quota_display_type',
-                      )}
+                    <Text
+                      type='tertiary'
+                      size='small'
+                      style={{ marginTop: 4, display: 'block' }}
                     >
-                      <Select.Option value='USD'>USD ($)</Select.Option>
-                      <Select.Option value='CNY'>CNY (¥)</Select.Option>
-                      <Select.Option value='TOKENS'>Tokens</Select.Option>
-                      <Select.Option value='CUSTOM'>
-                        {t('自定义货币')}
-                      </Select.Option>
-                    </Select>
-                  </InputGroup>
-                </Form.Slot>
-              </Col>
-              <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+                      {rateExtraText}
+                    </Text>
+                  </Form.Slot>
+                </Col>
+              )}
+              <Col
+                xs={24}
+                sm={12}
+                md={8}
+                lg={8}
+                xl={8}
+                style={
+                  quotaDisplayType !== 'CUSTOM'
+                    ? { display: 'none' }
+                    : undefined
+                }
+              >
                 <Form.Input
-                  field={'general_setting.custom_currency_symbol'}
+                  field='general_setting.custom_currency_symbol'
                   label={t('自定义货币符号')}
+                  extraText={t(
+                    '自定义货币符号将显示在所有额度数值前,例如 €1.50',
+                  )}
                   placeholder={t('例如 €, £, Rp, ₩, ₹...')}
                   onChange={handleFieldChange(
                     'general_setting.custom_currency_symbol',
                   )}
                   showClear
-                  disabled={
-                    inputs['general_setting.quota_display_type'] !== 'CUSTOM'
-                  }
                 />
               </Col>
+              <Col span={24}>
+                <Text type='tertiary' size='small'>
+                  {t('预览效果')}:{previewText}
+                </Text>
+              </Col>
             </Row>
             <Row gutter={16}>
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>

+ 0 - 1
web/src/pages/Setting/Performance/SettingsPerformance.jsx

@@ -356,7 +356,6 @@ export default function SettingsPerformance(props) {
                   label={t('CPU 阈值 (%)')}
                   extraText={t('CPU 使用率超过此值时拒绝请求')}
                   min={0}
-                  max={100}
                   onChange={handleFieldChange(
                     'performance_setting.monitor_cpu_threshold',
                   )}

+ 570 - 83
web/src/pages/Setting/Ratio/GroupRatioSettings.jsx

@@ -17,8 +17,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useEffect, useState, useRef } from 'react';
-import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
+import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
+import {
+  Button,
+  Col,
+  Collapsible,
+  Form,
+  Radio,
+  RadioGroup,
+  Row,
+  SideSheet,
+  Spin,
+  Switch,
+  Tabs,
+  Typography,
+} from '@douyinfe/semi-ui';
+import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
+import { IconHelpCircle } from '@douyinfe/semi-icons';
 import {
   compareObjects,
   API,
@@ -28,10 +43,37 @@ import {
   verifyJSON,
 } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
+import GroupTable from './components/GroupTable';
+import AutoGroupList from './components/AutoGroupList';
+import GroupGroupRatioRules from './components/GroupGroupRatioRules';
+import GroupSpecialUsableRules from './components/GroupSpecialUsableRules';
+
+const { Text, Title, Paragraph } = Typography;
+
+const OPTION_KEYS = [
+  'GroupRatio',
+  'UserUsableGroups',
+  'GroupGroupRatio',
+  'group_ratio_setting.group_special_usable_group',
+  'AutoGroups',
+  'DefaultUseAutoGroup',
+];
+
+function parseJSONSafe(str, fallback) {
+  if (!str || !str.trim()) return fallback;
+  try {
+    return JSON.parse(str);
+  } catch {
+    return fallback;
+  }
+}
 
 export default function GroupRatioSettings(props) {
   const { t } = useTranslation();
   const [loading, setLoading] = useState(false);
+  const [editMode, setEditMode] = useState('visual');
+  const [showGuide, setShowGuide] = useState(false);
+
   const [inputs, setInputs] = useState({
     GroupRatio: '',
     UserUsableGroups: '',
@@ -42,80 +84,189 @@ export default function GroupRatioSettings(props) {
   });
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(inputs);
+  const dataVersionRef = useRef(0);
+
+  const groupNames = useMemo(() => {
+    const ratioMap = parseJSONSafe(inputs.GroupRatio, {});
+    return Object.keys(ratioMap);
+  }, [inputs.GroupRatio]);
 
   async function onSubmit() {
-    try {
-      await refForm.current
-        .validate()
-        .then(() => {
-          const updateArray = compareObjects(inputs, inputsRow);
-          if (!updateArray.length)
-            return showWarning(t('你似乎并没有修改什么'));
-
-          const requestQueue = updateArray.map((item) => {
-            const value =
-              typeof inputs[item.key] === 'boolean'
-                ? String(inputs[item.key])
-                : inputs[item.key];
-            return API.put('/api/option/', { key: item.key, value });
-          });
-
-          setLoading(true);
-          Promise.all(requestQueue)
-            .then((res) => {
-              if (res.includes(undefined)) {
-                return showError(
-                  requestQueue.length > 1
-                    ? t('部分保存失败,请重试')
-                    : t('保存失败'),
-                );
-              }
+    if (editMode === 'manual') {
+      try {
+        await refForm.current.validate();
+      } catch {
+        showError(t('请检查输入'));
+        return;
+      }
+    }
 
-              for (let i = 0; i < res.length; i++) {
-                if (!res[i].data.success) {
-                  return showError(res[i].data.message);
-                }
-              }
+    const updateArray = compareObjects(inputs, inputsRow);
+    if (!updateArray.length) {
+      return showWarning(t('你似乎并没有修改什么'));
+    }
 
-              showSuccess(t('保存成功'));
-              props.refresh();
-            })
-            .catch((error) => {
-              console.error('Unexpected error:', error);
-              showError(t('保存失败,请重试'));
-            })
-            .finally(() => {
-              setLoading(false);
-            });
-        })
-        .catch(() => {
-          showError(t('请检查输入'));
-        });
+    const requestQueue = updateArray.map((item) => {
+      const value =
+        typeof inputs[item.key] === 'boolean'
+          ? String(inputs[item.key])
+          : inputs[item.key];
+      return API.put('/api/option/', { key: item.key, value });
+    });
+
+    setLoading(true);
+    try {
+      const res = await Promise.all(requestQueue);
+      if (res.includes(undefined)) {
+        return showError(
+          requestQueue.length > 1
+            ? t('部分保存失败,请重试')
+            : t('保存失败'),
+        );
+      }
+      for (let i = 0; i < res.length; i++) {
+        if (!res[i].data.success) {
+          return showError(res[i].data.message);
+        }
+      }
+      showSuccess(t('保存成功'));
+      props.refresh();
     } catch (error) {
-      showError(t('请检查输入'));
-      console.error(error);
+      console.error('Unexpected error:', error);
+      showError(t('保存失败,请重试'));
+    } finally {
+      setLoading(false);
     }
   }
 
   useEffect(() => {
     const currentInputs = {};
     for (let key in props.options) {
-      if (Object.keys(inputs).includes(key)) {
+      if (OPTION_KEYS.includes(key)) {
         currentInputs[key] = props.options[key];
       }
     }
     setInputs(currentInputs);
     setInputsRow(structuredClone(currentInputs));
-    refForm.current.setValues(currentInputs);
+    dataVersionRef.current += 1;
+    if (refForm.current) {
+      refForm.current.setValues(currentInputs);
+    }
   }, [props.options]);
 
-  return (
-    <Spin spinning={loading}>
-      <Form
-        values={inputs}
-        getFormApi={(formAPI) => (refForm.current = formAPI)}
-        style={{ marginBottom: 15 }}
-      >
+  const handleGroupTableChange = useCallback(
+    ({ GroupRatio, UserUsableGroups }) => {
+      setInputs((prev) => ({ ...prev, GroupRatio, UserUsableGroups }));
+    },
+    [],
+  );
+
+  const handleAutoGroupsChange = useCallback((value) => {
+    setInputs((prev) => ({ ...prev, AutoGroups: value }));
+  }, []);
+
+  const handleGroupGroupRatioChange = useCallback((value) => {
+    setInputs((prev) => ({ ...prev, GroupGroupRatio: value }));
+  }, []);
+
+  const handleSpecialUsableChange = useCallback((value) => {
+    setInputs((prev) => ({
+      ...prev,
+      'group_ratio_setting.group_special_usable_group': value,
+    }));
+  }, []);
+
+  const dv = dataVersionRef.current;
+
+  const renderVisualMode = () => (
+    <Form key='form-visual' values={inputs} style={{ marginBottom: 15 }}>
+      <Form.Section text={t('分组管理')}>
+        <Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
+          {t('倍率用于计费乘数,勾选「用户可选」后用户可在创建令牌时选择该分组')}
+        </Text>
+        <GroupTable
+          key={`gt_${dv}`}
+          groupRatio={inputs.GroupRatio}
+          userUsableGroups={inputs.UserUsableGroups}
+          onChange={handleGroupTableChange}
+        />
+      </Form.Section>
+
+      <Form.Section text={t('自动分组')}>
+        <Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
+          {t('令牌分组设为 auto 时,按以下顺序依次尝试选择可用分组,排在前面的优先级更高')}
+        </Text>
+        <Row gutter={16}>
+          <Col xs={24} sm={12} md={8} lg={8} xl={8}>
+            <Form.Slot label={t('默认使用auto分组')}>
+              <div className='flex items-center gap-2'>
+                <Switch
+                  checked={!!inputs.DefaultUseAutoGroup}
+                  size='default'
+                  checkedText='|'
+                  uncheckedText='〇'
+                  onChange={(value) =>
+                    setInputs((prev) => ({
+                      ...prev,
+                      DefaultUseAutoGroup: value,
+                    }))
+                  }
+                />
+              </div>
+              <Text type='tertiary' size='small' style={{ marginTop: 4 }}>
+                {t('开启后创建令牌默认选择auto分组,初始令牌也将设为auto')}
+              </Text>
+            </Form.Slot>
+          </Col>
+        </Row>
+        <AutoGroupList
+          key={`ag_${dv}`}
+          value={inputs.AutoGroups}
+          groupNames={groupNames}
+          onChange={handleAutoGroupsChange}
+        />
+      </Form.Section>
+
+      <Form.Section text={t('分组特殊倍率')}>
+        <Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
+          {t('当某个分组的用户使用另一个分组的令牌时,可设置特殊倍率覆盖基础倍率。例如:vip 分组的用户使用 default 分组时倍率为 0.5')}
+        </Text>
+        <GroupGroupRatioRules
+          key={`ggr_${dv}`}
+          value={inputs.GroupGroupRatio}
+          groupNames={groupNames}
+          onChange={handleGroupGroupRatioChange}
+        />
+      </Form.Section>
+
+      <Form.Section text={t('分组特殊可用分组')}>
+        <Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
+          {t('为特定用户分组配置可用分组的增减规则。「添加」为该分组新增可用分组,「移除」移除默认可用分组,「追加」直接追加分组')}
+        </Text>
+        <GroupSpecialUsableRules
+          key={`gsu_${dv}`}
+          value={inputs['group_ratio_setting.group_special_usable_group']}
+          groupNames={groupNames}
+          onChange={handleSpecialUsableChange}
+        />
+      </Form.Section>
+    </Form>
+  );
+
+  useEffect(() => {
+    if (editMode === 'manual' && refForm.current) {
+      refForm.current.setValues(inputs);
+    }
+  }, [editMode]);
+
+  const renderManualMode = () => (
+    <Form
+      key='form-manual'
+      initValues={inputs}
+      getFormApi={(formAPI) => (refForm.current = formAPI)}
+      style={{ marginBottom: 15 }}
+    >
+      <Form.Section text={t('分组JSON设置')}>
         <Row gutter={16}>
           <Col xs={24} sm={16}>
             <Form.TextArea
@@ -134,7 +285,9 @@ export default function GroupRatioSettings(props) {
                   message: t('不是合法的 JSON 字符串'),
                 },
               ]}
-              onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
+              onChange={(value) =>
+                setInputs((prev) => ({ ...prev, GroupRatio: value }))
+              }
             />
           </Col>
         </Row>
@@ -142,7 +295,9 @@ export default function GroupRatioSettings(props) {
           <Col xs={24} sm={16}>
             <Form.TextArea
               label={t('用户可选分组')}
-              placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
+              placeholder={t(
+                '为一个 JSON 文本,键为分组名称,值为分组描述',
+              )}
               extraText={t(
                 '用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
               )}
@@ -157,7 +312,7 @@ export default function GroupRatioSettings(props) {
                 },
               ]}
               onChange={(value) =>
-                setInputs({ ...inputs, UserUsableGroups: value })
+                setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
               }
             />
           </Col>
@@ -181,7 +336,7 @@ export default function GroupRatioSettings(props) {
                 },
               ]}
               onChange={(value) =>
-                setInputs({ ...inputs, GroupGroupRatio: value })
+                setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
               }
             />
           </Col>
@@ -205,10 +360,10 @@ export default function GroupRatioSettings(props) {
                 },
               ]}
               onChange={(value) =>
-                setInputs({
-                  ...inputs,
+                setInputs((prev) => ({
+                  ...prev,
                   'group_ratio_setting.group_special_usable_group': value,
-                })
+                }))
               }
             />
           </Col>
@@ -225,29 +380,23 @@ export default function GroupRatioSettings(props) {
               rules={[
                 {
                   validator: (rule, value) => {
-                    if (!value || value.trim() === '') {
-                      return true; // Allow empty values
-                    }
-
-                    // First check if it's valid JSON
+                    if (!value || value.trim() === '') return true;
                     try {
                       const parsed = JSON.parse(value);
-
-                      // Check if it's an array
-                      if (!Array.isArray(parsed)) {
-                        return false;
-                      }
-
-                      // Check if every element is a string
+                      if (!Array.isArray(parsed)) return false;
                       return parsed.every((item) => typeof item === 'string');
-                    } catch (error) {
+                    } catch {
                       return false;
                     }
                   },
-                  message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
+                  message: t(
+                    '必须是有效的 JSON 字符串数组,例如:["g1","g2"]',
+                  ),
                 },
               ]}
-              onChange={(value) => setInputs({ ...inputs, AutoGroups: value })}
+              onChange={(value) =>
+                setInputs((prev) => ({ ...prev, AutoGroups: value }))
+              }
             />
           </Col>
         </Row>
@@ -259,13 +408,351 @@ export default function GroupRatioSettings(props) {
               )}
               field={'DefaultUseAutoGroup'}
               onChange={(value) =>
-                setInputs({ ...inputs, DefaultUseAutoGroup: value })
+                setInputs((prev) => ({
+                  ...prev,
+                  DefaultUseAutoGroup: value,
+                }))
               }
             />
           </Col>
         </Row>
-      </Form>
-      <Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
+      </Form.Section>
+    </Form>
+  );
+
+  const GuideSection = ({ title, children }) => {
+    const [open, setOpen] = useState(false);
+    return (
+      <div style={{ marginTop: 16 }}>
+        <Button
+          theme='borderless'
+          size='small'
+          icon={open ? <IconChevronUp /> : <IconChevronDown />}
+          onClick={() => setOpen(!open)}
+          style={{ padding: '4px 0', color: 'var(--semi-color-primary)' }}
+        >
+          {title}
+        </Button>
+        <Collapsible isOpen={open} keepDOM>
+          <div
+            style={{
+              background: 'var(--semi-color-fill-0)',
+              padding: '12px 16px',
+              borderRadius: 8,
+              marginTop: 8,
+            }}
+          >
+            {children}
+          </div>
+        </Collapsible>
+      </div>
+    );
+  };
+
+  const CodeBlock = ({ children }) => (
+    <pre
+      style={{
+        background: 'var(--semi-color-bg-2)',
+        border: '1px solid var(--semi-color-border)',
+        padding: '10px 14px',
+        borderRadius: 6,
+        fontFamily: 'monospace',
+        fontSize: 13,
+        margin: '8px 0',
+        whiteSpace: 'pre-wrap',
+        lineHeight: 1.6,
+        overflowX: 'auto',
+      }}
+    >
+      {children}
+    </pre>
+  );
+
+  const renderGuide = () => (
+    <SideSheet
+      title={t('分组设置使用说明')}
+      visible={showGuide}
+      onCancel={() => setShowGuide(false)}
+      width={560}
+      bodyStyle={{ overflow: 'auto', padding: '0 24px 24px' }}
+    >
+      <Tabs type='line' size='small'>
+        <Tabs.TabPane tab={t('概览')} itemKey='overview'>
+          <div style={{ paddingTop: 20 }}>
+            <Title heading={5}>{t('什么是分组?')}</Title>
+            <Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
+              {t(
+                '分组是用于控制计费倍率和模型访问权限的核心概念。每个用户属于一个分组,每个令牌也可以指定使用某个分组。',
+              )}
+            </Paragraph>
+            <Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
+              {t(
+                '通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。',
+              )}
+            </Paragraph>
+
+            <GuideSection title={t('核心概念')}>
+              <Paragraph style={{ lineHeight: 1.8 }}>
+                <Text strong>{t('用户分组')}</Text>{' — '}
+                {t('由管理员分配,决定用户身份等级(如 default、vip)。')}
+              </Paragraph>
+              <Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
+                <Text strong>{t('令牌分组')}</Text>{' — '}
+                {t('用户创建令牌时选择的分组,决定该令牌的实际计费倍率。一个用户可以创建多个令牌,使用不同分组。')}
+              </Paragraph>
+              <Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
+                <Text strong>{t('倍率')}</Text>{' — '}
+                {t('计费乘数,倍率越低费用越低。例如倍率 0.5 表示半价。')}
+              </Paragraph>
+              <Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
+                <Text strong>{t('用户可选')}</Text>{' — '}
+                {t('勾选后,该分组会出现在用户创建令牌时的下拉菜单中。未勾选的分组只能由管理员分配,用户自己无法选择。')}
+              </Paragraph>
+              <Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
+                <Text strong>{t('自动分组')}</Text>{' — '}
+                {t('令牌分组设为 auto 时,系统按优先级顺序自动选择一个可用分组。')}
+              </Paragraph>
+            </GuideSection>
+          </div>
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab={t('分组管理')} itemKey='groups'>
+          <div style={{ paddingTop: 20 }}>
+            <Title heading={5}>{t('创建和管理分组')}</Title>
+            <Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
+              {t('每个分组代表一个价格档位。管理员创建分组后,可以选择哪些档位对用户开放自选。')}
+            </Paragraph>
+
+            <GuideSection title={t('查看示例')}>
+              <Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
+                {t('场景:站点提供两个价格档位,用户可以按需选择')}
+              </Paragraph>
+              <CodeBlock>
+                {`${t('分组名')}      ${t('倍率')}    ${t('用户可选')}    ${t('说明')}\n──────────────────────────────────────\nstandard  1.0     ${t('是')}        ${t('标准价格')}\npremium   0.5     ${t('是')}        ${t('高级套餐,半价优惠')}`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
+                {t('两个分组都勾选了「用户可选」,所以用户创建令牌时可以看到这两个选项:')}
+              </Paragraph>
+              <CodeBlock>
+                {t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
+                {`  ├─ standard (${t('标准价格')})`}{'\n'}
+                {`  └─ premium  (${t('高级套餐,半价优惠')})`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
+                {t('选择 premium 创建的令牌,调用 API 时费用为 standard 的 50%。')}
+              </Paragraph>
+              <Paragraph size='small' style={{ marginTop: 16, lineHeight: 1.8 }}>
+                <Text strong>{t('对比:不勾选「用户可选」的场景')}</Text>
+              </Paragraph>
+              <Paragraph size='small' style={{ marginTop: 4, lineHeight: 1.8 }}>
+                {t('假设再加两个分组 default 和 vip,但不勾选用户可选:')}
+              </Paragraph>
+              <CodeBlock>
+                {`${t('分组名')}      ${t('倍率')}    ${t('用户可选')}    ${t('说明')}\n──────────────────────────────────────\ndefault   1.0     ${t('否')}        ${t('管理员分配的基础分组')}\nvip       0.5     ${t('否')}        ${t('管理员分配的优惠分组')}\nstandard  1.0     ${t('是')}        ${t('标准价格')}\npremium   0.5     ${t('是')}        ${t('高级套餐,半价优惠')}`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
+                {t('此时用户创建令牌时只能看到 standard 和 premium:')}
+              </Paragraph>
+              <CodeBlock>
+                {t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
+                {`  ├─ standard (${t('标准价格')})`}{'\n'}
+                {`  └─ premium  (${t('高级套餐,半价优惠')})`}{'\n\n'}
+                {`  ${t('不会出现')} default ${t('和')} vip`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
+                {t('default 和 vip 只能由管理员在「用户管理」中分配给用户。适用于按用户等级定价、内部测试等不希望用户自主选择的场景。')}
+              </Paragraph>
+              <Paragraph size='small' style={{ marginTop: 12, lineHeight: 1.8 }}>
+                <Text strong>{t('用户分组的联动作用')}</Text>
+              </Paragraph>
+              <Paragraph size='small' style={{ lineHeight: 1.8 }}>
+                {t('管理员给用户分配的分组(如 vip)不仅决定用户身份,还会影响后续两个功能:')}
+              </Paragraph>
+              <Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 4 }}>
+                {'1. '}<Text strong>{t('特殊倍率')}</Text>{' — '}
+                {t('可以根据用户分组设置不同的计费倍率。例如 vip 用户使用 standard 令牌时倍率从 1.0 降为 0.8。')}
+              </Paragraph>
+              <Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 2 }}>
+                {'2. '}<Text strong>{t('可用分组')}</Text>{' — '}
+                {t('可以根据用户分组增减令牌可选的分组范围。例如 vip 用户额外开放 premium 分组,或移除某个分组的选择权。')}
+              </Paragraph>
+              <Paragraph size='small' type='tertiary' style={{ lineHeight: 1.8, marginTop: 6 }}>
+                {t('详见「特殊倍率」和「可用分组」标签页。')}
+              </Paragraph>
+            </GuideSection>
+
+            <GuideSection title={t('JSON 格式参考')}>
+              <Paragraph size='small' style={{ marginBottom: 4 }}>
+                <Text strong code>GroupRatio</Text>{' — '}{t('分组名称到倍率的映射')}
+              </Paragraph>
+              <CodeBlock>{`{"default": 1, "vip": 0.5, "standard": 1, "premium": 0.5}`}</CodeBlock>
+              <Paragraph size='small' style={{ marginBottom: 4, marginTop: 8 }}>
+                <Text strong code>UserUsableGroups</Text>{' — '}{t('用户可选分组的名称和描述(只包含勾选了用户可选的分组)')}
+              </Paragraph>
+              <CodeBlock>{`{"standard": "${t('标准价格')}", "premium": "${t('高级套餐,半价优惠')}"}`}</CodeBlock>
+            </GuideSection>
+          </div>
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab={t('自动分组')} itemKey='auto'>
+          <div style={{ paddingTop: 20 }}>
+            <Title heading={5}>{t('自动分组选择')}</Title>
+            <Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
+              {t('当令牌分组设为 auto 时,系统按列表顺序依次选择可用分组。排在前面的优先级更高。')}
+            </Paragraph>
+
+            <GuideSection title={t('查看示例')}>
+              <Paragraph size='small' type='tertiary' style={{ marginBottom: 6 }}>
+                {t('场景:设置自动选择优先级')}
+              </Paragraph>
+              <CodeBlock>
+                {`1. default    ${t('最高优先级')}\n2. vip`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 6, lineHeight: 1.6 }}>
+                {t('开启「默认使用 auto 分组」后,新建令牌和初始令牌都会自动设为 auto。')}
+              </Paragraph>
+            </GuideSection>
+
+            <GuideSection title={t('JSON 格式参考')}>
+              <Paragraph size='small' style={{ marginBottom: 4 }}>
+                <Text strong code>AutoGroups</Text>{' — '}{t('有序字符串数组')}
+              </Paragraph>
+              <CodeBlock>{`["default", "vip"]`}</CodeBlock>
+            </GuideSection>
+          </div>
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab={t('特殊倍率')} itemKey='ratios'>
+          <div style={{ paddingTop: 20 }}>
+            <Title heading={5}>{t('跨分组特殊倍率')}</Title>
+            <Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
+              {t('正常情况下,令牌的计费倍率由令牌所选的分组决定。特殊倍率可以根据「用户所在分组」进一步覆盖这个倍率。')}
+            </Paragraph>
+            <Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
+              {t('简单来说:同一个令牌分组,不同等级的用户可以享受不同的价格。')}
+            </Paragraph>
+
+            <GuideSection title={t('查看示例')}>
+              <Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
+                {t('场景:站点有 standard(倍率 1.0)和 premium(倍率 0.5)两个分组,希望 vip 用户使用 standard 令牌时也能享受折扣')}
+              </Paragraph>
+              <Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
+                <Text strong>{t('不配置特殊倍率时:')}</Text>
+              </Paragraph>
+              <CodeBlock>
+                {`${t('普通用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0  (${t('原价')})\nvip ${t('用户')}  + standard ${t('令牌')} → ${t('倍率')} 1.0  (${t('原价,和普通用户一样')})`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
+                <Text strong>{t('配置特殊倍率后:')}</Text>
+              </Paragraph>
+              <CodeBlock>
+                {`${t('用户分组')}    ${t('使用分组')}    ${t('倍率')}\n────────────────────────────\nvip       standard   0.8\nvip       premium    0.3`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
+                {t('配置后的效果:')}
+              </Paragraph>
+              <CodeBlock>
+                {`${t('普通用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0  (${t('不变')})\nvip ${t('用户')}  + standard ${t('令牌')} → ${t('倍率')} 0.8  (${t('享受 8 折')})\nvip ${t('用户')}  + premium  ${t('令牌')} → ${t('倍率')} 0.3  (${t('从 0.5 降到 0.3')})`}
+              </CodeBlock>
+              <Paragraph size='small' type='tertiary' style={{ marginTop: 10, lineHeight: 1.8 }}>
+                {t('只有配置了规则的组合才会覆盖,未配置的组合仍使用令牌分组的基础倍率。')}
+              </Paragraph>
+            </GuideSection>
+
+            <GuideSection title={t('JSON 格式参考')}>
+              <Paragraph size='small' style={{ marginBottom: 4 }}>
+                <Text strong code>GroupGroupRatio</Text>{' — '}{t('嵌套映射:用户分组 → 使用分组 → 倍率')}
+              </Paragraph>
+              <CodeBlock>{`{\n  "vip": {\n    "standard": 0.8,\n    "premium": 0.3\n  }\n}`}</CodeBlock>
+            </GuideSection>
+          </div>
+        </Tabs.TabPane>
+
+        <Tabs.TabPane tab={t('可用分组')} itemKey='usable'>
+          <div style={{ paddingTop: 20 }}>
+            <Title heading={5}>{t('特殊可用分组规则')}</Title>
+            <Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
+              {t('默认情况下,所有用户创建令牌时看到的可选分组列表是一样的(即「用户可选」列勾选的分组)。')}
+            </Paragraph>
+            <Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
+              {t('通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。')}
+            </Paragraph>
+
+            <GuideSection title={t('查看示例')}>
+              <Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
+                {t('场景:站点有 standard 和 premium 两个用户可选分组。希望 vip 用户额外看到 exclusive 分组,同时不再看到 standard 分组')}
+              </Paragraph>
+              <Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
+                <Text strong>{t('不配置规则时,所有用户看到的下拉框一样:')}</Text>
+              </Paragraph>
+              <CodeBlock>
+                {`${t('所有用户')} → ${t('创建令牌可选')}:\n  ├─ standard\n  └─ premium`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
+                <Text strong>{t('为 vip 用户配置规则:')}</Text>
+              </Paragraph>
+              <CodeBlock>
+                {`${t('用户分组')}    ${t('操作')}        ${t('目标分组')}    ${t('描述')}\n──────────────────────────────────────────\nvip       ${t('添加')} (+:)   exclusive   ${t('专属分组')}\nvip       ${t('移除')} (-:)   standard    -`}
+              </CodeBlock>
+              <Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
+                {t('配置后的效果:')}
+              </Paragraph>
+              <CodeBlock>
+                {`${t('普通用户')} → ${t('创建令牌可选')}:\n  ├─ standard\n  └─ premium\n\nvip ${t('用户')} → ${t('创建令牌可选')}:\n  ├─ premium     (${t('保留')})\n  └─ exclusive   (${t('新增')})\n\n  ${t('standard 已被移除,vip 用户看不到')}`}
+              </CodeBlock>
+
+              <Paragraph size='small' style={{ marginTop: 14, lineHeight: 1.8 }}>
+                <Text strong>{t('三种操作的区别:')}</Text>
+              </Paragraph>
+              <CodeBlock>
+                {`${t('添加')} (+:)  → ${t('在默认列表基础上新增一个分组')}\n${t('移除')} (-:)  → ${t('从默认列表中去掉一个分组')}\n${t('追加')}       → ${t('直接追加(和添加类似,但无前缀)')}`}
+              </CodeBlock>
+            </GuideSection>
+
+            <GuideSection title={t('JSON 格式参考')}>
+              <Paragraph size='small' style={{ marginBottom: 4 }}>
+                <Text strong code>group_special_usable_group</Text>
+              </Paragraph>
+              <CodeBlock>{`{\n  "vip": {\n    "+:exclusive": "${t('专属分组')}",\n    "-:standard": "remove"\n  }\n}`}</CodeBlock>
+              <Paragraph size='small' type='tertiary' style={{ marginTop: 6, lineHeight: 1.6 }}>
+                {t('键的前缀 +: 表示添加,-: 表示移除,无前缀表示追加。值为分组描述(移除时填 "remove")。')}
+              </Paragraph>
+            </GuideSection>
+          </div>
+        </Tabs.TabPane>
+      </Tabs>
+    </SideSheet>
+  );
+
+  return (
+    <Spin spinning={loading}>
+      <div style={{ marginBottom: 15 }}>
+        <div className='flex items-center gap-3' style={{ marginTop: 12, marginBottom: 16 }}>
+          <RadioGroup
+            type='button'
+            size='small'
+            value={editMode}
+            onChange={(e) => setEditMode(e.target.value)}
+          >
+            <Radio value='visual'>{t('可视化编辑')}</Radio>
+            <Radio value='manual'>{t('手动编辑')}</Radio>
+          </RadioGroup>
+          <Button
+            icon={<IconHelpCircle />}
+            theme='borderless'
+            type='tertiary'
+            size='small'
+            onClick={() => setShowGuide(true)}
+          >
+            {t('使用说明')}
+          </Button>
+        </div>
+        {editMode === 'visual' ? renderVisualMode() : renderManualMode()}
+      </div>
+      <Button size='default' onClick={onSubmit}>
+        {t('保存分组相关设置')}
+      </Button>
+      {renderGuide()}
     </Spin>
   );
 }

+ 50 - 0
web/src/pages/Setting/Ratio/ModelPricingCombined.jsx

@@ -0,0 +1,50 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState } from 'react';
+import { Radio, RadioGroup } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import ModelPricingEditor from './components/ModelPricingEditor';
+import ModelRatioSettings from './ModelRatioSettings';
+
+export default function ModelPricingCombined({ options, refresh }) {
+  const { t } = useTranslation();
+  const [editMode, setEditMode] = useState('visual');
+
+  return (
+    <div>
+      <div style={{ marginTop: 12, marginBottom: 16 }}>
+        <RadioGroup
+          type='button'
+          size='small'
+          value={editMode}
+          onChange={(e) => setEditMode(e.target.value)}
+        >
+          <Radio value='visual'>{t('可视化编辑')}</Radio>
+          <Radio value='manual'>{t('手动编辑')}</Radio>
+        </RadioGroup>
+      </div>
+      {editMode === 'visual' ? (
+        <ModelPricingEditor options={options} refresh={refresh} />
+      ) : (
+        <ModelRatioSettings options={options} refresh={refresh} />
+      )}
+    </div>
+  );
+}

+ 169 - 0
web/src/pages/Setting/Ratio/components/AutoGroupList.jsx

@@ -0,0 +1,169 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import {
+  Button,
+  Select,
+  Typography,
+  Popconfirm,
+  Tag,
+} from '@douyinfe/semi-ui';
+import {
+  IconPlus,
+  IconDelete,
+  IconChevronUp,
+  IconChevronDown,
+} from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+let _idCounter = 0;
+const uid = () => `ag_${++_idCounter}`;
+
+function parseAutoGroups(str) {
+  if (!str || !str.trim()) return [];
+  try {
+    const parsed = JSON.parse(str);
+    if (!Array.isArray(parsed)) return [];
+    return parsed
+      .filter((item) => typeof item === 'string')
+      .map((name) => ({ _id: uid(), name }));
+  } catch {
+    return [];
+  }
+}
+
+function serializeAutoGroups(items) {
+  const names = items.map((i) => i.name).filter(Boolean);
+  return names.length === 0 ? '' : JSON.stringify(names);
+}
+
+export default function AutoGroupList({ value, groupNames = [], onChange }) {
+  const { t } = useTranslation();
+
+  const [items, setItems] = useState(() => parseAutoGroups(value));
+
+  const emitChange = useCallback(
+    (newItems) => {
+      setItems(newItems);
+      onChange?.(serializeAutoGroups(newItems));
+    },
+    [onChange],
+  );
+
+  const groupOptions = useMemo(
+    () => groupNames.map((n) => ({ value: n, label: n })),
+    [groupNames],
+  );
+
+  const addItem = useCallback(() => {
+    emitChange([...items, { _id: uid(), name: '' }]);
+  }, [items, emitChange]);
+
+  const removeItem = useCallback(
+    (id) => {
+      emitChange(items.filter((i) => i._id !== id));
+    },
+    [items, emitChange],
+  );
+
+  const updateItem = useCallback(
+    (id, name) => {
+      emitChange(items.map((i) => (i._id === id ? { ...i, name } : i)));
+    },
+    [items, emitChange],
+  );
+
+  const moveUp = useCallback(
+    (index) => {
+      if (index <= 0) return;
+      const next = [...items];
+      [next[index - 1], next[index]] = [next[index], next[index - 1]];
+      emitChange(next);
+    },
+    [items, emitChange],
+  );
+
+  const moveDown = useCallback(
+    (index) => {
+      if (index >= items.length - 1) return;
+      const next = [...items];
+      [next[index], next[index + 1]] = [next[index + 1], next[index]];
+      emitChange(next);
+    },
+    [items, emitChange],
+  );
+
+  if (items.length === 0) {
+    return (
+      <div>
+        <Text type='tertiary' className='block text-center py-4'>
+          {t('暂无自动分组,点击下方按钮添加')}
+        </Text>
+        <div className='mt-2 flex justify-center'>
+          <Button icon={<IconPlus />} theme='outline' onClick={addItem}>
+            {t('添加分组')}
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div>
+      <div className='space-y-2'>
+        {items.map((item, index) => (
+          <div
+            key={item._id}
+            className='flex items-center gap-2'
+          >
+            <Tag size='small' color='blue' className='shrink-0'>
+              {index + 1}
+            </Tag>
+            <Select
+              size='small'
+              filter
+              value={item.name || undefined}
+              placeholder={t('选择分组')}
+              optionList={groupOptions}
+              onChange={(v) => updateItem(item._id, v)}
+              style={{ flex: 1 }}
+              allowCreate
+              position='bottomLeft'
+            />
+            <Button
+              icon={<IconChevronUp />}
+              theme='borderless'
+              size='small'
+              disabled={index === 0}
+              onClick={() => moveUp(index)}
+            />
+            <Button
+              icon={<IconChevronDown />}
+              theme='borderless'
+              size='small'
+              disabled={index === items.length - 1}
+              onClick={() => moveDown(index)}
+            />
+            <Popconfirm
+              title={t('确认移除?')}
+              onConfirm={() => removeItem(item._id)}
+              position='left'
+            >
+              <Button
+                icon={<IconDelete />}
+                type='danger'
+                theme='borderless'
+                size='small'
+              />
+            </Popconfirm>
+          </div>
+        ))}
+      </div>
+      <div className='mt-3 flex justify-center'>
+        <Button icon={<IconPlus />} theme='outline' onClick={addItem}>
+          {t('添加分组')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 287 - 0
web/src/pages/Setting/Ratio/components/GroupGroupRatioRules.jsx

@@ -0,0 +1,287 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import {
+  Button,
+  Collapsible,
+  Input,
+  InputNumber,
+  Select,
+  Tag,
+  Typography,
+  Popconfirm,
+} from '@douyinfe/semi-ui';
+import {
+  IconPlus,
+  IconDelete,
+  IconChevronDown,
+  IconChevronUp,
+} from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+let _idCounter = 0;
+const uid = () => `ggr_${++_idCounter}`;
+
+function parseJSON(str) {
+  if (!str || !str.trim()) return {};
+  try {
+    return JSON.parse(str);
+  } catch {
+    return {};
+  }
+}
+
+function flattenRules(nested) {
+  const rules = [];
+  for (const [userGroup, inner] of Object.entries(nested)) {
+    if (typeof inner !== 'object' || inner === null) continue;
+    for (const [usingGroup, ratio] of Object.entries(inner)) {
+      rules.push({
+        _id: uid(),
+        userGroup,
+        usingGroup,
+        ratio: typeof ratio === 'number' ? ratio : 1,
+      });
+    }
+  }
+  return rules;
+}
+
+function nestRules(rules) {
+  const result = {};
+  rules.forEach(({ userGroup, usingGroup, ratio }) => {
+    if (!userGroup || !usingGroup) return;
+    if (!result[userGroup]) result[userGroup] = {};
+    result[userGroup][usingGroup] = ratio;
+  });
+  return result;
+}
+
+export function serializeGroupGroupRatio(rules) {
+  const nested = nestRules(rules);
+  return Object.keys(nested).length === 0
+    ? ''
+    : JSON.stringify(nested, null, 2);
+}
+
+function GroupSection({ groupName, items, groupOptions, onUpdate, onRemove, onAdd, t }) {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <div
+      style={{
+        border: '1px solid var(--semi-color-border)',
+        borderRadius: 8,
+        overflow: 'hidden',
+      }}
+    >
+      <div
+        className='flex items-center justify-between cursor-pointer'
+        style={{
+          padding: '8px 12px',
+          background: 'var(--semi-color-fill-0)',
+        }}
+        onClick={() => setOpen(!open)}
+      >
+        <div className='flex items-center gap-2'>
+          {open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
+          <Text strong>{groupName}</Text>
+          <Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
+        </div>
+        <div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
+          <Button
+            icon={<IconPlus />}
+            size='small'
+            theme='borderless'
+            onClick={() => onAdd(groupName)}
+          />
+          <Popconfirm
+            title={t('确认删除该分组的所有规则?')}
+            onConfirm={() => items.forEach((item) => onRemove(item._id))}
+            position='left'
+          >
+            <Button
+              icon={<IconDelete />}
+              size='small'
+              type='danger'
+              theme='borderless'
+            />
+          </Popconfirm>
+        </div>
+      </div>
+      <Collapsible isOpen={open} keepDOM>
+        <div style={{ padding: '8px 12px' }}>
+          {items.map((rule) => (
+            <div
+              key={rule._id}
+              className='flex items-center gap-2'
+              style={{ marginBottom: 6 }}
+            >
+              <Select
+                size='small'
+                filter
+                value={rule.usingGroup || undefined}
+                placeholder={t('选择使用分组')}
+                optionList={groupOptions}
+                onChange={(v) => onUpdate(rule._id, 'usingGroup', v)}
+                style={{ flex: 1 }}
+                allowCreate
+                position='bottomLeft'
+              />
+              <InputNumber
+                size='small'
+                min={0}
+                step={0.1}
+                value={rule.ratio}
+                style={{ width: 100 }}
+                onChange={(v) => onUpdate(rule._id, 'ratio', v ?? 0)}
+              />
+              <Popconfirm
+                title={t('确认删除该规则?')}
+                onConfirm={() => onRemove(rule._id)}
+                position='left'
+              >
+                <Button
+                  icon={<IconDelete />}
+                  type='danger'
+                  theme='borderless'
+                  size='small'
+                />
+              </Popconfirm>
+            </div>
+          ))}
+        </div>
+      </Collapsible>
+    </div>
+  );
+}
+
+export default function GroupGroupRatioRules({
+  value,
+  groupNames = [],
+  onChange,
+}) {
+  const { t } = useTranslation();
+  const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
+  const [newGroupName, setNewGroupName] = useState('');
+
+  const emitChange = useCallback(
+    (newRules) => {
+      setRules(newRules);
+      onChange?.(serializeGroupGroupRatio(newRules));
+    },
+    [onChange],
+  );
+
+  const updateRule = useCallback(
+    (id, field, val) => {
+      emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
+    },
+    [rules, emitChange],
+  );
+
+  const removeRule = useCallback(
+    (id) => {
+      emitChange(rules.filter((r) => r._id !== id));
+    },
+    [rules, emitChange],
+  );
+
+  const addRuleToGroup = useCallback(
+    (groupName) => {
+      emitChange([
+        ...rules,
+        { _id: uid(), userGroup: groupName, usingGroup: '', ratio: 1 },
+      ]);
+    },
+    [rules, emitChange],
+  );
+
+  const addNewGroup = useCallback(() => {
+    const name = newGroupName.trim();
+    if (!name) return;
+    emitChange([
+      ...rules,
+      { _id: uid(), userGroup: name, usingGroup: '', ratio: 1 },
+    ]);
+    setNewGroupName('');
+  }, [rules, emitChange, newGroupName]);
+
+  const groupOptions = useMemo(
+    () => groupNames.map((n) => ({ value: n, label: n })),
+    [groupNames],
+  );
+
+  const grouped = useMemo(() => {
+    const map = {};
+    const order = [];
+    rules.forEach((r) => {
+      if (!r.userGroup) return;
+      if (!map[r.userGroup]) {
+        map[r.userGroup] = [];
+        order.push(r.userGroup);
+      }
+      map[r.userGroup].push(r);
+    });
+    return order.map((name) => ({ name, items: map[name] }));
+  }, [rules]);
+
+  if (grouped.length === 0 && rules.length === 0) {
+    return (
+      <div>
+        <Text type='tertiary' className='block text-center py-4'>
+          {t('暂无规则,点击下方按钮添加')}
+        </Text>
+        <div className='mt-2 flex justify-center gap-2'>
+          <Select
+            size='small'
+            filter
+            allowCreate
+            placeholder={t('选择用户分组')}
+            optionList={groupOptions}
+            value={newGroupName || undefined}
+            onChange={setNewGroupName}
+            style={{ width: 200 }}
+            position='bottomLeft'
+          />
+          <Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
+            {t('添加分组规则')}
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className='space-y-2'>
+      {grouped.map((group) => (
+        <GroupSection
+          key={group.name}
+          groupName={group.name}
+          items={group.items}
+          groupOptions={groupOptions}
+          onUpdate={updateRule}
+          onRemove={removeRule}
+          onAdd={addRuleToGroup}
+          t={t}
+        />
+      ))}
+      <div className='mt-3 flex justify-center gap-2'>
+        <Select
+          size='small'
+          filter
+          allowCreate
+          placeholder={t('选择用户分组')}
+          optionList={groupOptions}
+          value={newGroupName || undefined}
+          onChange={setNewGroupName}
+          style={{ width: 200 }}
+          position='bottomLeft'
+        />
+        <Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
+          {t('添加分组规则')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 351 - 0
web/src/pages/Setting/Ratio/components/GroupSpecialUsableRules.jsx

@@ -0,0 +1,351 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import React, { useState, useCallback, useMemo } from 'react';
+import {
+  Button,
+  Collapsible,
+  Input,
+  Select,
+  Tag,
+  Typography,
+  Popconfirm,
+} from '@douyinfe/semi-ui';
+import {
+  IconPlus,
+  IconDelete,
+  IconChevronDown,
+  IconChevronUp,
+} from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+let _idCounter = 0;
+const uid = () => `gsu_${++_idCounter}`;
+
+const OP_ADD = 'add';
+const OP_REMOVE = 'remove';
+const OP_APPEND = 'append';
+
+function parsePrefix(rawKey) {
+  if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) };
+  if (rawKey.startsWith('-:')) return { op: OP_REMOVE, groupName: rawKey.slice(2) };
+  return { op: OP_APPEND, groupName: rawKey };
+}
+
+function toRawKey(op, groupName) {
+  if (op === OP_ADD) return `+:${groupName}`;
+  if (op === OP_REMOVE) return `-:${groupName}`;
+  return groupName;
+}
+
+function parseJSON(str) {
+  if (!str || !str.trim()) return {};
+  try { return JSON.parse(str); } catch { return {}; }
+}
+
+function flattenRules(nested) {
+  const rules = [];
+  for (const [userGroup, inner] of Object.entries(nested)) {
+    if (typeof inner !== 'object' || inner === null) continue;
+    for (const [rawKey, desc] of Object.entries(inner)) {
+      const { op, groupName } = parsePrefix(rawKey);
+      rules.push({
+        _id: uid(),
+        userGroup,
+        op,
+        targetGroup: groupName,
+        description: op === OP_REMOVE ? 'remove' : (typeof desc === 'string' ? desc : ''),
+      });
+    }
+  }
+  return rules;
+}
+
+function nestRules(rules) {
+  const result = {};
+  rules.forEach(({ userGroup, op, targetGroup, description }) => {
+    if (!userGroup || !targetGroup) return;
+    if (!result[userGroup]) result[userGroup] = {};
+    result[userGroup][toRawKey(op, targetGroup)] = description;
+  });
+  return result;
+}
+
+export function serializeGroupSpecialUsable(rules) {
+  const nested = nestRules(rules);
+  return Object.keys(nested).length === 0 ? '' : JSON.stringify(nested, null, 2);
+}
+
+const OP_TAG_MAP = {
+  [OP_ADD]: { color: 'green', label: '添加 (+:)' },
+  [OP_REMOVE]: { color: 'red', label: '移除 (-:)' },
+  [OP_APPEND]: { color: 'blue', label: '追加' },
+};
+
+function UsableGroupSection({ groupName, items, opOptions, onUpdate, onRemove, onAdd, t }) {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <div
+      style={{
+        border: '1px solid var(--semi-color-border)',
+        borderRadius: 8,
+        overflow: 'hidden',
+      }}
+    >
+      <div
+        className='flex items-center justify-between cursor-pointer'
+        style={{
+          padding: '8px 12px',
+          background: 'var(--semi-color-fill-0)',
+        }}
+        onClick={() => setOpen(!open)}
+      >
+        <div className='flex items-center gap-2'>
+          {open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
+          <Text strong>{groupName}</Text>
+          <Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
+        </div>
+        <div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
+          <Button
+            icon={<IconPlus />}
+            size='small'
+            theme='borderless'
+            onClick={() => onAdd(groupName)}
+          />
+          <Popconfirm
+            title={t('确认删除该分组的所有规则?')}
+            onConfirm={() => items.forEach((item) => onRemove(item._id))}
+            position='left'
+          >
+            <Button
+              icon={<IconDelete />}
+              size='small'
+              type='danger'
+              theme='borderless'
+            />
+          </Popconfirm>
+        </div>
+      </div>
+      <Collapsible isOpen={open} keepDOM>
+        <div style={{ padding: '8px 12px' }}>
+          {items.map((rule) => (
+            <div
+              key={rule._id}
+              className='flex items-center gap-2'
+              style={{ marginBottom: 6 }}
+            >
+              <Select
+                size='small'
+                value={rule.op}
+                optionList={opOptions}
+                onChange={(v) => onUpdate(rule._id, 'op', v)}
+                style={{ width: 120 }}
+                renderSelectedItem={(optionNode) => {
+                  const info = OP_TAG_MAP[optionNode.value] || {};
+                  return <Tag size='small' color={info.color}>{optionNode.label}</Tag>;
+                }}
+              />
+              <Input
+                size='small'
+                value={rule.targetGroup}
+                placeholder={t('分组名称')}
+                onChange={(v) => onUpdate(rule._id, 'targetGroup', v)}
+                style={{ flex: 1 }}
+              />
+              {rule.op !== OP_REMOVE ? (
+                <Input
+                  size='small'
+                  value={rule.description}
+                  placeholder={t('分组描述')}
+                  onChange={(v) => onUpdate(rule._id, 'description', v)}
+                  style={{ flex: 1 }}
+                />
+              ) : (
+                <div style={{ flex: 1 }}>
+                  <Text type='tertiary' size='small'>-</Text>
+                </div>
+              )}
+              <Popconfirm
+                title={t('确认删除该规则?')}
+                onConfirm={() => onRemove(rule._id)}
+                position='left'
+              >
+                <Button
+                  icon={<IconDelete />}
+                  type='danger'
+                  theme='borderless'
+                  size='small'
+                />
+              </Popconfirm>
+            </div>
+          ))}
+        </div>
+      </Collapsible>
+    </div>
+  );
+}
+
+export default function GroupSpecialUsableRules({
+  value,
+  groupNames = [],
+  onChange,
+}) {
+  const { t } = useTranslation();
+  const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
+  const [newGroupName, setNewGroupName] = useState('');
+
+  const emitChange = useCallback(
+    (newRules) => {
+      setRules(newRules);
+      onChange?.(serializeGroupSpecialUsable(newRules));
+    },
+    [onChange],
+  );
+
+  const updateRule = useCallback(
+    (id, field, val) => {
+      emitChange(
+        rules.map((r) => {
+          if (r._id !== id) return r;
+          const updated = { ...r, [field]: val };
+          if (field === 'op' && val === OP_REMOVE) updated.description = 'remove';
+          else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
+            if (updated.description === 'remove') updated.description = '';
+          }
+          return updated;
+        }),
+      );
+    },
+    [rules, emitChange],
+  );
+
+  const removeRule = useCallback(
+    (id) => emitChange(rules.filter((r) => r._id !== id)),
+    [rules, emitChange],
+  );
+
+  const addRuleToGroup = useCallback(
+    (groupName) => {
+      emitChange([
+        ...rules,
+        { _id: uid(), userGroup: groupName, op: OP_APPEND, targetGroup: '', description: '' },
+      ]);
+    },
+    [rules, emitChange],
+  );
+
+  const addNewGroup = useCallback(() => {
+    const name = newGroupName.trim();
+    if (!name) return;
+    emitChange([
+      ...rules,
+      { _id: uid(), userGroup: name, op: OP_APPEND, targetGroup: '', description: '' },
+    ]);
+    setNewGroupName('');
+  }, [rules, emitChange, newGroupName]);
+
+  const groupOptions = useMemo(
+    () => groupNames.map((n) => ({ value: n, label: n })),
+    [groupNames],
+  );
+
+  const opOptions = useMemo(
+    () => [
+      { value: OP_ADD, label: t('添加 (+:)') },
+      { value: OP_REMOVE, label: t('移除 (-:)') },
+      { value: OP_APPEND, label: t('追加') },
+    ],
+    [t],
+  );
+
+  const grouped = useMemo(() => {
+    const map = {};
+    const order = [];
+    rules.forEach((r) => {
+      if (!r.userGroup) return;
+      if (!map[r.userGroup]) {
+        map[r.userGroup] = [];
+        order.push(r.userGroup);
+      }
+      map[r.userGroup].push(r);
+    });
+    return order.map((name) => ({ name, items: map[name] }));
+  }, [rules]);
+
+  if (grouped.length === 0 && rules.length === 0) {
+    return (
+      <div>
+        <Text type='tertiary' className='block text-center py-4'>
+          {t('暂无规则,点击下方按钮添加')}
+        </Text>
+        <div className='mt-2 flex justify-center gap-2'>
+          <Select
+            size='small'
+            filter
+            allowCreate
+            placeholder={t('选择用户分组')}
+            optionList={groupOptions}
+            value={newGroupName || undefined}
+            onChange={setNewGroupName}
+            style={{ width: 200 }}
+            position='bottomLeft'
+          />
+          <Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
+            {t('添加分组规则')}
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className='space-y-2'>
+      {grouped.map((group) => (
+        <UsableGroupSection
+          key={group.name}
+          groupName={group.name}
+          items={group.items}
+          opOptions={opOptions}
+          onUpdate={updateRule}
+          onRemove={removeRule}
+          onAdd={addRuleToGroup}
+          t={t}
+        />
+      ))}
+      <div className='mt-3 flex justify-center gap-2'>
+        <Select
+          size='small'
+          filter
+          allowCreate
+          placeholder={t('选择用户分组')}
+          optionList={groupOptions}
+          value={newGroupName || undefined}
+          onChange={setNewGroupName}
+          style={{ width: 200 }}
+          position='bottomLeft'
+        />
+        <Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
+          {t('添加分组规则')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 242 - 0
web/src/pages/Setting/Ratio/components/GroupTable.jsx

@@ -0,0 +1,242 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import {
+  Button,
+  Input,
+  InputNumber,
+  Checkbox,
+  Typography,
+  Popconfirm,
+} from '@douyinfe/semi-ui';
+import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import CardTable from '../../../../components/common/ui/CardTable';
+
+const { Text } = Typography;
+
+let _idCounter = 0;
+const uid = () => `gr_${++_idCounter}`;
+
+function parseJSON(str, fallback) {
+  if (!str || !str.trim()) return fallback;
+  try {
+    return JSON.parse(str);
+  } catch {
+    return fallback;
+  }
+}
+
+function buildRows(groupRatioStr, userUsableGroupsStr) {
+  const ratioMap = parseJSON(groupRatioStr, {});
+  const usableMap = parseJSON(userUsableGroupsStr, {});
+
+  const allNames = new Set([
+    ...Object.keys(ratioMap),
+    ...Object.keys(usableMap),
+  ]);
+
+  return Array.from(allNames).map((name) => ({
+    _id: uid(),
+    name,
+    ratio: ratioMap[name] ?? 1,
+    selectable: name in usableMap,
+    description: usableMap[name] ?? '',
+  }));
+}
+
+export function serializeGroupTable(rows) {
+  const groupRatio = {};
+  const userUsableGroups = {};
+
+  rows.forEach((row) => {
+    if (!row.name) return;
+    groupRatio[row.name] = row.ratio;
+    if (row.selectable) {
+      userUsableGroups[row.name] = row.description;
+    }
+  });
+
+  return {
+    GroupRatio: JSON.stringify(groupRatio, null, 2),
+    UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
+  };
+}
+
+export default function GroupTable({
+  groupRatio,
+  userUsableGroups,
+  onChange,
+}) {
+  const { t } = useTranslation();
+
+  const [rows, setRows] = useState(() =>
+    buildRows(groupRatio, userUsableGroups),
+  );
+
+  const emitChange = useCallback(
+    (newRows) => {
+      setRows(newRows);
+      onChange?.(serializeGroupTable(newRows));
+    },
+    [onChange],
+  );
+
+  const updateRow = useCallback(
+    (id, field, value) => {
+      const next = rows.map((r) =>
+        r._id === id ? { ...r, [field]: value } : r,
+      );
+      emitChange(next);
+    },
+    [rows, emitChange],
+  );
+
+  const addRow = useCallback(() => {
+    const existingNames = new Set(rows.map((r) => r.name));
+    let counter = 1;
+    let newName = `group_${counter}`;
+    while (existingNames.has(newName)) {
+      counter++;
+      newName = `group_${counter}`;
+    }
+    emitChange([
+      ...rows,
+      {
+        _id: uid(),
+        name: newName,
+        ratio: 1,
+        selectable: true,
+        description: '',
+      },
+    ]);
+  }, [rows, emitChange]);
+
+  const removeRow = useCallback(
+    (id) => {
+      emitChange(rows.filter((r) => r._id !== id));
+    },
+    [rows, emitChange],
+  );
+
+  const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
+
+  const duplicateNames = useMemo(() => {
+    const counts = {};
+    groupNames.forEach((n) => {
+      counts[n] = (counts[n] || 0) + 1;
+    });
+    return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
+  }, [groupNames]);
+
+  const columns = useMemo(
+    () => [
+      {
+        title: t('分组名称'),
+        dataIndex: 'name',
+        key: 'name',
+        width: 180,
+        render: (_, record) => (
+          <Input
+            size='small'
+            value={record.name}
+            status={duplicateNames.has(record.name) ? 'warning' : undefined}
+            onChange={(v) => updateRow(record._id, 'name', v)}
+          />
+        ),
+      },
+      {
+        title: t('倍率'),
+        dataIndex: 'ratio',
+        key: 'ratio',
+        width: 120,
+        render: (_, record) => (
+          <InputNumber
+            size='small'
+            min={0}
+            step={0.1}
+            value={record.ratio}
+            style={{ width: '100%' }}
+            onChange={(v) => updateRow(record._id, 'ratio', v ?? 0)}
+          />
+        ),
+      },
+      {
+        title: t('用户可选'),
+        dataIndex: 'selectable',
+        key: 'selectable',
+        width: 90,
+        align: 'center',
+        render: (_, record) => (
+          <Checkbox
+            checked={record.selectable}
+            onChange={(e) =>
+              updateRow(record._id, 'selectable', e.target.checked)
+            }
+          />
+        ),
+      },
+      {
+        title: t('描述'),
+        dataIndex: 'description',
+        key: 'description',
+        render: (_, record) =>
+          record.selectable ? (
+            <Input
+              size='small'
+              value={record.description}
+              placeholder={t('分组描述')}
+              onChange={(v) => updateRow(record._id, 'description', v)}
+            />
+          ) : (
+            <Text type='tertiary' size='small'>
+              -
+            </Text>
+          ),
+      },
+      {
+        title: '',
+        key: 'actions',
+        width: 50,
+        render: (_, record) => (
+          <Popconfirm
+            title={t('确认删除该分组?')}
+            onConfirm={() => removeRow(record._id)}
+            position='left'
+          >
+            <Button
+              icon={<IconDelete />}
+              type='danger'
+              theme='borderless'
+              size='small'
+            />
+          </Popconfirm>
+        ),
+      },
+    ],
+    [t, duplicateNames, updateRow, removeRow],
+  );
+
+  return (
+    <div>
+      <CardTable
+        columns={columns}
+        dataSource={rows}
+        rowKey='_id'
+        hidePagination
+        size='small'
+        empty={
+          <Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>
+        }
+      />
+      <div className='mt-3 flex justify-center'>
+        <Button icon={<IconPlus />} theme='outline' onClick={addRow}>
+          {t('添加分组')}
+        </Button>
+      </div>
+      {duplicateNames.size > 0 && (
+        <Text type='warning' size='small' className='mt-2 block'>
+          {t('存在重复的分组名称:')}{Array.from(duplicateNames).join(', ')}
+        </Text>
+      )}
+    </div>
+  );
+}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä