Merge pull request #18 from QuantumNous/main

Fork Sync: Update from parent repository
This commit is contained in:
github-actions[bot]
2025-10-03 15:14:37 +00:00
committed by GitHub
34 changed files with 1317 additions and 599 deletions

View File

@@ -13,7 +13,3 @@
### PR 描述
**请在下方详细描述您的 PR包括目的、实现细节等。**
### **重要提示**
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**

View File

@@ -19,6 +19,7 @@ var TopUpLink = ""
// var ChatLink = ""
// var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
var DisplayInCurrencyEnabled = true
var DisplayTokenStatEnabled = true
var DrawingEnabled = true

View File

@@ -5,6 +5,7 @@ import (
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting/operation_setting"
)
func GetSubscription(c *gin.Context) {
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
}
quota := remainQuota + usedQuota
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
// 我们将其解释为以“站点展示类型”为准:
// - USD: 直接除以 QuotaPerUnit
// - CNY: 先转 USD 再乘汇率
// - TOKENS: 直接使用 tokens 数量
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// amount 保持 tokens 数值
default:
amount = amount / common.QuotaPerUnit
}
if token != nil && token.UnlimitedQuota {
amount = 100000000
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
return
}
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// tokens 保持原值
default:
amount = amount / common.QuotaPerUnit
}
usage := OpenAIUsageResponse{
Object: "list",

View File

@@ -66,7 +66,11 @@ func GetStatus(c *gin.Context) {
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
"display_in_currency": operation_setting.IsCurrencyDisplay(),
"quota_display_type": operation_setting.GetQuotaDisplayType(),
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,

View File

@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
func getPayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
if !common.DisplayInCurrencyEnabled {
// 充值金额以“展示类型”为准:
// - USD/CNY: 前端传 amount 为金额单位TOKENS: 前端传 tokens需要换成 USD 金额
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
dAmount = dAmount.Div(dQuotaPerUnit)
}
@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
func getMinTopup() int64 {
minTopup := operation_setting.MinTopUp
if !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) {
return
}
amount := req.Amount
if !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(int64(amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart()

View File

@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
func getStripePayMoney(amount float64, group string) float64 {
originalAmount := amount
if !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
@@ -279,7 +279,7 @@ func getStripePayMoney(amount float64, group string) float64 {
func getStripeMinTopup() int64 {
minTopup := setting.StripeMinTopUp
if !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)

View File

@@ -7,6 +7,7 @@ import (
"io"
"log"
"one-api/common"
"one-api/setting/operation_setting"
"os"
"path/filepath"
"sync"
@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
}
func LogQuota(quota int) string {
if common.DisplayInCurrencyEnabled {
return fmt.Sprintf("%.6f 额度", float64(quota)/common.QuotaPerUnit)
} else {
// 新逻辑:根据额度展示类型输出
q := float64(quota)
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
usd := q / common.QuotaPerUnit
cny := usd * operation_setting.USDExchangeRate
return fmt.Sprintf("¥%.6f 额度", cny)
case operation_setting.QuotaDisplayTypeCustom:
usd := q / common.QuotaPerUnit
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
if symbol == "" {
symbol = "¤"
}
if rate <= 0 {
rate = 1
}
v := usd * rate
return fmt.Sprintf("%s%.6f 额度", symbol, v)
case operation_setting.QuotaDisplayTypeTokens:
return fmt.Sprintf("%d 点额度", quota)
default: // USD
return fmt.Sprintf("%.6f 额度", q/common.QuotaPerUnit)
}
}
func FormatQuota(quota int) string {
if common.DisplayInCurrencyEnabled {
return fmt.Sprintf("%.6f", float64(quota)/common.QuotaPerUnit)
} else {
q := float64(quota)
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
usd := q / common.QuotaPerUnit
cny := usd * operation_setting.USDExchangeRate
return fmt.Sprintf("¥%.6f", cny)
case operation_setting.QuotaDisplayTypeCustom:
usd := q / common.QuotaPerUnit
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
if symbol == "" {
symbol = "¤"
}
if rate <= 0 {
rate = 1
}
v := usd * rate
return fmt.Sprintf("%s%.6f", symbol, v)
case operation_setting.QuotaDisplayTypeTokens:
return fmt.Sprintf("%d", quota)
default:
return fmt.Sprintf("%.6f", q/common.QuotaPerUnit)
}
}

View File

@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled":
common.DisplayInCurrencyEnabled = boolValue
// 兼容旧字段:同步到新配置 general_setting.quota_display_type运行时生效
// true -> USD, false -> TOKENS
newVal := "USD"
if !boolValue {
newVal = "TOKENS"
}
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
}
case "DisplayTokenStatEnabled":
common.DisplayTokenStatEnabled = boolValue
case "DrawingEnabled":

View File

@@ -18,7 +18,9 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{}
@@ -33,16 +35,24 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
if info.RelayMode == relayconstant.RelayModeEmbeddings {
return info.ChannelBaseUrl + "/api/embed", nil
}
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return info.ChannelBaseUrl + "/api/generate", nil
}
return info.ChannelBaseUrl + "/api/chat", nil
}
@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil { return nil, errors.New("request is nil") }
if request == nil {
return nil, errors.New("request is nil")
}
// decide generate or chat
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return openAIToGenerate(c, request)
@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return requestOpenAI2Embeddings(request), nil
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)

View File

@@ -67,4 +67,3 @@ type OllamaEmbeddingResponse struct {
Embeddings [][]float64 `json:"embeddings"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}

View File

@@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
}
// options mapping
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
if r.Temperature != nil {
chatReq.Options["temperature"] = r.Temperature
}
if r.TopP != 0 {
chatReq.Options["top_p"] = r.TopP
}
if r.TopK != 0 {
chatReq.Options["top_k"] = r.TopK
}
if r.FrequencyPenalty != 0 {
chatReq.Options["frequency_penalty"] = r.FrequencyPenalty
}
if r.PresencePenalty != 0 {
chatReq.Options["presence_penalty"] = r.PresencePenalty
}
if r.Seed != 0 {
chatReq.Options["seed"] = int(r.Seed)
}
if mt := r.GetMaxTokens(); mt != 0 {
chatReq.Options["num_predict"] = int(mt)
}
if r.Stop != nil {
switch v := r.Stop.(type) {
@@ -51,8 +65,14 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
chatReq.Options["stop"] = v
case []any:
arr := make([]string, 0, len(v))
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
if len(arr)>0 { chatReq.Options["stop"] = arr }
for _, i := range v {
if s, ok := i.(string); ok {
arr = append(arr, s)
}
}
if len(arr) > 0 {
chatReq.Options["stop"] = arr
}
}
}
@@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
var base64Data string
if strings.HasPrefix(img.Url, "http") {
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
if err != nil { return nil, err }
if err != nil {
return nil, err
}
base64Data = fileData.Base64Data
} else if strings.HasPrefix(img.Url, "data:") {
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
base64Data = img.Url[idx+1:]
}
} else {
base64Data = img.Url
}
if base64Data != "" { images = append(images, base64Data) }
if base64Data != "" {
images = append(images, base64Data)
}
}
} else if part.Type == dto.ContentTypeText {
textBuilder.WriteString(part.Text)
@@ -94,16 +120,24 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
}
}
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
if len(images)>0 { cm.Images = images }
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
if len(images) > 0 {
cm.Images = images
}
if m.Role == "tool" && m.Name != nil {
cm.ToolName = *m.Name
}
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
parsed := m.ParseToolCalls()
if len(parsed) > 0 {
calls := make([]OllamaToolCall, 0, len(parsed))
for _, tc := range parsed {
var args interface{}
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
if args==nil { args = map[string]any{} }
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
}
if args == nil {
args = map[string]any{}
}
oc := OllamaToolCall{}
oc.Function.Name = tc.Function.Name
oc.Function.Arguments = args
@@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
gen.Prompt = v
case []any:
var sb strings.Builder
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
for _, it := range v {
if s, ok := it.(string); ok {
sb.WriteString(s)
}
}
gen.Prompt = sb.String()
default:
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
}
}
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
if r.Suffix != nil {
if s, ok := r.Suffix.(string); ok {
gen.Suffix = s
}
}
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" {
gen.Format = "json"
} else if r.ResponseFormat.Type == "json_schema" {
var schema any
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
gen.Format = schema
}
}
if r.Temperature != nil {
gen.Options["temperature"] = r.Temperature
}
if r.TopP != 0 {
gen.Options["top_p"] = r.TopP
}
if r.TopK != 0 {
gen.Options["top_k"] = r.TopK
}
if r.FrequencyPenalty != 0 {
gen.Options["frequency_penalty"] = r.FrequencyPenalty
}
if r.PresencePenalty != 0 {
gen.Options["presence_penalty"] = r.PresencePenalty
}
if r.Seed != 0 {
gen.Options["seed"] = int(r.Seed)
}
if mt := r.GetMaxTokens(); mt != 0 {
gen.Options["num_predict"] = int(mt)
}
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
if r.Stop != nil {
switch v := r.Stop.(type) {
case string: gen.Options["stop"] = []string{v}
case []string: gen.Options["stop"] = v
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
case string:
gen.Options["stop"] = []string{v}
case []string:
gen.Options["stop"] = v
case []any:
arr := make([]string, 0, len(v))
for _, i := range v {
if s, ok := i.(string); ok {
arr = append(arr, s)
}
}
if len(arr) > 0 {
gen.Options["stop"] = arr
}
}
}
return gen, nil
@@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
opts := map[string]any{}
if r.Temperature != nil { opts["temperature"] = r.Temperature }
if r.TopP != 0 { opts["top_p"] = r.TopP }
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
if r.Temperature != nil {
opts["temperature"] = r.Temperature
}
if r.TopP != 0 {
opts["top_p"] = r.TopP
}
if r.FrequencyPenalty != 0 {
opts["frequency_penalty"] = r.FrequencyPenalty
}
if r.PresencePenalty != 0 {
opts["presence_penalty"] = r.PresencePenalty
}
if r.Seed != 0 {
opts["seed"] = int(r.Seed)
}
if r.Dimensions != 0 {
opts["dimensions"] = r.Dimensions
}
input := r.ParseInput()
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
if len(input) == 1 {
return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions}
}
return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions}
}
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var oResp OllamaEmbeddingResponse
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
if err = common.Unmarshal(body, &oResp); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if oResp.Error != "" {
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings))
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
for i, emb := range oResp.Embeddings {
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
}
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
out, _ := common.Marshal(embResp)
service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
}

View File

@@ -47,15 +47,25 @@ type ollamaChatStreamChunk struct {
}
func toUnix(ts string) int64 {
if ts == "" { return time.Now().Unix() }
if ts == "" {
return time.Now().Unix()
}
// try time.RFC3339 or with nanoseconds
t, err := time.Parse(time.RFC3339Nano, ts)
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
if err != nil {
t2, err2 := time.Parse(time.RFC3339, ts)
if err2 == nil {
return t2.Unix()
}
return time.Now().Unix()
}
return t.Unix()
}
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
if resp == nil || resp.Body == nil {
return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest)
}
defer service.CloseResponseBodyGracefully(resp)
helper.SetEventStreamHeaders(c)
@@ -66,24 +76,34 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var created = time.Now().Unix()
var toolCallIndex int
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
if data, err := common.Marshal(start); err == nil {
_ = helper.StringData(c, string(data))
}
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
if line == "" { continue }
if line == "" {
continue
}
var chunk ollamaChatStreamChunk
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chunk.Model != "" { model = chunk.Model }
if chunk.Model != "" {
model = chunk.Model
}
created = toUnix(chunk.CreatedAt)
if !chunk.Done {
// delta content
var content string
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
if chunk.Message != nil {
content = chunk.Message.Content
} else {
content = chunk.Response
}
delta := dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
@@ -94,10 +114,14 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"},
}},
}
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
if content != "" {
delta.Choices[0].Delta.SetContentString(content)
}
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
raw := strings.TrimSpace(string(chunk.Message.Thinking))
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
if raw != "" && raw != "null" {
delta.Choices[0].Delta.SetReasoningContent(raw)
}
}
// tool calls
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
@@ -112,7 +136,9 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
}
}
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
if data, err := common.Marshal(delta); err == nil {
_ = helper.StringData(c, string(data))
}
continue
}
// done frame
@@ -121,30 +147,42 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.CompletionTokens = chunk.EvalCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
finishReason := chunk.DoneReason
if finishReason == "" { finishReason = "stop" }
if finishReason == "" {
finishReason = "stop"
}
// emit stop delta
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
if data, err := common.Marshal(stop); err == nil {
_ = helper.StringData(c, string(data))
}
}
// emit usage frame
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
if data, err := common.Marshal(final); err == nil {
_ = helper.StringData(c, string(data))
}
}
// send [DONE]
helper.Done(c)
break
}
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
if err := scanner.Err(); err != nil && err != io.EOF {
logger.LogError(c, "ollama stream scan error: "+err.Error())
}
return usage, nil
}
// non-stream handler for chat/generate
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
raw := string(body)
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
if common.DebugEnabled {
println("ollama non-stream raw resp:", raw)
}
lines := strings.Split(raw, "\n")
var (
@@ -155,41 +193,66 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
)
for _, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" { continue }
if ln == "" {
continue
}
var ck ollamaChatStreamChunk
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
if len(lines) == 1 {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
continue
}
parsedAny = true
lastChunk = ck
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
raw := strings.TrimSpace(string(ck.Message.Thinking))
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
if raw != "" && raw != "null" {
reasoningBuilder.WriteString(raw)
}
}
if ck.Message != nil && ck.Message.Content != "" {
aggContent.WriteString(ck.Message.Content)
} else if ck.Response != "" {
aggContent.WriteString(ck.Response)
}
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
}
if !parsedAny {
var single ollamaChatStreamChunk
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
if err := json.Unmarshal(body, &single); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
lastChunk = single
if single.Message != nil {
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
if len(single.Message.Thinking) > 0 {
raw := strings.TrimSpace(string(single.Message.Thinking))
if raw != "" && raw != "null" {
reasoningBuilder.WriteString(raw)
}
}
aggContent.WriteString(single.Message.Content)
} else { aggContent.WriteString(single.Response) }
} else {
aggContent.WriteString(single.Response)
}
}
model := lastChunk.Model
if model == "" { model = info.UpstreamModelName }
if model == "" {
model = info.UpstreamModelName
}
created := toUnix(lastChunk.CreatedAt)
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
content := aggContent.String()
finishReason := lastChunk.DoneReason
if finishReason == "" { finishReason = "stop" }
if finishReason == "" {
finishReason = "stop"
}
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
if rc := reasoningBuilder.String(); rc != "" {
msg.ReasoningContent = rc
}
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
@@ -207,4 +270,9 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return usage, nil
}
func contentPtr(s string) *string { if s=="" { return nil }; return &s }
func contentPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -2,10 +2,24 @@ package operation_setting
import "one-api/setting/config"
// 额度展示类型
const (
QuotaDisplayTypeUSD = "USD"
QuotaDisplayTypeCNY = "CNY"
QuotaDisplayTypeTokens = "TOKENS"
QuotaDisplayTypeCustom = "CUSTOM"
)
type GeneralSetting struct {
DocsLink string `json:"docs_link"`
PingIntervalEnabled bool `json:"ping_interval_enabled"`
PingIntervalSeconds int `json:"ping_interval_seconds"`
// 当前站点额度展示类型USD / CNY / TOKENS
QuotaDisplayType string `json:"quota_display_type"`
// 自定义货币符号,用于 CUSTOM 展示类型
CustomCurrencySymbol string `json:"custom_currency_symbol"`
// 自定义货币与美元汇率1 USD = X Custom
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
}
// 默认配置
@@ -13,6 +27,9 @@ var generalSetting = GeneralSetting{
DocsLink: "https://docs.newapi.pro",
PingIntervalEnabled: false,
PingIntervalSeconds: 60,
QuotaDisplayType: QuotaDisplayTypeUSD,
CustomCurrencySymbol: "¤",
CustomCurrencyExchangeRate: 1.0,
}
func init() {
@@ -23,3 +40,52 @@ func init() {
func GetGeneralSetting() *GeneralSetting {
return &generalSetting
}
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
func IsCurrencyDisplay() bool {
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
}
// IsCNYDisplay 是否以人民币展示
func IsCNYDisplay() bool {
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
}
// GetQuotaDisplayType 返回额度展示类型
func GetQuotaDisplayType() string {
return generalSetting.QuotaDisplayType
}
// GetCurrencySymbol 返回当前展示类型对应符号
func GetCurrencySymbol() string {
switch generalSetting.QuotaDisplayType {
case QuotaDisplayTypeUSD:
return "$"
case QuotaDisplayTypeCNY:
return "¥"
case QuotaDisplayTypeCustom:
if generalSetting.CustomCurrencySymbol != "" {
return generalSetting.CustomCurrencySymbol
}
return "¤"
default:
return ""
}
}
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 XTOKENS 不适用)
func GetUsdToCurrencyRate(usdToCny float64) float64 {
switch generalSetting.QuotaDisplayType {
case QuotaDisplayTypeUSD:
return 1
case QuotaDisplayTypeCNY:
return usdToCny
case QuotaDisplayTypeCustom:
if generalSetting.CustomCurrencyExchangeRate > 0 {
return generalSetting.CustomCurrencyExchangeRate
}
return 1
default:
return 1
}
}

View File

@@ -42,7 +42,7 @@ const OperationSetting = () => {
QuotaPerUnit: 0,
USDExchangeRate: 0,
RetryTimes: 0,
DisplayInCurrencyEnabled: false,
'general_setting.quota_display_type': 'USD',
DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,

View File

@@ -91,7 +91,8 @@ const AccountManagement = ({
);
};
const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
const [showTelegramBindModal, setShowTelegramBindModal] =
React.useState(false);
const passkeyEnabled = passkeyStatus?.enabled;
const lastUsedLabel = passkeyStatus?.last_used_at
? new Date(passkeyStatus.last_used_at).toLocaleString()
@@ -236,7 +237,8 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
isBound(userState.user?.github_id) || !status.github_oauth
isBound(userState.user?.github_id) ||
!status.github_oauth
}
>
{status.github_oauth ? t('绑定') : t('未启用')}
@@ -394,7 +396,8 @@ const AccountManagement = ({
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
isBound(userState.user?.linux_do_id) ||
!status.linuxdo_oauth
}
>
{status.linuxdo_oauth ? t('绑定') : t('未启用')}

View File

@@ -91,8 +91,7 @@ const REGION_EXAMPLE = {
// 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48,
43,
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
]);
function type2secretPrompt(type) {
@@ -408,7 +407,10 @@ const EditChannelModal = (props) => {
break;
case 45:
localModels = getChannelModels(value);
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
setInputs((prevInputs) => ({
...prevInputs,
base_url: 'https://ark.cn-beijing.volces.com',
}));
break;
default:
localModels = getChannelModels(value);
@@ -502,7 +504,8 @@ const EditChannelModal = (props) => {
// 读取 Vertex 密钥格式
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
// 读取企业账户设置
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
data.is_enterprise_account =
parsedSettings.openrouter_enterprise === true;
// 读取字段透传控制设置
data.allow_service_tier = parsedSettings.allow_service_tier || false;
data.disable_store = parsedSettings.disable_store || false;
@@ -929,7 +932,10 @@ const EditChannelModal = (props) => {
showInfo(t('请至少选择一个模型!'));
return;
}
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
if (
localInputs.type === 45 &&
(!localInputs.base_url || localInputs.base_url.trim() === '')
) {
showInfo(t('请输入API地址'));
return;
}
@@ -974,7 +980,8 @@ const EditChannelModal = (props) => {
// type === 20: 设置企业账户标识无论是true还是false都要传到后端
if (localInputs.type === 20) {
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
settings.openrouter_enterprise =
localInputs.is_enterprise_account === true;
}
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
@@ -1433,7 +1440,9 @@ const EditChannelModal = (props) => {
setIsEnterpriseAccount(value);
handleInputChange('is_enterprise_account', value);
}}
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
extraText={t(
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
)}
initValue={inputs.is_enterprise_account}
/>
)}
@@ -2072,12 +2081,12 @@ const EditChannelModal = (props) => {
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com'
label: 'https://ark.cn-beijing.volces.com',
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com'
}
label: 'https://ark.ap-southeast.bytepluses.com',
},
]}
defaultValue='https://ark.cn-beijing.volces.com'
/>

View File

@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
const currencyItems = [
{ value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' },
{ value: 'CUSTOM', label: t('自定义货币') },
];
const handleChange = (value) => {

View File

@@ -107,6 +107,7 @@ const SearchActions = memo(
optionList={[
{ value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' },
{ value: 'CUSTOM', label: t('自定义货币') },
]}
/>
)}

View File

@@ -60,16 +60,28 @@ const ContentModal = ({
if (videoError) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px' }}
>
视频无法在当前浏览器中播放这可能是由于
</Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
视频服务商的跨域限制
</Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
需要特定的请求头或认证
</Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
>
防盗链保护机制
</Text>
@@ -81,17 +93,21 @@ const ContentModal = ({
>
在新标签页中打开
</Button>
<Button
icon={<IconCopy />}
onClick={handleCopyUrl}
>
<Button icon={<IconCopy />} onClick={handleCopyUrl}>
复制链接
</Button>
</div>
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
<div
style={{
marginTop: '16px',
padding: '8px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
}}
>
<Text
type="tertiary"
type='tertiary'
style={{ fontSize: '10px', wordBreak: 'break-all' }}
>
{modalContent}
@@ -104,14 +120,16 @@ const ContentModal = ({
return (
<div style={{ position: 'relative' }}>
{isLoading && (
<div style={{
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10
}}>
<Spin size="large" />
zIndex: 10,
}}
>
<Spin size='large' />
</div>
)}
<video
@@ -119,7 +137,7 @@ const ContentModal = ({
controls
style={{ width: '100%' }}
autoPlay
crossOrigin="anonymous"
crossOrigin='anonymous'
onError={handleVideoError}
onLoadedData={handleVideoLoaded}
onLoadStart={() => setIsLoading(true)}
@@ -137,7 +155,7 @@ const ContentModal = ({
bodyStyle={{
height: isVideo ? '450px' : '400px',
overflow: 'auto',
padding: isVideo && videoError ? '0' : '24px'
padding: isVideo && videoError ? '0' : '24px',
}}
width={800}
>

View File

@@ -419,7 +419,7 @@ export const getLogsColumns = ({
},
{
key: COLUMN_KEYS.PROMPT,
title: t('提示'),
title: t('输入'),
dataIndex: 'prompt_tokens',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
@@ -431,7 +431,7 @@ export const getLogsColumns = ({
},
{
key: COLUMN_KEYS.COMPLETION,
title: t('补全'),
title: t('输出'),
dataIndex: 'completion_tokens',
render: (text, record, index) => {
return parseInt(text) > 0 &&

View File

@@ -37,6 +37,7 @@ import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
import { IconGift } from '@douyinfe/semi-icons';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { getCurrencyConfig } from '../../helpers/render';
const { Text } = Typography;
@@ -338,7 +339,23 @@ const RechargeCard = ({
)}
{(enableOnlineTopUp || enableStripeTopUp) && (
<Form.Slot label={t('选择充值额度')}>
<Form.Slot
label={
<div className='flex items-center gap-2'>
<span>{t('选择充值额度')}</span>
{(() => {
const { symbol, rate, type } = getCurrencyConfig();
if (type === 'USD') return null;
return (
<span style={{ color: 'var(--semi-color-text-2)', fontSize: '12px', fontWeight: 'normal' }}>
(1 $ = {rate.toFixed(2)} {symbol})
</span>
);
})()}
</div>
}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => {
const discount =
@@ -351,6 +368,35 @@ const RechargeCard = ({
const actualPay = discountedPrice;
const save = originalPrice - discountedPrice;
// 根据当前货币类型换算显示金额和数量
const { symbol, rate, type } = getCurrencyConfig();
const statusStr = localStorage.getItem('status');
let usdRate = 7; // 默认CNY汇率
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) {}
let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay;
let displaySave = save;
if (type === 'USD') {
// 数量保持USD价格从CNY转USD
displayActualPay = actualPay / usdRate;
displaySave = save / usdRate;
} else if (type === 'CNY') {
// 数量转CNY价格已是CNY
displayValue = preset.value * usdRate;
} else if (type === 'CUSTOM') {
// 数量和价格都转自定义货币
displayValue = preset.value * rate;
displayActualPay = (actualPay / usdRate) * rate;
displaySave = (save / usdRate) * rate;
}
return (
<Card
key={index}
@@ -378,7 +424,7 @@ const RechargeCard = ({
style={{ margin: '0 0 8px 0' }}
>
<Coins size={18} />
{formatLargeNumber(preset.value)}
{formatLargeNumber(preset.value)} $
{hasDiscount && (
<Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off')
@@ -398,10 +444,10 @@ const RechargeCard = ({
margin: '4px 0',
}}
>
{t('实付')} {actualPay.toFixed(2)}
{t('实付')} {symbol}{displayActualPay.toFixed(2)}
{hasDiscount
? `${t('节省')} ${save.toFixed(2)}`
: `${t('节省')} 0.00`}
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
: `${t('节省')} ${symbol}0.00`}
</div>
</div>
</Card>

View File

@@ -23,7 +23,9 @@ export function setStatusData(data) {
localStorage.setItem('logo', data.logo);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('quota_per_unit', data.quota_per_unit);
// 兼容:保留旧字段,同时写入新的额度展示类型
localStorage.setItem('display_in_currency', data.display_in_currency);
localStorage.setItem('quota_display_type', data.quota_display_type || 'USD');
localStorage.setItem('enable_drawing', data.enable_drawing);
localStorage.setItem('enable_task', data.enable_task);
localStorage.setItem('enable_data_export', data.enable_data_export);

View File

@@ -832,13 +832,26 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
if (typeof num !== 'number' || isNaN(num)) {
return 0;
}
let displayInCurrency = localStorage.getItem('display_in_currency');
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
num = num.toFixed(digits);
if (displayInCurrency) {
if (quotaDisplayType === 'CNY') {
return '¥' + num;
} else if (quotaDisplayType === 'USD') {
return '$' + num;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + num;
} else {
return num;
}
}
export function renderNumberWithPoint(num) {
if (num === undefined) return '';
@@ -889,34 +902,112 @@ export function getQuotaWithUnit(quota, digits = 6) {
}
export function renderQuotaWithAmount(amount) {
let displayInCurrency = localStorage.getItem('display_in_currency');
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
return '$' + amount;
} else {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
if (quotaDisplayType === 'TOKENS') {
return renderNumber(renderUnitWithQuota(amount));
}
if (quotaDisplayType === 'CNY') {
return '¥' + amount;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + amount;
}
return '$' + amount;
}
/**
* 获取当前货币配置信息
* @returns {Object} - { symbol, rate, type }
*/
export function getCurrencyConfig() {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
const statusStr = localStorage.getItem('status');
let symbol = '$';
let rate = 1;
if (quotaDisplayType === 'CNY') {
symbol = '¥';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
rate = s?.usd_exchange_rate || 7;
}
} catch (e) {}
} else if (quotaDisplayType === 'CUSTOM') {
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || '¤';
rate = s?.custom_currency_exchange_rate || 1;
}
} catch (e) {}
}
return { symbol, rate, type: quotaDisplayType };
}
/**
* 将美元金额转换为当前选择的货币
* @param {number} usdAmount - 美元金额
* @param {number} digits - 小数位数
* @returns {string} - 格式化后的货币字符串
*/
export function convertUSDToCurrency(usdAmount, digits = 2) {
const { symbol, rate } = getCurrencyConfig();
const convertedAmount = usdAmount * rate;
return symbol + convertedAmount.toFixed(digits);
}
export function renderQuota(quota, digits = 2) {
let quotaPerUnit = localStorage.getItem('quota_per_unit');
let displayInCurrency = localStorage.getItem('display_in_currency');
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
quotaPerUnit = parseFloat(quotaPerUnit);
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
const result = quota / quotaPerUnit;
const fixedResult = result.toFixed(digits);
// 如果 toFixed 后结果为 0 但原始值不为 0显示最小值
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
const minValue = Math.pow(10, -digits);
return '$' + minValue.toFixed(digits);
}
return '$' + fixedResult;
}
if (quotaDisplayType === 'TOKENS') {
return renderNumber(quota);
}
const resultUSD = quota / quotaPerUnit;
let symbol = '$';
let value = resultUSD;
if (quotaDisplayType === 'CNY') {
const statusStr = localStorage.getItem('status');
let usdRate = 1;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 1;
}
} catch (e) {}
value = resultUSD * usdRate;
symbol = '¥';
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbolCustom = '¤';
let rate = 1;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbolCustom = s?.custom_currency_symbol || symbolCustom;
rate = s?.custom_currency_exchange_rate || rate;
}
} catch (e) {}
value = resultUSD * rate;
symbol = symbolCustom;
}
const fixedResult = value.toFixed(digits);
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
const minValue = Math.pow(10, -digits);
return symbol + minValue.toFixed(digits);
}
return symbol + fixedResult;
}
function isValidGroupRatio(ratio) {
return Number.isFinite(ratio) && ratio !== -1;
@@ -1038,13 +1129,19 @@ export function renderModelPrice(
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
if (modelPrice !== -1) {
const displayPrice = (modelPrice * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
return i18next.t(
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
'模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',
{
price: modelPrice,
symbol: symbol,
price: displayPrice,
ratio: groupRatio,
total: modelPrice * groupRatio,
total: displayTotal,
ratioType: ratioLabel,
},
);
@@ -1080,19 +1177,21 @@ export function renderModelPrice(
<>
<article>
<p>
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
price: inputRatioPrice,
{i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
audioPrice: audioInputSeperatePrice
? `,音频 $${audioInputPrice} / 1M tokens`
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
: '',
})}
</p>
<p>
{i18next.t(
'输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
'输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
{
price: inputRatioPrice,
total: completionRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (completionRatioPrice * rate).toFixed(6),
completionRatio: completionRatio,
},
)}
@@ -1100,10 +1199,11 @@ export function renderModelPrice(
{cacheTokens > 0 && (
<p>
{i18next.t(
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
{
price: inputRatioPrice,
total: inputRatioPrice * cacheRatio,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
cacheRatio: cacheRatio,
},
)}
@@ -1112,11 +1212,12 @@ export function renderModelPrice(
{image && imageOutputTokens > 0 && (
<p>
{i18next.t(
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
'图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})',
{
price: imageRatioPrice,
symbol: symbol,
price: (imageRatioPrice * rate).toFixed(6),
ratio: groupRatio,
total: imageRatioPrice * groupRatio,
total: (imageRatioPrice * groupRatio * rate).toFixed(6),
imageRatio: imageRatio,
},
)}
@@ -1124,22 +1225,25 @@ export function renderModelPrice(
)}
{webSearch && webSearchCallCount > 0 && (
<p>
{i18next.t('Web搜索价格${{price}} / 1K 次', {
price: webSearchPrice,
{i18next.t('Web搜索价格{{symbol}}{{price}} / 1K 次', {
symbol: symbol,
price: (webSearchPrice * rate).toFixed(6),
})}
</p>
)}
{fileSearch && fileSearchCallCount > 0 && (
<p>
{i18next.t('文件搜索价格:${{price}} / 1K 次', {
price: fileSearchPrice,
{i18next.t('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
symbol: symbol,
price: (fileSearchPrice * rate).toFixed(6),
})}
</p>
)}
{imageGenerationCall && imageGenerationCallPrice > 0 && (
<p>
{i18next.t('图片生成调用:${{price}} / 1次', {
price: imageGenerationCallPrice,
{i18next.t('图片生成调用:{{symbol}}{{price}} / 1次', {
symbol: symbol,
price: (imageGenerationCallPrice * rate).toFixed(6),
})}
</p>
)}
@@ -1149,50 +1253,55 @@ export function renderModelPrice(
let inputDesc = '';
if (image && imageOutputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
},
);
} else if (cacheTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
price: inputRatioPrice,
cachePrice: cacheRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
cachePrice: (cacheRatioPrice * rate).toFixed(6),
},
);
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
{
nonAudioInput: inputTokens - audioInputTokens,
audioInput: audioInputTokens,
price: inputRatioPrice,
audioPrice: audioInputPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
audioPrice: (audioInputPrice * rate).toFixed(6),
},
);
} else {
inputDesc = i18next.t(
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
{
input: inputTokens,
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
},
);
}
// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
compPrice: completionRatioPrice,
symbol: symbol,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
@@ -1202,10 +1311,11 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
symbol: symbol,
price: (webSearchPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
@@ -1213,10 +1323,11 @@ export function renderModelPrice(
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
symbol: symbol,
price: (fileSearchPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
@@ -1224,9 +1335,10 @@ export function renderModelPrice(
: '',
imageGenerationCall && imageGenerationCallPrice > 0
? i18next.t(
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
{
price: imageGenerationCallPrice,
symbol: symbol,
price: (imageGenerationCallPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
@@ -1235,12 +1347,13 @@ export function renderModelPrice(
].join('');
return i18next.t(
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
{
inputDesc,
outputDesc,
extraServices,
total: price.toFixed(6),
symbol: symbol,
total: (price * rate).toFixed(6),
},
);
})()}
@@ -1272,9 +1385,13 @@ export function renderLogContent(
useUserGroupRatio: useUserGroupRatio,
} = getEffectiveRatio(groupRatio, user_group_ratio);
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
if (modelPrice !== -1) {
return i18next.t('模型价格 ${{price}}{{ratioType}} {{ratio}}', {
price: modelPrice,
return i18next.t('模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel,
ratio,
});
@@ -1367,14 +1484,19 @@ export function renderAudioModelPrice(
user_group_ratio,
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
// 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) {
return i18next.t(
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
'模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',
{
price: modelPrice,
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratio: groupRatio,
total: modelPrice * groupRatio,
total: (modelPrice * groupRatio * rate).toFixed(6),
ratioType: ratioLabel,
},
);
@@ -1409,16 +1531,18 @@ export function renderAudioModelPrice(
<>
<article>
<p>
{i18next.t('提示价格:${{price}} / 1M tokens', {
price: inputRatioPrice,
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
})}
</p>
<p>
{i18next.t(
'补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
'补全价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
{
price: inputRatioPrice,
total: completionRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (completionRatioPrice * rate).toFixed(6),
completionRatio: completionRatio,
},
)}
@@ -1426,10 +1550,11 @@ export function renderAudioModelPrice(
{cacheTokens > 0 && (
<p>
{i18next.t(
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
{
price: inputRatioPrice,
total: inputRatioPrice * cacheRatio,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
cacheRatio: cacheRatio,
},
)}
@@ -1437,20 +1562,22 @@ export function renderAudioModelPrice(
)}
<p>
{i18next.t(
'音频提示价格:${{price}} * {{audioRatio}} = ${{total}} / 1M tokens (音频倍率: {{audioRatio}})',
'音频提示价格:{{symbol}}{{price}} * {{audioRatio}} = {{symbol}}{{total}} / 1M tokens (音频倍率: {{audioRatio}})',
{
price: inputRatioPrice,
total: inputRatioPrice * audioRatio,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (inputRatioPrice * audioRatio * rate).toFixed(6),
audioRatio: audioRatio,
},
)}
</p>
<p>
{i18next.t(
'音频补全价格:${{price}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
'音频补全价格:{{symbol}}{{price}} * {{audioRatio}} * {{audioCompRatio}} = {{symbol}}{{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
{
price: inputRatioPrice,
total: inputRatioPrice * audioRatio * audioCompletionRatio,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6),
audioRatio: audioRatio,
audioCompRatio: audioCompletionRatio,
},
@@ -1459,48 +1586,52 @@ 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}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
symbol: symbol,
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
compPrice: (completionRatioPrice * rate).toFixed(6),
total: (textPrice * rate).toFixed(6),
},
)
: i18next.t(
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
{
input: inputTokens,
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
compPrice: (completionRatioPrice * rate).toFixed(6),
total: (textPrice * rate).toFixed(6),
},
)}
</p>
<p>
{i18next.t(
'音频提示 {{input}} tokens / 1M tokens * ${{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * ${{audioCompPrice}} = ${{total}}',
'音频提示 {{input}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} = {{symbol}}{{total}}',
{
input: audioInputTokens,
completion: audioCompletionTokens,
audioInputPrice: audioRatio * inputRatioPrice,
symbol: symbol,
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6),
audioCompPrice:
audioRatio * audioCompletionRatio * inputRatioPrice,
total: audioPrice.toFixed(6),
(audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6),
total: (audioPrice * rate).toFixed(6),
},
)}
</p>
<p>
{i18next.t(
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = ${{total}}',
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}',
{
total: price.toFixed(6),
textPrice: textPrice.toFixed(6),
audioPrice: audioPrice.toFixed(6),
symbol: symbol,
total: (price * rate).toFixed(6),
textPrice: (textPrice * rate).toFixed(6),
audioPrice: (audioPrice * rate).toFixed(6),
},
)}
</p>
@@ -1512,9 +1643,8 @@ export function renderAudioModelPrice(
}
export function renderQuotaWithPrompt(quota, digits) {
let displayInCurrency = localStorage.getItem('display_in_currency');
displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
if (quotaDisplayType !== 'TOKENS') {
return i18next.t('等价金额:') + renderQuota(quota, digits);
}
return '';
@@ -1539,14 +1669,18 @@ export function renderClaudeModelPrice(
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
if (modelPrice !== -1) {
return i18next.t(
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
'模型价格:{{symbol}}{{price}} * {{ratioType}}{{ratio}} = {{symbol}}{{total}}',
{
price: modelPrice,
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel,
ratio: groupRatio,
total: modelPrice * groupRatio,
total: (modelPrice * groupRatio * rate).toFixed(6),
},
);
} else {
@@ -1575,28 +1709,31 @@ export function renderClaudeModelPrice(
<>
<article>
<p>
{i18next.t('提示价格:${{price}} / 1M tokens', {
price: inputRatioPrice,
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
})}
</p>
<p>
{i18next.t(
'补全价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens',
'补全价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens',
{
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: completionRatio,
total: completionRatioPrice,
total: (completionRatioPrice * rate).toFixed(6),
},
)}
</p>
{cacheTokens > 0 && (
<p>
{i18next.t(
'缓存价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
'缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
{
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: cacheRatio,
total: cacheRatioPrice,
total: (cacheRatioPrice * rate).toFixed(2),
cacheRatio: cacheRatio,
},
)}
@@ -1605,11 +1742,12 @@ export function renderClaudeModelPrice(
{cacheCreationTokens > 0 && (
<p>
{i18next.t(
'缓存创建价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
'缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
{
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: cacheCreationRatio,
total: cacheCreationRatioPrice,
total: (cacheCreationRatioPrice * rate).toFixed(6),
cacheCreationRatio: cacheCreationRatio,
},
)}
@@ -1619,33 +1757,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}} * {{ratioType}} {{ratio}} = ${{total}}',
'提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
symbol: symbol,
cachePrice: (cacheRatioPrice * rate).toFixed(2),
cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: completionRatioPrice,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
total: (price * rate).toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
input: inputTokens,
price: inputRatioPrice,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: completionRatioPrice,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
total: (price * rate).toFixed(6),
},
)}
</p>
@@ -1671,9 +1811,13 @@ export function renderClaudeLogContent(
);
groupRatio = effectiveGroupRatio;
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
if (modelPrice !== -1) {
return i18next.t('模型价格 ${{price}}{{ratioType}} {{ratio}}', {
price: modelPrice,
return i18next.t('模型价格 {{symbol}}{{price}}{{ratioType}} {{ratio}}', {
symbol: symbol,
price: (modelPrice * rate).toFixed(6),
ratioType: ratioLabel,
ratio: groupRatio,
});

View File

@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
const numCompletion =
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
let symbol = '$';
if (currency === 'CNY') {
symbol = '¥';
} else if (currency === 'CUSTOM') {
try {
const statusStr = localStorage.getItem('status');
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || '¤';
} else {
symbol = '¤';
}
} catch (e) {
symbol = '¤';
}
}
return {
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
unitLabel,
isPerToken: true,
usedGroup,

View File

@@ -25,9 +25,13 @@ import {
showInfo,
showSuccess,
loadChannelModels,
copy
copy,
} from '../../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
import {
CHANNEL_OPTIONS,
ITEMS_PER_PAGE,
MODEL_TABLE_PAGE_SIZE,
} from '../../constants';
import { useIsMobile } from '../common/useIsMobile';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { Modal } from '@douyinfe/semi-ui';
@@ -64,7 +68,7 @@ export const useChannelsData = () => {
// Status filter
const [statusFilter, setStatusFilter] = useState(
localStorage.getItem('channel-status-filter') || 'all'
localStorage.getItem('channel-status-filter') || 'all',
);
// Type tabs states
@@ -117,9 +121,12 @@ export const useChannelsData = () => {
// Initialize from localStorage
useEffect(() => {
const localIdSort = localStorage.getItem('id-sort') === 'true';
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
const localPageSize =
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
const localEnableTagMode =
localStorage.getItem('enable-tag-mode') === 'true';
const localEnableBatchDelete =
localStorage.getItem('enable-batch-delete') === 'true';
setIdSort(localIdSort);
setPageSize(localPageSize);
@@ -177,7 +184,10 @@ export const useChannelsData = () => {
// Save column preferences
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
localStorage.setItem(
'channels-table-columns',
JSON.stringify(visibleColumns),
);
}
}, [visibleColumns]);
@@ -291,14 +301,21 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true);
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
await searchChannels(
enableTagMode,
typeKey,
statusF,
page,
pageSize,
idSort,
);
setLoading(false);
return;
}
const reqId = ++requestCounter.current;
setLoading(true);
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
@@ -312,7 +329,10 @@ export const useChannelsData = () => {
if (success) {
const { items, total, type_counts } = data;
if (type_counts) {
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
const sumAll = Object.values(type_counts).reduce(
(acc, v) => acc + v,
0,
);
setTypeCounts({ ...type_counts, all: sumAll });
}
setChannelFormat(items, enableTagMode);
@@ -336,11 +356,18 @@ export const useChannelsData = () => {
setSearching(true);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
await loadChannels(
page,
pageSz,
sortFlag,
enableTagMode,
typeKey,
statusF,
);
return;
}
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
@@ -348,7 +375,10 @@ export const useChannelsData = () => {
const { success, message, data } = res.data;
if (success) {
const { items = [], total = 0, type_counts = {} } = data;
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
const sumAll = Object.values(type_counts).reduce(
(acc, v) => acc + v,
0,
);
setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode);
setChannelCount(total);
@@ -367,7 +397,14 @@ export const useChannelsData = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(page, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
await searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
page,
pageSize,
idSort,
);
}
};
@@ -455,7 +492,14 @@ export const useChannelsData = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
page,
pageSize,
idSort,
);
}
};
@@ -471,7 +515,14 @@ export const useChannelsData = () => {
showError(reason);
});
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
searchChannels(
enableTagMode,
activeTypeKey,
statusFilter,
1,
size,
idSort,
);
}
};
@@ -502,7 +553,10 @@ export const useChannelsData = () => {
showError(res?.data?.message || t('渠道复制失败'));
}
} catch (error) {
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
showError(
t('渠道复制失败: ') +
(error?.response?.data?.message || error?.message || error),
);
}
};
@@ -541,7 +595,11 @@ export const useChannelsData = () => {
data.priority = parseInt(data.priority);
break;
case 'weight':
if (data.weight === undefined || data.weight < 0 || data.weight === '') {
if (
data.weight === undefined ||
data.weight < 0 ||
data.weight === ''
) {
showInfo('权重必须是非负整数!');
return;
}
@@ -684,7 +742,11 @@ export const useChannelsData = () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
if (success) {
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
showSuccess(
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
.replace('${success}', data.success)
.replace('${fails}', data.fails),
);
await refresh();
} else {
showError(message);
@@ -701,7 +763,7 @@ export const useChannelsData = () => {
}
// 添加到正在测试的模型集合
setTestingModels(prev => new Set([...prev, model]));
setTestingModels((prev) => new Set([...prev, model]));
try {
let url = `/api/channel/test/${record.id}?model=${model}`;
@@ -718,14 +780,14 @@ export const useChannelsData = () => {
const { success, message, time } = res.data;
// 更新测试结果
setModelTestResults(prev => ({
setModelTestResults((prev) => ({
...prev,
[testKey]: {
success,
message,
time: time || 0,
timestamp: Date.now()
}
timestamp: Date.now(),
},
}));
if (success) {
@@ -743,7 +805,9 @@ export const useChannelsData = () => {
);
} else {
showInfo(
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
t(
'通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。',
)
.replace('${name}', record.name)
.replace('${model}', model)
.replace('${time.toFixed(2)}', time.toFixed(2)),
@@ -755,19 +819,19 @@ export const useChannelsData = () => {
} catch (error) {
// 处理网络错误
const testKey = `${record.id}-${model}`;
setModelTestResults(prev => ({
setModelTestResults((prev) => ({
...prev,
[testKey]: {
success: false,
message: error.message || t('网络错误'),
time: 0,
timestamp: Date.now()
}
timestamp: Date.now(),
},
}));
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
} finally {
// 从正在测试的模型集合中移除
setTestingModels(prev => {
setTestingModels((prev) => {
const newSet = new Set(prev);
newSet.delete(model);
return newSet;
@@ -782,8 +846,10 @@ export const useChannelsData = () => {
return;
}
const models = currentTestChannel.models.split(',').filter(model =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
const models = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
if (models.length === 0) {
@@ -795,9 +861,9 @@ export const useChannelsData = () => {
shouldStopBatchTestingRef.current = false; // 重置停止标志
// 清空该渠道之前的测试结果
setModelTestResults(prev => {
setModelTestResults((prev) => {
const newResults = { ...prev };
models.forEach(model => {
models.forEach((model) => {
const testKey = `${currentTestChannel.id}-${model}`;
delete newResults[testKey];
});
@@ -805,7 +871,12 @@ export const useChannelsData = () => {
});
try {
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
showInfo(
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
'${count}',
models.length,
),
);
// 提高并发数量以加快测试速度,参考旧版的并发限制
const concurrencyLimit = 5;
@@ -819,13 +890,16 @@ export const useChannelsData = () => {
}
const batch = models.slice(i, i + concurrencyLimit);
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
showInfo(
t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
.replace('${current}', i + 1)
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
.replace('${total}', models.length)
.replace('${total}', models.length),
);
const batchPromises = batch.map(model => testChannel(currentTestChannel, model, selectedEndpointType));
const batchPromises = batch.map((model) =>
testChannel(currentTestChannel, model, selectedEndpointType),
);
const batchResults = await Promise.allSettled(batchPromises);
results.push(...batchResults);
@@ -837,20 +911,20 @@ export const useChannelsData = () => {
// 短暂延迟避免过于频繁的请求
if (i + concurrencyLimit < models.length) {
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (!shouldStopBatchTestingRef.current) {
// 等待一小段时间确保所有结果都已更新
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// 使用当前状态重新计算结果统计
setModelTestResults(currentResults => {
setModelTestResults((currentResults) => {
let successCount = 0;
let failCount = 0;
models.forEach(model => {
models.forEach((model) => {
const testKey = `${currentTestChannel.id}-${model}`;
const result = currentResults[testKey];
if (result && result.success) {
@@ -862,10 +936,11 @@ export const useChannelsData = () => {
// 显示完成消息
setTimeout(() => {
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
showSuccess(
t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
.replace('${success}', successCount)
.replace('${fail}', failCount)
.replace('${total}', models.length)
.replace('${total}', models.length),
);
}, 100);

View File

@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
() => statusState?.status?.usd_exchange_rate ?? priceRate,
[statusState, priceRate],
);
const customExchangeRate = useMemo(
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
[statusState],
);
const customCurrencySymbol = useMemo(
() => statusState?.status?.custom_currency_symbol ?? '¤',
[statusState],
);
// 默认货币与站点展示类型同步USD/CNYTOKENS 时仍允许切换视图内货币
const siteDisplayType = useMemo(
() => statusState?.status?.quota_display_type || 'USD',
[statusState],
);
useEffect(() => {
if (
siteDisplayType === 'USD' ||
siteDisplayType === 'CNY' ||
siteDisplayType === 'CUSTOM'
) {
setCurrency(siteDisplayType);
}
}, [siteDisplayType]);
const filteredModels = useMemo(() => {
let result = models;
@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
if (currency === 'CNY') {
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
} else if (currency === 'CUSTOM') {
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
}
return `$${priceInUSD.toFixed(3)}`;
};

View File

@@ -1810,7 +1810,10 @@
"自定义模型名称": "Custom model name",
"启用全部密钥": "Enable all keys",
"充值价格显示": "Recharge price",
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)",
"自定义货币": "Custom currency",
"自定义货币符号": "Custom currency symbol",
"例如 €, £, Rp, ₩, ₹...": "For example, €, £, Rp, ₩, ₹...",
"站点额度展示类型及汇率": "Site quota display type and exchange rate",
"美元汇率": "USD exchange rate",
"隐藏操作项": "Hide actions",
"显示操作项": "Show actions",

View File

@@ -1806,7 +1806,10 @@
"自定义模型名称": "Nom de modèle personnalisé",
"启用全部密钥": "Activer toutes les clés",
"充值价格显示": "Prix de recharge",
"美元汇率(非充值汇率,仅用于定价页面换算)": "Taux de change USD (pas de taux de recharge, uniquement utilisé pour la conversion de la page de tarification)",
"站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change",
"自定义货币": "Devise personnalisée",
"自定义货币符号": "Symbole de devise personnalisé",
"例如 €, £, Rp, ₩, ₹...": "Par exemple, €, £, Rp, ₩, ₹...",
"美元汇率": "Taux de change USD",
"隐藏操作项": "Masquer les actions",
"显示操作项": "Afficher les actions",

View File

@@ -17,8 +17,19 @@ 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 { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import {
Banner,
Button,
Col,
Form,
Row,
Spin,
Modal,
Select,
InputGroup,
Input,
} from '@douyinfe/semi-ui';
import {
compareObjects,
API,
@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
const [inputs, setInputs] = useState({
TopUpLink: '',
'general_setting.docs_link': '',
'general_setting.quota_display_type': 'USD',
'general_setting.custom_currency_symbol': '¤',
'general_setting.custom_currency_exchange_rate': '',
QuotaPerUnit: '',
RetryTimes: '',
USDExchangeRate: '',
DisplayInCurrencyEnabled: false,
DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
@@ -88,6 +101,30 @@ export default function GeneralSettings(props) {
});
}
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
const combinedRate = useMemo(() => {
const type = inputs['general_setting.quota_display_type'];
if (type === 'USD') return '1';
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
if (type === 'CUSTOM')
return String(
inputs['general_setting.custom_currency_exchange_rate'] || '',
);
return '';
}, [inputs]);
const onCombinedRateChange = (val) => {
const type = inputs['general_setting.quota_display_type'];
if (type === 'CNY') {
handleFieldChange('USDExchangeRate')(val);
} else if (type === 'TOKENS') {
handleFieldChange('QuotaPerUnit')(val);
} else if (type === 'CUSTOM') {
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
}
};
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
currentInputs[key] = props.options[key];
}
}
// 若旧字段存在且新字段缺失,则做一次兜底映射
if (
currentInputs['general_setting.quota_display_type'] === undefined &&
props.options?.DisplayInCurrencyEnabled !== undefined
) {
currentInputs['general_setting.quota_display_type'] = props.options
.DisplayInCurrencyEnabled
? 'USD'
: 'TOKENS';
}
// 回填自定义货币相关字段(如果后端已存在)
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
currentInputs['general_setting.custom_currency_symbol'] =
props.options['general_setting.custom_currency_symbol'];
}
if (
props.options['general_setting.custom_currency_exchange_rate'] !==
undefined
) {
currentInputs['general_setting.custom_currency_exchange_rate'] =
props.options['general_setting.custom_currency_exchange_rate'];
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
@@ -130,30 +189,7 @@ export default function GeneralSettings(props) {
showClear
/>
</Col>
{inputs.QuotaPerUnit !== '500000' &&
inputs.QuotaPerUnit !== 500000 && (
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'QuotaPerUnit'}
label={t('单位美元额度')}
initValue={''}
placeholder={t('一单位货币能兑换的额度')}
onChange={handleFieldChange('QuotaPerUnit')}
showClear
onClick={() => setShowQuotaWarning(true)}
/>
</Col>
)}
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'USDExchangeRate'}
label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
initValue={''}
placeholder={t('美元汇率')}
onChange={handleFieldChange('USDExchangeRate')}
showClear
/>
</Col>
{/* 单位美元额度已合入汇率组合控件TOKENS 模式下编辑),不再单独展示 */}
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'RetryTimes'}
@@ -164,18 +200,51 @@ export default function GeneralSettings(props) {
showClear
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={'DisplayInCurrencyEnabled'}
label={t('以货币形式显示额度')}
size='default'
checkedText=''
uncheckedText=''
onChange={handleFieldChange('DisplayInCurrencyEnabled')}
<Form.Slot label={t('站点额度展示类型及汇率')}>
<InputGroup style={{ width: '100%' }}>
<Input
prefix={'1 USD = '}
style={{ width: '50%' }}
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',
)}
>
<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}>
<Form.Input
field={'general_setting.custom_currency_symbol'}
label={t('自定义货币符号')}
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
onChange={handleFieldChange(
'general_setting.custom_currency_symbol',
)}
showClear
disabled={
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={'DisplayTokenStatEnabled'}
@@ -196,8 +265,6 @@ export default function GeneralSettings(props) {
onChange={handleFieldChange('DefaultCollapseSidebar')}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={'DemoSiteEnabled'}