Parcourir la source

Merge pull request #1207 from QuantumNous/user_group_ratio

feat: 分组特殊倍率
Calcium-Ion il y a 6 mois
Parent
commit
358af20ad1

+ 1 - 1
controller/channel-test.go

@@ -166,7 +166,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
 	milliseconds := tok.Sub(tik).Milliseconds()
 	consumedTime := float64(milliseconds) / 1000.0
 	other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatio, priceData.CompletionRatio,
-		usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice)
+		usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.UserGroupRatio)
 	model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试",
 		quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other)
 	common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))

+ 8 - 1
controller/pricing.go

@@ -1,10 +1,11 @@
 package controller
 
 import (
-	"github.com/gin-gonic/gin"
 	"one-api/model"
 	"one-api/setting"
 	"one-api/setting/operation_setting"
+
+	"github.com/gin-gonic/gin"
 )
 
 func GetPricing(c *gin.Context) {
@@ -20,6 +21,12 @@ func GetPricing(c *gin.Context) {
 		user, err := model.GetUserCache(userId.(int))
 		if err == nil {
 			group = user.Group
+			for g := range groupRatio {
+				ratio, ok := setting.GetGroupGroupRatio(group, g)
+				if ok {
+					groupRatio[g] = ratio
+				}
+			}
 		}
 	}
 

+ 3 - 0
model/option.go

@@ -98,6 +98,7 @@ func InitOptionMap() {
 	common.OptionMap["ModelPrice"] = operation_setting.ModelPrice2JSONString()
 	common.OptionMap["CacheRatio"] = operation_setting.CacheRatio2JSONString()
 	common.OptionMap["GroupRatio"] = setting.GroupRatio2JSONString()
+	common.OptionMap["GroupGroupRatio"] = setting.GroupGroupRatio2JSONString()
 	common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
 	common.OptionMap["CompletionRatio"] = operation_setting.CompletionRatio2JSONString()
 	common.OptionMap["TopUpLink"] = common.TopUpLink
@@ -357,6 +358,8 @@ func updateOptionMap(key string, value string) (err error) {
 		err = operation_setting.UpdateModelRatioByJSONString(value)
 	case "GroupRatio":
 		err = setting.UpdateGroupRatioByJSONString(value)
+	case "GroupGroupRatio":
+		err = setting.UpdateGroupGroupRatioByJSONString(value)
 	case "UserUsableGroups":
 		err = setting.UpdateUserUsableGroupsByJSONString(value)
 	case "CompletionRatio":

+ 2 - 0
relay/common/relay_info.go

@@ -61,6 +61,7 @@ type RelayInfo struct {
 	TokenKey          string
 	UserId            int
 	Group             string
+	UserGroup         string
 	TokenUnlimited    bool
 	StartTime         time.Time
 	FirstResponseTime time.Time
@@ -204,6 +205,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
 		TokenKey:          tokenKey,
 		UserId:            userId,
 		Group:             group,
+		UserGroup:         c.GetString(constant.ContextKeyUserGroup),
 		TokenUnlimited:    tokenUnlimited,
 		StartTime:         startTime,
 		FirstResponseTime: startTime.Add(-time.Second),

+ 8 - 1
relay/helper/price.go

@@ -2,12 +2,13 @@ package helper
 
 import (
 	"fmt"
-	"github.com/gin-gonic/gin"
 	"one-api/common"
 	constant2 "one-api/constant"
 	relaycommon "one-api/relay/common"
 	"one-api/setting"
 	"one-api/setting/operation_setting"
+
+	"github.com/gin-gonic/gin"
 )
 
 type PriceData struct {
@@ -18,6 +19,7 @@ type PriceData struct {
 	CacheCreationRatio     float64
 	ImageRatio             float64
 	GroupRatio             float64
+	UserGroupRatio         float64
 	UsePrice               bool
 	ShouldPreConsumedQuota int
 }
@@ -29,6 +31,10 @@ func (p PriceData) ToSetting() string {
 func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) {
 	modelPrice, usePrice := operation_setting.GetModelPrice(info.OriginModelName, false)
 	groupRatio := setting.GetGroupRatio(info.Group)
+	userGroupRatio, ok := setting.GetGroupGroupRatio(info.UserGroup, info.Group)
+	if ok {
+		groupRatio = userGroupRatio
+	}
 	var preConsumedQuota int
 	var modelRatio float64
 	var completionRatio float64
@@ -69,6 +75,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
 		ModelRatio:             modelRatio,
 		CompletionRatio:        completionRatio,
 		GroupRatio:             groupRatio,
+		UserGroupRatio:         userGroupRatio,
 		UsePrice:               usePrice,
 		CacheRatio:             cacheRatio,
 		ImageRatio:             imageRatio,

+ 2 - 1
relay/relay-text.go

@@ -363,6 +363,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	modelRatio := priceData.ModelRatio
 	groupRatio := priceData.GroupRatio
 	modelPrice := priceData.ModelPrice
+	userGroupRatio := priceData.UserGroupRatio
 
 	// Convert values to decimal for precise calculation
 	dPromptTokens := decimal.NewFromInt(int64(promptTokens))
@@ -510,7 +511,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	if extraContent != "" {
 		logContent += ", " + extraContent
 	}
-	other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
+	other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
 	if imageTokens != 0 {
 		other["image"] = true
 		other["image_ratio"] = imageRatio

+ 8 - 7
service/log_info_generate.go

@@ -8,7 +8,7 @@ import (
 )
 
 func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
-	cacheTokens int, cacheRatio float64, modelPrice float64) map[string]interface{} {
+	cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
 	other := make(map[string]interface{})
 	other["model_ratio"] = modelRatio
 	other["group_ratio"] = groupRatio
@@ -16,6 +16,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
 	other["cache_tokens"] = cacheTokens
 	other["cache_ratio"] = cacheRatio
 	other["model_price"] = modelPrice
+	other["user_group_ratio"] = userGroupRatio
 	other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
 	if relayInfo.ReasoningEffort != "" {
 		other["reasoning_effort"] = relayInfo.ReasoningEffort
@@ -30,8 +31,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
 	return other
 }
 
-func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
-	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
+func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
+	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
 	info["ws"] = true
 	info["audio_input"] = usage.InputTokenDetails.AudioTokens
 	info["audio_output"] = usage.OutputTokenDetails.AudioTokens
@@ -42,8 +43,8 @@ func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
 	return info
 }
 
-func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
-	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
+func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
+	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
 	info["audio"] = true
 	info["audio_input"] = usage.PromptTokensDetails.AudioTokens
 	info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
@@ -55,8 +56,8 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 }
 
 func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
-	cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64) map[string]interface{} {
-	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
+	cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
+	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
 	info["claude"] = true
 	info["cache_creation_tokens"] = cacheCreationTokens
 	info["cache_creation_ratio"] = cacheCreationRatio

+ 21 - 6
service/quota.go

@@ -94,6 +94,10 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
 	audioInputTokens := usage.InputTokenDetails.AudioTokens
 	audioOutTokens := usage.OutputTokenDetails.AudioTokens
 	groupRatio := setting.GetGroupRatio(relayInfo.Group)
+	userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
+	if ok {
+		groupRatio = userGroupRatio
+	}
 	modelRatio, _ := operation_setting.GetModelRatio(modelName)
 
 	quotaInfo := QuotaInfo{
@@ -145,6 +149,11 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
 	audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName))
 	audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName))
 
+	actualGroupRatio := groupRatio
+	userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
+	if ok {
+		actualGroupRatio = userGroupRatio
+	}
 	quotaInfo := QuotaInfo{
 		InputDetails: TokenDetails{
 			TextTokens:  textInputTokens,
@@ -157,7 +166,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
 		ModelName:  modelName,
 		UsePrice:   usePrice,
 		ModelRatio: modelRatio,
-		GroupRatio: groupRatio,
+		GroupRatio: actualGroupRatio,
 	}
 
 	quota := calculateAudioQuota(quotaInfo)
@@ -189,7 +198,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
 		logContent += ", " + extraContent
 	}
 	other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
-		completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
+		completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, userGroupRatio)
 	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel,
 		tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
 }
@@ -207,7 +216,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	modelRatio := priceData.ModelRatio
 	groupRatio := priceData.GroupRatio
 	modelPrice := priceData.ModelPrice
-
+	userGroupRatio := priceData.UserGroupRatio
 	cacheRatio := priceData.CacheRatio
 	cacheTokens := usage.PromptTokensDetails.CachedTokens
 
@@ -256,7 +265,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	}
 
 	other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
-		cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice)
+		cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, userGroupRatio)
 	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName,
 		tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
 }
@@ -281,6 +290,12 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	modelPrice := priceData.ModelPrice
 	usePrice := priceData.UsePrice
 
+	actualGroupRatio := groupRatio
+	userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
+	if ok {
+		actualGroupRatio = userGroupRatio
+	}
+
 	quotaInfo := QuotaInfo{
 		InputDetails: TokenDetails{
 			TextTokens:  textInputTokens,
@@ -293,7 +308,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 		ModelName:  relayInfo.OriginModelName,
 		UsePrice:   usePrice,
 		ModelRatio: modelRatio,
-		GroupRatio: groupRatio,
+		GroupRatio: actualGroupRatio,
 	}
 
 	quota := calculateAudioQuota(quotaInfo)
@@ -333,7 +348,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 		logContent += ", " + extraContent
 	}
 	other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
-		completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
+		completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, userGroupRatio)
 	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel,
 		tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
 }

+ 48 - 5
setting/group_ratio.go

@@ -14,10 +14,19 @@ var groupRatio = map[string]float64{
 }
 var groupRatioMutex sync.RWMutex
 
+var (
+	GroupGroupRatio = map[string]map[string]float64{
+		"vip": {
+			"edit_this": 0.9,
+		},
+	}
+	groupGroupRatioMutex sync.RWMutex
+)
+
 func GetGroupRatioCopy() map[string]float64 {
 	groupRatioMutex.RLock()
 	defer groupRatioMutex.RUnlock()
-	
+
 	groupRatioCopy := make(map[string]float64)
 	for k, v := range groupRatio {
 		groupRatioCopy[k] = v
@@ -28,7 +37,7 @@ func GetGroupRatioCopy() map[string]float64 {
 func ContainsGroupRatio(name string) bool {
 	groupRatioMutex.RLock()
 	defer groupRatioMutex.RUnlock()
-	
+
 	_, ok := groupRatio[name]
 	return ok
 }
@@ -36,7 +45,7 @@ func ContainsGroupRatio(name string) bool {
 func GroupRatio2JSONString() string {
 	groupRatioMutex.RLock()
 	defer groupRatioMutex.RUnlock()
-	
+
 	jsonBytes, err := json.Marshal(groupRatio)
 	if err != nil {
 		common.SysError("error marshalling model ratio: " + err.Error())
@@ -47,7 +56,7 @@ func GroupRatio2JSONString() string {
 func UpdateGroupRatioByJSONString(jsonStr string) error {
 	groupRatioMutex.Lock()
 	defer groupRatioMutex.Unlock()
-	
+
 	groupRatio = make(map[string]float64)
 	return json.Unmarshal([]byte(jsonStr), &groupRatio)
 }
@@ -55,7 +64,7 @@ func UpdateGroupRatioByJSONString(jsonStr string) error {
 func GetGroupRatio(name string) float64 {
 	groupRatioMutex.RLock()
 	defer groupRatioMutex.RUnlock()
-	
+
 	ratio, ok := groupRatio[name]
 	if !ok {
 		common.SysError("group ratio not found: " + name)
@@ -64,6 +73,40 @@ func GetGroupRatio(name string) float64 {
 	return ratio
 }
 
+func GetGroupGroupRatio(group, name string) (float64, bool) {
+	groupGroupRatioMutex.RLock()
+	defer groupGroupRatioMutex.RUnlock()
+
+	gp, ok := GroupGroupRatio[group]
+	if !ok {
+		return -1, false
+	}
+	ratio, ok := gp[name]
+	if !ok {
+		return -1, false
+	}
+	return ratio, true
+}
+
+func GroupGroupRatio2JSONString() string {
+	groupGroupRatioMutex.RLock()
+	defer groupGroupRatioMutex.RUnlock()
+
+	jsonBytes, err := json.Marshal(GroupGroupRatio)
+	if err != nil {
+		common.SysError("error marshalling group-group ratio: " + err.Error())
+	}
+	return string(jsonBytes)
+}
+
+func UpdateGroupGroupRatioByJSONString(jsonStr string) error {
+	groupGroupRatioMutex.Lock()
+	defer groupGroupRatioMutex.Unlock()
+
+	GroupGroupRatio = make(map[string]map[string]float64)
+	return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio)
+}
+
 func CheckGroupRatio(jsonStr string) error {
 	checkGroupRatio := make(map[string]float64)
 	err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio)

+ 2 - 0
web/src/components/settings/OperationSetting.js

@@ -30,6 +30,7 @@ const OperationSetting = () => {
     CompletionRatio: '',
     ModelPrice: '',
     GroupRatio: '',
+    GroupGroupRatio: '',
     UserUsableGroups: '',
     TopUpLink: '',
     'general_setting.docs_link': '',
@@ -74,6 +75,7 @@ const OperationSetting = () => {
         if (
           item.key === 'ModelRatio' ||
           item.key === 'GroupRatio' ||
+          item.key === 'GroupGroupRatio' ||
           item.key === 'UserUsableGroups' ||
           item.key === 'CompletionRatio' ||
           item.key === 'ModelPrice' ||

+ 88 - 57
web/src/components/table/LogsTable.js

@@ -20,7 +20,7 @@ import {
   renderQuota,
   stringToColor,
   getLogOther,
-  renderModelTag
+  renderModelTag,
 } from '../../helpers';
 
 import {
@@ -39,11 +39,11 @@ import {
   Card,
   Typography,
   Divider,
-  Form
+  Form,
 } from '@douyinfe/semi-ui';
 import {
   IllustrationNoResult,
-  IllustrationNoResultDark
+  IllustrationNoResultDark,
 } from '@douyinfe/semi-illustrations';
 import { ITEMS_PER_PAGE } from '../../constants';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
@@ -192,7 +192,7 @@ const LogsTable = () => {
     if (!modelMapped) {
       return renderModelTag(record.model_name, {
         onClick: (event) => {
-          copyText(event, record.model_name).then((r) => { });
+          copyText(event, record.model_name).then((r) => {});
         },
       });
     } else {
@@ -209,7 +209,7 @@ const LogsTable = () => {
                       </Text>
                       {renderModelTag(record.model_name, {
                         onClick: (event) => {
-                          copyText(event, record.model_name).then((r) => { });
+                          copyText(event, record.model_name).then((r) => {});
                         },
                       })}
                     </div>
@@ -220,7 +220,7 @@ const LogsTable = () => {
                       {renderModelTag(other.upstream_model_name, {
                         onClick: (event) => {
                           copyText(event, other.upstream_model_name).then(
-                            (r) => { },
+                            (r) => {},
                           );
                         },
                       })}
@@ -231,7 +231,7 @@ const LogsTable = () => {
             >
               {renderModelTag(record.model_name, {
                 onClick: (event) => {
-                  copyText(event, record.model_name).then((r) => { });
+                  copyText(event, record.model_name).then((r) => {});
                 },
                 suffixIcon: (
                   <Route
@@ -598,21 +598,23 @@ const LogsTable = () => {
         }
         let content = other?.claude
           ? renderClaudeModelPriceSimple(
-            other.model_ratio,
-            other.model_price,
-            other.group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-            other.cache_creation_tokens || 0,
-            other.cache_creation_ratio || 1.0,
-          )
+              other.model_ratio,
+              other.model_price,
+              other.group_ratio,
+              other?.user_group_ratio,
+              other.cache_tokens || 0,
+              other.cache_ratio || 1.0,
+              other.cache_creation_tokens || 0,
+              other.cache_creation_ratio || 1.0,
+            )
           : renderModelPriceSimple(
-            other.model_ratio,
-            other.model_price,
-            other.group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-          );
+              other.model_ratio,
+              other.model_price,
+              other.group_ratio,
+              other?.user_group_ratio,
+              other.cache_tokens || 0,
+              other.cache_ratio || 1.0,
+            );
         return (
           <Paragraph
             ellipsis={{
@@ -742,7 +744,7 @@ const LogsTable = () => {
     group: '',
     dateRange: [
       timestamp2string(getTodayStartTimestamp()),
-      timestamp2string(now.getTime() / 1000 + 3600)
+      timestamp2string(now.getTime() / 1000 + 3600),
     ],
     logType: '0',
   };
@@ -763,7 +765,11 @@ const LogsTable = () => {
     let start_timestamp = timestamp2string(getTodayStartTimestamp());
     let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
 
-    if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
+    if (
+      formValues.dateRange &&
+      Array.isArray(formValues.dateRange) &&
+      formValues.dateRange.length === 2
+    ) {
       start_timestamp = formValues.dateRange[0];
       end_timestamp = formValues.dateRange[1];
     }
@@ -941,27 +947,27 @@ const LogsTable = () => {
           key: t('日志详情'),
           value: other?.claude
             ? renderClaudeLogContent(
-              other?.model_ratio,
-              other.completion_ratio,
-              other.model_price,
-              other.group_ratio,
-              other.cache_ratio || 1.0,
-              other.cache_creation_ratio || 1.0,
-            )
+                other?.model_ratio,
+                other.completion_ratio,
+                other.model_price,
+                other.group_ratio,
+                other?.user_group_ratio,
+                other.cache_ratio || 1.0,
+                other.cache_creation_ratio || 1.0,
+              )
             : renderLogContent(
-              other?.model_ratio,
-              other.completion_ratio,
-              other.model_price,
-              other.group_ratio,
-              other?.user_group_ratio,
-              false,
-              1.0,
-              undefined,
-              other.web_search || false,
-              other.web_search_call_count || 0,
-              other.file_search || false,
-              other.file_search_call_count || 0,
-            ),
+                other?.model_ratio,
+                other.completion_ratio,
+                other.model_price,
+                other.group_ratio,
+                other?.user_group_ratio,
+                false,
+                1.0,
+                other.web_search || false,
+                other.web_search_call_count || 0,
+                other.file_search || false,
+                other.file_search_call_count || 0,
+              ),
         });
       }
       if (logs[i].type === 2) {
@@ -992,6 +998,7 @@ const LogsTable = () => {
             other?.audio_ratio,
             other?.audio_completion_ratio,
             other?.group_ratio,
+            other?.user_group_ratio,
             other?.cache_tokens || 0,
             other?.cache_ratio || 1.0,
           );
@@ -1003,6 +1010,7 @@ const LogsTable = () => {
             other.model_price,
             other.completion_ratio,
             other.group_ratio,
+            other?.user_group_ratio,
             other.cache_tokens || 0,
             other.cache_ratio || 1.0,
             other.cache_creation_tokens || 0,
@@ -1016,6 +1024,7 @@ const LogsTable = () => {
             other?.model_price,
             other?.completion_ratio,
             other?.group_ratio,
+            other?.user_group_ratio,
             other?.cache_tokens || 0,
             other?.cache_ratio || 1.0,
             other?.image || false,
@@ -1066,7 +1075,12 @@ const LogsTable = () => {
     } = getFormValues();
 
     // 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
-    const currentLogType = customLogType !== null ? customLogType : formLogType !== undefined ? formLogType : logType;
+    const currentLogType =
+      customLogType !== null
+        ? customLogType
+        : formLogType !== undefined
+          ? formLogType
+          : logType;
 
     let localStartTimestamp = Date.parse(start_timestamp) / 1000;
     let localEndTimestamp = Date.parse(end_timestamp) / 1000;
@@ -1093,7 +1107,7 @@ const LogsTable = () => {
 
   const handlePageChange = (page) => {
     setActivePage(page);
-    loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
+    loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
   };
 
   const handlePageSizeChange = async (size) => {
@@ -1208,9 +1222,9 @@ const LogsTable = () => {
               getFormApi={(api) => setFormApi(api)}
               onSubmit={refresh}
               allowEmpty={true}
-              autoComplete="off"
-              layout="vertical"
-              trigger="change"
+              autoComplete='off'
+              layout='vertical'
+              trigger='change'
               stopValidateWithError={false}
             >
               <div className='flex flex-col gap-4'>
@@ -1294,12 +1308,24 @@ const LogsTable = () => {
                         }, 0);
                       }}
                     >
-                      <Form.Select.Option value='0'>{t('全部')}</Form.Select.Option>
-                      <Form.Select.Option value='1'>{t('充值')}</Form.Select.Option>
-                      <Form.Select.Option value='2'>{t('消费')}</Form.Select.Option>
-                      <Form.Select.Option value='3'>{t('管理')}</Form.Select.Option>
-                      <Form.Select.Option value='4'>{t('系统')}</Form.Select.Option>
-                      <Form.Select.Option value='5'>{t('错误')}</Form.Select.Option>
+                      <Form.Select.Option value='0'>
+                        {t('全部')}
+                      </Form.Select.Option>
+                      <Form.Select.Option value='1'>
+                        {t('充值')}
+                      </Form.Select.Option>
+                      <Form.Select.Option value='2'>
+                        {t('消费')}
+                      </Form.Select.Option>
+                      <Form.Select.Option value='3'>
+                        {t('管理')}
+                      </Form.Select.Option>
+                      <Form.Select.Option value='4'>
+                        {t('系统')}
+                      </Form.Select.Option>
+                      <Form.Select.Option value='5'>
+                        {t('错误')}
+                      </Form.Select.Option>
                     </Form.Select>
                   </div>
 
@@ -1351,7 +1377,8 @@ const LogsTable = () => {
           {...(hasExpandableRows() && {
             expandedRowRender: expandRowRender,
             expandRowByClick: true,
-            rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
+            rowExpandable: (record) =>
+              expandData[record.key] && expandData[record.key].length > 0,
           })}
           dataSource={logs}
           rowKey='key'
@@ -1361,8 +1388,12 @@ const LogsTable = () => {
           size='middle'
           empty={
             <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+              image={
+                <IllustrationNoResult style={{ width: 150, height: 150 }} />
+              }
+              darkModeImage={
+                <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
+              }
               description={t('搜索无结果')}
               style={{ padding: 30 }}
             />

+ 124 - 80
web/src/helpers/render.js

@@ -30,7 +30,7 @@ import {
   Dify,
   Coze,
   SiliconCloud,
-  FastGPT
+  FastGPT,
 } from '@lobehub/icons';
 
 import {
@@ -46,7 +46,7 @@ import {
   Gift,
   User,
   Settings,
-  CircleUser
+  CircleUser,
 } from 'lucide-react';
 
 // 侧边栏图标颜色映射
@@ -315,7 +315,6 @@ export const getModelCategories = (() => {
   };
 })();
 
-
 /**
  * 根据渠道类型返回对应的厂商图标
  * @param {number} channelType - 渠道类型值
@@ -868,6 +867,30 @@ export function renderQuota(quota, digits = 2) {
   return renderNumber(quota);
 }
 
+function isValidGroupRatio(ratio) {
+  return Number.isFinite(ratio) && ratio !== -1;
+}
+
+/**
+ * Helper function to get effective ratio and label
+ * @param {number} groupRatio - The default group ratio
+ * @param {number} user_group_ratio - The user-specific group ratio  
+ * @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
+ */
+function getEffectiveRatio(groupRatio, user_group_ratio) {
+  const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
+  const ratioLabel = useUserGroupRatio
+    ? i18next.t('专属倍率')
+    : i18next.t('分组倍率');
+  const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
+  
+  return {
+    ratio: effectiveRatio,
+    label: ratioLabel,
+    useUserGroupRatio: useUserGroupRatio
+  };
+}
+
 export function renderModelPrice(
   inputTokens,
   completionTokens,
@@ -875,6 +898,7 @@ export function renderModelPrice(
   modelPrice = -1,
   completionRatio,
   groupRatio,
+  user_group_ratio,
   cacheTokens = 0,
   cacheRatio = 1.0,
   image = false,
@@ -890,13 +914,17 @@ export function renderModelPrice(
   audioInputTokens = 0,
   audioInputPrice = 0,
 ) {
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
+  groupRatio = effectiveGroupRatio;
+
   if (modelPrice !== -1) {
     return i18next.t(
-      '模型价格:${{price}} * 分组倍率:{{ratio}} = ${{total}}',
+      '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
       {
         price: modelPrice,
         ratio: groupRatio,
         total: modelPrice * groupRatio,
+        ratioType: ratioLabel,
       },
     );
   } else {
@@ -1033,11 +1061,12 @@ export function renderModelPrice(
 
               // 构建输出部分描述
               const outputDesc = i18next.t(
-                '输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
+                '输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
                 {
                   completion: completionTokens,
                   compPrice: completionRatioPrice,
                   ratio: groupRatio,
+                  ratioType: ratioLabel,
                 },
               );
 
@@ -1045,23 +1074,25 @@ export function renderModelPrice(
               const extraServices = [
                 webSearch && webSearchCallCount > 0
                   ? i18next.t(
-                    ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
-                    {
-                      count: webSearchCallCount,
-                      price: webSearchPrice,
-                      ratio: groupRatio,
-                    },
-                  )
+                      ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
+                      {
+                        count: webSearchCallCount,
+                        price: webSearchPrice,
+                        ratio: groupRatio,
+                        ratioType: ratioLabel,
+                      },
+                    )
                   : '',
                 fileSearch && fileSearchCallCount > 0
                   ? i18next.t(
-                    ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
-                    {
-                      count: fileSearchCallCount,
-                      price: fileSearchPrice,
-                      ratio: groupRatio,
-                    },
-                  )
+                      ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
+                      {
+                        count: fileSearchCallCount,
+                        price: fileSearchPrice,
+                        ratio: groupRatio,
+                        ratioType: ratioLabel,
+                      },
+                    )
                   : '',
               ].join('');
 
@@ -1091,16 +1122,12 @@ export function renderLogContent(
   user_group_ratio,
   image = false,
   imageRatio = 1.0,
-  useUserGroupRatio = undefined,
   webSearch = false,
   webSearchCallCount = 0,
   fileSearch = false,
   fileSearchCallCount = 0,
 ) {
-  const ratioLabel = useUserGroupRatio
-    ? i18next.t('专属倍率')
-    : i18next.t('分组倍率');
-  const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
+  const { ratio, label: ratioLabel, useUserGroupRatio: useUserGroupRatio } = getEffectiveRatio(groupRatio, user_group_ratio);
 
   if (modelPrice !== -1) {
     return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
@@ -1149,14 +1176,18 @@ export function renderModelPriceSimple(
   modelRatio,
   modelPrice = -1,
   groupRatio,
+  user_group_ratio,
   cacheTokens = 0,
   cacheRatio = 1.0,
   image = false,
   imageRatio = 1.0,
 ) {
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
+  groupRatio = effectiveGroupRatio;
   if (modelPrice !== -1) {
-    return i18next.t('价格:${{price}} * 分组:{{ratio}}', {
+    return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
       price: modelPrice,
+      ratioType: ratioLabel,
       ratio: groupRatio,
     });
   } else {
@@ -1191,8 +1222,9 @@ export function renderModelPriceSimple(
         },
       );
     } else {
-      return i18next.t('模型: {{ratio}} * 分组: {{groupRatio}}', {
+      return i18next.t('模型: {{ratio}} * {{ratioType}}:{{groupRatio}}', {
         ratio: modelRatio,
+        ratioType: ratioLabel,
         groupRatio: groupRatio,
       });
     }
@@ -1210,17 +1242,21 @@ export function renderAudioModelPrice(
   audioRatio,
   audioCompletionRatio,
   groupRatio,
+  user_group_ratio,
   cacheTokens = 0,
   cacheRatio = 1.0,
 ) {
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
+  groupRatio = effectiveGroupRatio;
   // 1 ratio = $0.002 / 1K tokens
   if (modelPrice !== -1) {
     return i18next.t(
-      '模型价格:${{price}} * 分组倍率:{{ratio}} = ${{total}}',
+      '模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
       {
         price: modelPrice,
         ratio: groupRatio,
         total: modelPrice * groupRatio,
+        ratioType: ratioLabel,
       },
     );
   } else {
@@ -1245,10 +1281,10 @@ export function renderAudioModelPrice(
     let audioPrice =
       (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
       (audioCompletionTokens / 1000000) *
-      inputRatioPrice *
-      audioRatio *
-      audioCompletionRatio *
-      groupRatio;
+        inputRatioPrice *
+        audioRatio *
+        audioCompletionRatio *
+        groupRatio;
     let price = textPrice + audioPrice;
     return (
       <>
@@ -1304,27 +1340,27 @@ export function renderAudioModelPrice(
           <p>
             {cacheTokens > 0
               ? i18next.t(
-                '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                {
-                  nonCacheInput: inputTokens - cacheTokens,
-                  cacheInput: cacheTokens,
-                  cachePrice: inputRatioPrice * cacheRatio,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  total: textPrice.toFixed(6),
-                },
-              )
+                  '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                  {
+                    nonCacheInput: inputTokens - cacheTokens,
+                    cacheInput: cacheTokens,
+                    cachePrice: inputRatioPrice * cacheRatio,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    total: textPrice.toFixed(6),
+                  },
+                )
               : i18next.t(
-                '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                {
-                  input: inputTokens,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  total: textPrice.toFixed(6),
-                },
-              )}
+                  '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                  {
+                    input: inputTokens,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    total: textPrice.toFixed(6),
+                  },
+                )}
           </p>
           <p>
             {i18next.t(
@@ -1374,12 +1410,14 @@ export function renderClaudeModelPrice(
   modelPrice = -1,
   completionRatio,
   groupRatio,
+  user_group_ratio,
   cacheTokens = 0,
   cacheRatio = 1.0,
   cacheCreationTokens = 0,
   cacheCreationRatio = 1.0,
 ) {
-  const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
+  groupRatio = effectiveGroupRatio;
 
   if (modelPrice !== -1) {
     return i18next.t(
@@ -1461,33 +1499,35 @@ export function renderClaudeModelPrice(
           <p>
             {cacheTokens > 0 || cacheCreationTokens > 0
               ? i18next.t(
-                '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                {
-                  nonCacheInput: nonCachedTokens,
-                  cacheInput: cacheTokens,
-                  cacheRatio: cacheRatio,
-                  cacheCreationInput: cacheCreationTokens,
-                  cacheCreationRatio: cacheCreationRatio,
-                  cachePrice: cacheRatioPrice,
-                  cacheCreationPrice: cacheCreationRatioPrice,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  ratio: groupRatio,
-                  total: price.toFixed(6),
-                },
-              )
+                  '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+                  {
+                    nonCacheInput: nonCachedTokens,
+                    cacheInput: cacheTokens,
+                    cacheRatio: cacheRatio,
+                    cacheCreationInput: cacheCreationTokens,
+                    cacheCreationRatio: cacheCreationRatio,
+                    cachePrice: cacheRatioPrice,
+                    cacheCreationPrice: cacheCreationRatioPrice,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    ratio: groupRatio,
+                    ratioType: ratioLabel,
+                    total: price.toFixed(6),
+                  },
+                )
               : i18next.t(
-                '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                {
-                  input: inputTokens,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  ratio: groupRatio,
-                  total: price.toFixed(6),
-                },
-              )}
+                  '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+                  {
+                    input: inputTokens,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    ratio: groupRatio,
+                    ratioType: ratioLabel,
+                    total: price.toFixed(6),
+                  },
+                )}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -1501,10 +1541,12 @@ export function renderClaudeLogContent(
   completionRatio,
   modelPrice = -1,
   groupRatio,
+  user_group_ratio,
   cacheRatio = 1.0,
   cacheCreationRatio = 1.0,
 ) {
-  const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
+  groupRatio = effectiveGroupRatio;
 
   if (modelPrice !== -1) {
     return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
@@ -1531,12 +1573,14 @@ export function renderClaudeModelPriceSimple(
   modelRatio,
   modelPrice = -1,
   groupRatio,
+  user_group_ratio,
   cacheTokens = 0,
   cacheRatio = 1.0,
   cacheCreationTokens = 0,
   cacheCreationRatio = 1.0,
 ) {
-  const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组');
+  const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
+  groupRatio = effectiveGroupRatio;
 
   if (modelPrice !== -1) {
     return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {

+ 31 - 0
web/src/pages/Setting/Operation/GroupRatioSettings.js

@@ -16,6 +16,7 @@ export default function GroupRatioSettings(props) {
   const [inputs, setInputs] = useState({
     GroupRatio: '',
     UserUsableGroups: '',
+    GroupGroupRatio: '',
   });
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(inputs);
@@ -99,6 +100,9 @@ export default function GroupRatioSettings(props) {
               <Form.TextArea
                 label={t('分组倍率')}
                 placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
+                extraText={t(
+                  '分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1',
+                )}
                 field={'GroupRatio'}
                 autosize={{ minRows: 6, maxRows: 12 }}
                 trigger='blur'
@@ -120,6 +124,9 @@ export default function GroupRatioSettings(props) {
               <Form.TextArea
                 label={t('用户可选分组')}
                 placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
+                extraText={t(
+                  '用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
+                )}
                 field={'UserUsableGroups'}
                 autosize={{ minRows: 6, maxRows: 12 }}
                 trigger='blur'
@@ -136,6 +143,30 @@ export default function GroupRatioSettings(props) {
               />
             </Col>
           </Row>
+          <Row gutter={16}>
+            <Col xs={24} sm={16}>
+              <Form.TextArea
+                label={t('分组特殊倍率')}
+                placeholder={t('为一个 JSON 文本')}
+                extraText={t(
+                  '键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
+                )}
+                field={'GroupGroupRatio'}
+                autosize={{ minRows: 6, maxRows: 12 }}
+                trigger='blur'
+                stopValidateWithError
+                rules={[
+                  {
+                    validator: (rule, value) => verifyJSON(value),
+                    message: t('不是合法的 JSON 字符串'),
+                  },
+                ]}
+                onChange={(value) =>
+                  setInputs({ ...inputs, GroupGroupRatio: value })
+                }
+              />
+            </Col>
+          </Row>
         </Form.Section>
       </Form>
       <Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>