Commit 29581142 by Calcium-Ion Committed by GitHub

Merge pull request #1957 from seefs001/pr/custom-currency-1923

💱 feat(settings): introduce site-wide quota display type
parents 99424878 b65e2719
...@@ -19,6 +19,7 @@ var TopUpLink = "" ...@@ -19,6 +19,7 @@ var TopUpLink = ""
// var ChatLink = "" // var ChatLink = ""
// var ChatLink2 = "" // var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
var DisplayInCurrencyEnabled = true var DisplayInCurrencyEnabled = true
var DisplayTokenStatEnabled = true var DisplayTokenStatEnabled = true
var DrawingEnabled = true var DrawingEnabled = true
......
...@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries ...@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false var UsingMySQL = false
var UsingClickHouse = false var UsingClickHouse = false
var SQLitePath = "one-api.db?_busy_timeout=30000" var SQLitePath = "one-api.db?_busy_timeout=30000"
\ No newline at end of file
...@@ -31,7 +31,7 @@ const ( ...@@ -31,7 +31,7 @@ const (
APITypeXai APITypeXai
APITypeCoze APITypeCoze
APITypeJimeng APITypeJimeng
APITypeMoonshot APITypeMoonshot
APITypeSubmodel APITypeSubmodel
APITypeDummy // this one is only for count, do not add any channel after this APITypeDummy // this one is only for count, do not add any channel after this
) )
...@@ -5,6 +5,7 @@ import ( ...@@ -5,6 +5,7 @@ import (
"one-api/common" "one-api/common"
"one-api/dto" "one-api/dto"
"one-api/model" "one-api/model"
"one-api/setting/operation_setting"
) )
func GetSubscription(c *gin.Context) { func GetSubscription(c *gin.Context) {
...@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) { ...@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
} }
quota := remainQuota + usedQuota quota := remainQuota + usedQuota
amount := float64(quota) amount := float64(quota)
if common.DisplayInCurrencyEnabled { // OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
amount /= common.QuotaPerUnit // 我们将其解释为以“站点展示类型”为准:
// - 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 { if token != nil && token.UnlimitedQuota {
amount = 100000000 amount = 100000000
...@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) { ...@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
return return
} }
amount := float64(quota) amount := float64(quota)
if common.DisplayInCurrencyEnabled { switch operation_setting.GetQuotaDisplayType() {
amount /= common.QuotaPerUnit case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// tokens 保持原值
default:
amount = amount / common.QuotaPerUnit
} }
usage := OpenAIUsageResponse{ usage := OpenAIUsageResponse{
Object: "list", Object: "list",
......
...@@ -66,18 +66,22 @@ func GetStatus(c *gin.Context) { ...@@ -66,18 +66,22 @@ func GetStatus(c *gin.Context) {
"top_up_link": common.TopUpLink, "top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink, "docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit, "quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled, // 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
"enable_batch_update": common.BatchUpdateEnabled, "display_in_currency": operation_setting.IsCurrencyDisplay(),
"enable_drawing": common.DrawingEnabled, "quota_display_type": operation_setting.GetQuotaDisplayType(),
"enable_task": common.TaskEnabled, "custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
"enable_data_export": common.DataExportEnabled, "custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
"data_export_default_time": common.DataExportDefaultTime, "enable_batch_update": common.BatchUpdateEnabled,
"default_collapse_sidebar": common.DefaultCollapseSidebar, "enable_drawing": common.DrawingEnabled,
"mj_notify_enabled": setting.MjNotifyEnabled, "enable_task": common.TaskEnabled,
"chats": setting.Chats, "enable_data_export": common.DataExportEnabled,
"demo_site_enabled": operation_setting.DemoSiteEnabled, "data_export_default_time": common.DataExportDefaultTime,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "default_collapse_sidebar": common.DefaultCollapseSidebar,
"default_use_auto_group": setting.DefaultUseAutoGroup, "mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"usd_exchange_rate": operation_setting.USDExchangeRate, "usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price, "price": operation_setting.Price,
......
...@@ -178,4 +178,4 @@ func boolToString(b bool) string { ...@@ -178,4 +178,4 @@ func boolToString(b bool) string {
return "true" return "true"
} }
return "false" return "false"
} }
\ No newline at end of file
...@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client { ...@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
func getPayMoney(amount int64, group string) float64 { func getPayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount) 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) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
dAmount = dAmount.Div(dQuotaPerUnit) dAmount = dAmount.Div(dQuotaPerUnit)
} }
...@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 { ...@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
func getMinTopup() int64 { func getMinTopup() int64 {
minTopup := operation_setting.MinTopUp minTopup := operation_setting.MinTopUp
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup)) dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
...@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) { ...@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) {
return return
} }
amount := req.Amount amount := req.Amount
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(int64(amount)) dAmount := decimal.NewFromInt(int64(amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart() amount = dAmount.Div(dQuotaPerUnit).IntPart()
......
...@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 { ...@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
func getStripePayMoney(amount float64, group string) float64 { func getStripePayMoney(amount float64, group string) float64 {
originalAmount := amount originalAmount := amount
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit amount = amount / common.QuotaPerUnit
} }
// Using float64 for monetary calculations is acceptable here due to the small amounts involved // 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 { ...@@ -279,7 +279,7 @@ func getStripePayMoney(amount float64, group string) float64 {
func getStripeMinTopup() int64 { func getStripeMinTopup() int64 {
minTopup := setting.StripeMinTopUp minTopup := setting.StripeMinTopUp
if !common.DisplayInCurrencyEnabled { if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit) minTopup = minTopup * int(common.QuotaPerUnit)
} }
return int64(minTopup) return int64(minTopup)
......
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"io" "io"
"log" "log"
"one-api/common" "one-api/common"
"one-api/setting/operation_setting"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
...@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) { ...@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
} }
func LogQuota(quota int) string { func LogQuota(quota int) string {
if common.DisplayInCurrencyEnabled { // 新逻辑:根据额度展示类型输出
return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit) q := float64(quota)
} else { 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) return fmt.Sprintf("%d 点额度", quota)
default: // USD
return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit)
} }
} }
func FormatQuota(quota int) string { func FormatQuota(quota int) string {
if common.DisplayInCurrencyEnabled { q := float64(quota)
return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit) switch operation_setting.GetQuotaDisplayType() {
} else { 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) return fmt.Sprintf("%d", quota)
default:
return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit)
} }
} }
......
...@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) { ...@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
case "LogConsumeEnabled": case "LogConsumeEnabled":
common.LogConsumeEnabled = boolValue common.LogConsumeEnabled = boolValue
case "DisplayInCurrencyEnabled": 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": case "DisplayTokenStatEnabled":
common.DisplayTokenStatEnabled = boolValue common.DisplayTokenStatEnabled = boolValue
case "DrawingEnabled": case "DrawingEnabled":
......
...@@ -18,7 +18,9 @@ import ( ...@@ -18,7 +18,9 @@ import (
type Adaptor struct { 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) { func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{} openaiAdaptor := openai.Adaptor{}
...@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest)) 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) Init(info *relaycommon.RelayInfo) {
} }
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil } if info.RelayMode == relayconstant.RelayModeEmbeddings {
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil } return info.ChannelBaseUrl + "/api/embed", nil
return info.ChannelBaseUrl + "/api/chat", nil }
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return info.ChannelBaseUrl + "/api/generate", nil
}
return info.ChannelBaseUrl + "/api/chat", nil
} }
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
...@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel ...@@ -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) { 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 // decide generate or chat
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return openAIToGenerate(c, request) return openAIToGenerate(c, request)
...@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela ...@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return requestOpenAI2Embeddings(request), nil 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) { func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody) return channel.DoApiRequest(a, c, info, requestBody)
......
...@@ -5,12 +5,12 @@ import ( ...@@ -5,12 +5,12 @@ import (
) )
type OllamaChatMessage struct { type OllamaChatMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"` ToolName string `json:"tool_name,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"` Thinking json.RawMessage `json:"thinking,omitempty"`
} }
type OllamaToolFunction struct { type OllamaToolFunction struct {
...@@ -20,7 +20,7 @@ type OllamaToolFunction struct { ...@@ -20,7 +20,7 @@ type OllamaToolFunction struct {
} }
type OllamaTool struct { type OllamaTool struct {
Type string `json:"type"` Type string `json:"type"`
Function OllamaToolFunction `json:"function"` Function OllamaToolFunction `json:"function"`
} }
...@@ -43,28 +43,27 @@ type OllamaChatRequest struct { ...@@ -43,28 +43,27 @@ type OllamaChatRequest struct {
} }
type OllamaGenerateRequest struct { type OllamaGenerateRequest struct {
Model string `json:"model"` Model string `json:"model"`
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
Suffix string `json:"suffix,omitempty"` Suffix string `json:"suffix,omitempty"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
Format interface{} `json:"format,omitempty"` Format interface{} `json:"format,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Options map[string]any `json:"options,omitempty"` Options map[string]any `json:"options,omitempty"`
KeepAlive interface{} `json:"keep_alive,omitempty"` KeepAlive interface{} `json:"keep_alive,omitempty"`
Think json.RawMessage `json:"think,omitempty"` Think json.RawMessage `json:"think,omitempty"`
} }
type OllamaEmbeddingRequest struct { type OllamaEmbeddingRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input interface{} `json:"input"` Input interface{} `json:"input"`
Options map[string]any `json:"options,omitempty"` Options map[string]any `json:"options,omitempty"`
Dimensions int `json:"dimensions,omitempty"` Dimensions int `json:"dimensions,omitempty"`
} }
type OllamaEmbeddingResponse struct { type OllamaEmbeddingResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Model string `json:"model"` Model string `json:"model"`
Embeddings [][]float64 `json:"embeddings"` Embeddings [][]float64 `json:"embeddings"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"` PromptEvalCount int `json:"prompt_eval_count,omitempty"`
} }
...@@ -13,4 +13,4 @@ var ModelList = []string{ ...@@ -13,4 +13,4 @@ var ModelList = []string{
"deepseek-ai/DeepSeek-V3.1", "deepseek-ai/DeepSeek-V3.1",
} }
const ChannelName = "submodel" const ChannelName = "submodel"
\ No newline at end of file
...@@ -2,17 +2,34 @@ package operation_setting ...@@ -2,17 +2,34 @@ package operation_setting
import "one-api/setting/config" import "one-api/setting/config"
// 额度展示类型
const (
QuotaDisplayTypeUSD = "USD"
QuotaDisplayTypeCNY = "CNY"
QuotaDisplayTypeTokens = "TOKENS"
QuotaDisplayTypeCustom = "CUSTOM"
)
type GeneralSetting struct { type GeneralSetting struct {
DocsLink string `json:"docs_link"` DocsLink string `json:"docs_link"`
PingIntervalEnabled bool `json:"ping_interval_enabled"` PingIntervalEnabled bool `json:"ping_interval_enabled"`
PingIntervalSeconds int `json:"ping_interval_seconds"` 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"`
} }
// 默认配置 // 默认配置
var generalSetting = GeneralSetting{ var generalSetting = GeneralSetting{
DocsLink: "https://docs.newapi.pro", DocsLink: "https://docs.newapi.pro",
PingIntervalEnabled: false, PingIntervalEnabled: false,
PingIntervalSeconds: 60, PingIntervalSeconds: 60,
QuotaDisplayType: QuotaDisplayTypeUSD,
CustomCurrencySymbol: "¤",
CustomCurrencyExchangeRate: 1.0,
} }
func init() { func init() {
...@@ -23,3 +40,52 @@ func init() { ...@@ -23,3 +40,52 @@ func init() {
func GetGeneralSetting() *GeneralSetting { func GetGeneralSetting() *GeneralSetting {
return &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> 的 X(TOKENS 不适用)
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
}
}
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
/> />
<title>New API</title> <title>New API</title>
<analytics></analytics> <analytics></analytics>
</head> </head>
<body> <body>
......
...@@ -42,7 +42,7 @@ const OperationSetting = () => { ...@@ -42,7 +42,7 @@ const OperationSetting = () => {
QuotaPerUnit: 0, QuotaPerUnit: 0,
USDExchangeRate: 0, USDExchangeRate: 0,
RetryTimes: 0, RetryTimes: 0,
DisplayInCurrencyEnabled: false, 'general_setting.quota_display_type': 'USD',
DisplayTokenStatEnabled: false, DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false, DefaultCollapseSidebar: false,
DemoSiteEnabled: false, DemoSiteEnabled: false,
......
...@@ -91,7 +91,8 @@ const AccountManagement = ({ ...@@ -91,7 +91,8 @@ const AccountManagement = ({
); );
}; };
const isBound = (accountId) => Boolean(accountId); const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); const [showTelegramBindModal, setShowTelegramBindModal] =
React.useState(false);
const passkeyEnabled = passkeyStatus?.enabled; const passkeyEnabled = passkeyStatus?.enabled;
const lastUsedLabel = passkeyStatus?.last_used_at const lastUsedLabel = passkeyStatus?.last_used_at
? new Date(passkeyStatus.last_used_at).toLocaleString() ? new Date(passkeyStatus.last_used_at).toLocaleString()
...@@ -236,7 +237,8 @@ const AccountManagement = ({ ...@@ -236,7 +237,8 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id) onGitHubOAuthClicked(status.github_client_id)
} }
disabled={ disabled={
isBound(userState.user?.github_id) || !status.github_oauth isBound(userState.user?.github_id) ||
!status.github_oauth
} }
> >
{status.github_oauth ? t('绑定') : t('未启用')} {status.github_oauth ? t('绑定') : t('未启用')}
...@@ -394,7 +396,8 @@ const AccountManagement = ({ ...@@ -394,7 +396,8 @@ const AccountManagement = ({
onLinuxDOOAuthClicked(status.linuxdo_client_id) onLinuxDOOAuthClicked(status.linuxdo_client_id)
} }
disabled={ disabled={
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth isBound(userState.user?.linux_do_id) ||
!status.linuxdo_oauth
} }
> >
{status.linuxdo_oauth ? t('绑定') : t('未启用')} {status.linuxdo_oauth ? t('绑定') : t('未启用')}
......
...@@ -91,8 +91,7 @@ const REGION_EXAMPLE = { ...@@ -91,8 +91,7 @@ const REGION_EXAMPLE = {
// 支持并且已适配通过接口获取模型列表的渠道类型 // 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([ const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
43,
]); ]);
function type2secretPrompt(type) { function type2secretPrompt(type) {
...@@ -408,7 +407,10 @@ const EditChannelModal = (props) => { ...@@ -408,7 +407,10 @@ const EditChannelModal = (props) => {
break; break;
case 45: case 45:
localModels = getChannelModels(value); 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; break;
default: default:
localModels = getChannelModels(value); localModels = getChannelModels(value);
...@@ -502,7 +504,8 @@ const EditChannelModal = (props) => { ...@@ -502,7 +504,8 @@ const EditChannelModal = (props) => {
// 读取 Vertex 密钥格式 // 读取 Vertex 密钥格式
data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; 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.allow_service_tier = parsedSettings.allow_service_tier || false;
data.disable_store = parsedSettings.disable_store || false; data.disable_store = parsedSettings.disable_store || false;
...@@ -929,7 +932,10 @@ const EditChannelModal = (props) => { ...@@ -929,7 +932,10 @@ const EditChannelModal = (props) => {
showInfo(t('请至少选择一个模型!')); showInfo(t('请至少选择一个模型!'));
return; 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地址!')); showInfo(t('请输入API地址!'));
return; return;
} }
...@@ -974,7 +980,8 @@ const EditChannelModal = (props) => { ...@@ -974,7 +980,8 @@ const EditChannelModal = (props) => {
// type === 20: 设置企业账户标识,无论是true还是false都要传到后端 // type === 20: 设置企业账户标识,无论是true还是false都要传到后端
if (localInputs.type === 20) { 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): 设置字段透传控制(显式保存布尔值) // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
...@@ -1433,7 +1440,9 @@ const EditChannelModal = (props) => { ...@@ -1433,7 +1440,9 @@ const EditChannelModal = (props) => {
setIsEnterpriseAccount(value); setIsEnterpriseAccount(value);
handleInputChange('is_enterprise_account', value); handleInputChange('is_enterprise_account', value);
}} }}
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')} extraText={t(
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
)}
initValue={inputs.is_enterprise_account} initValue={inputs.is_enterprise_account}
/> />
)} )}
...@@ -2061,27 +2070,27 @@ const EditChannelModal = (props) => { ...@@ -2061,27 +2070,27 @@ const EditChannelModal = (props) => {
)} )}
{inputs.type === 45 && ( {inputs.type === 45 && (
<div> <div>
<Form.Select <Form.Select
field='base_url' field='base_url'
label={t('API地址')} label={t('API地址')}
placeholder={t('请选择API地址')} placeholder={t('请选择API地址')}
onChange={(value) => onChange={(value) =>
handleInputChange('base_url', value) handleInputChange('base_url', value)
} }
optionList={[ optionList={[
{ {
value: 'https://ark.cn-beijing.volces.com', 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', 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' defaultValue='https://ark.cn-beijing.volces.com'
/> />
</div> </div>
)} )}
</Card> </Card>
</div> </div>
......
...@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({ ...@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
const currencyItems = [ const currencyItems = [
{ value: 'USD', label: 'USD ($)' }, { value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' }, { value: 'CNY', label: 'CNY (¥)' },
{ value: 'CUSTOM', label: t('自定义货币') },
]; ];
const handleChange = (value) => { const handleChange = (value) => {
......
...@@ -107,6 +107,7 @@ const SearchActions = memo( ...@@ -107,6 +107,7 @@ const SearchActions = memo(
optionList={[ optionList={[
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' }, { value: 'CNY', label: 'CNY' },
{ value: 'CUSTOM', label: t('自定义货币') },
]} ]}
/> />
)} )}
......
...@@ -60,38 +60,54 @@ const ContentModal = ({ ...@@ -60,38 +60,54 @@ const ContentModal = ({
if (videoError) { if (videoError) {
return ( return (
<div style={{ textAlign: 'center', padding: '40px' }}> <div style={{ textAlign: 'center', padding: '40px' }}>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px' }}
>
视频无法在当前浏览器中播放,这可能是由于: 视频无法在当前浏览器中播放,这可能是由于:
</Text> </Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
• 视频服务商的跨域限制 • 视频服务商的跨域限制
</Text> </Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
• 需要特定的请求头或认证 • 需要特定的请求头或认证
</Text> </Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}> <Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
>
• 防盗链保护机制 • 防盗链保护机制
</Text> </Text>
<div style={{ marginTop: '20px' }}> <div style={{ marginTop: '20px' }}>
<Button <Button
icon={<IconExternalOpen />} icon={<IconExternalOpen />}
onClick={handleOpenInNewTab} onClick={handleOpenInNewTab}
style={{ marginRight: '8px' }} style={{ marginRight: '8px' }}
> >
在新标签页中打开 在新标签页中打开
</Button> </Button>
<Button <Button icon={<IconCopy />} onClick={handleCopyUrl}>
icon={<IconCopy />}
onClick={handleCopyUrl}
>
复制链接 复制链接
</Button> </Button>
</div> </div>
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}> <div
<Text style={{
type="tertiary" marginTop: '16px',
padding: '8px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
}}
>
<Text
type='tertiary'
style={{ fontSize: '10px', wordBreak: 'break-all' }} style={{ fontSize: '10px', wordBreak: 'break-all' }}
> >
{modalContent} {modalContent}
...@@ -104,22 +120,24 @@ const ContentModal = ({ ...@@ -104,22 +120,24 @@ const ContentModal = ({
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{isLoading && ( {isLoading && (
<div style={{ <div
position: 'absolute', style={{
top: '50%', position: 'absolute',
left: '50%', top: '50%',
transform: 'translate(-50%, -50%)', left: '50%',
zIndex: 10 transform: 'translate(-50%, -50%)',
}}> zIndex: 10,
<Spin size="large" /> }}
>
<Spin size='large' />
</div> </div>
)} )}
<video <video
src={modalContent} src={modalContent}
controls controls
style={{ width: '100%' }} style={{ width: '100%' }}
autoPlay autoPlay
crossOrigin="anonymous" crossOrigin='anonymous'
onError={handleVideoError} onError={handleVideoError}
onLoadedData={handleVideoLoaded} onLoadedData={handleVideoLoaded}
onLoadStart={() => setIsLoading(true)} onLoadStart={() => setIsLoading(true)}
...@@ -134,10 +152,10 @@ const ContentModal = ({ ...@@ -134,10 +152,10 @@ const ContentModal = ({
onOk={() => setIsModalOpen(false)} onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)} onCancel={() => setIsModalOpen(false)}
closable={null} closable={null}
bodyStyle={{ bodyStyle={{
height: isVideo ? '450px' : '400px', height: isVideo ? '450px' : '400px',
overflow: 'auto', overflow: 'auto',
padding: isVideo && videoError ? '0' : '24px' padding: isVideo && videoError ? '0' : '24px',
}} }}
width={800} width={800}
> >
......
...@@ -23,7 +23,9 @@ export function setStatusData(data) { ...@@ -23,7 +23,9 @@ export function setStatusData(data) {
localStorage.setItem('logo', data.logo); localStorage.setItem('logo', data.logo);
localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('quota_per_unit', data.quota_per_unit); localStorage.setItem('quota_per_unit', data.quota_per_unit);
// 兼容:保留旧字段,同时写入新的额度展示类型
localStorage.setItem('display_in_currency', data.display_in_currency); 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_drawing', data.enable_drawing);
localStorage.setItem('enable_task', data.enable_task); localStorage.setItem('enable_task', data.enable_task);
localStorage.setItem('enable_data_export', data.enable_data_export); localStorage.setItem('enable_data_export', data.enable_data_export);
......
...@@ -832,12 +832,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) { ...@@ -832,12 +832,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
if (typeof num !== 'number' || isNaN(num)) { if (typeof num !== 'number' || isNaN(num)) {
return 0; return 0;
} }
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
num = num.toFixed(digits); num = num.toFixed(digits);
if (displayInCurrency) { if (quotaDisplayType === 'CNY') {
return '¥' + num;
} else if (quotaDisplayType === 'USD') {
return '$' + num; 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;
} }
return num;
} }
export function renderNumberWithPoint(num) { export function renderNumberWithPoint(num) {
...@@ -889,33 +902,67 @@ export function getQuotaWithUnit(quota, digits = 6) { ...@@ -889,33 +902,67 @@ export function getQuotaWithUnit(quota, digits = 6) {
} }
export function renderQuotaWithAmount(amount) { export function renderQuotaWithAmount(amount) {
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
displayInCurrency = displayInCurrency === 'true'; if (quotaDisplayType === 'TOKENS') {
if (displayInCurrency) {
return '$' + amount;
} else {
return renderNumber(renderUnitWithQuota(amount)); 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;
} }
export function renderQuota(quota, digits = 2) { export function renderQuota(quota, digits = 2) {
let quotaPerUnit = localStorage.getItem('quota_per_unit'); 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); quotaPerUnit = parseFloat(quotaPerUnit);
displayInCurrency = displayInCurrency === 'true'; if (quotaDisplayType === 'TOKENS') {
if (displayInCurrency) { return renderNumber(quota);
const result = quota / quotaPerUnit; }
const fixedResult = result.toFixed(digits); const resultUSD = quota / quotaPerUnit;
let symbol = '$';
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值 let value = resultUSD;
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) { if (quotaDisplayType === 'CNY') {
const minValue = Math.pow(10, -digits); const statusStr = localStorage.getItem('status');
return '$' + minValue.toFixed(digits); let usdRate = 1;
} try {
if (statusStr) {
return '$' + fixedResult; 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 renderNumber(quota); return symbol + fixedResult;
} }
function isValidGroupRatio(ratio) { function isValidGroupRatio(ratio) {
...@@ -1512,9 +1559,8 @@ export function renderAudioModelPrice( ...@@ -1512,9 +1559,8 @@ export function renderAudioModelPrice(
} }
export function renderQuotaWithPrompt(quota, digits) { export function renderQuotaWithPrompt(quota, digits) {
let displayInCurrency = localStorage.getItem('display_in_currency'); const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
displayInCurrency = displayInCurrency === 'true'; if (quotaDisplayType !== 'TOKENS') {
if (displayInCurrency) {
return i18next.t('等价金额:') + renderQuota(quota, digits); return i18next.t('等价金额:') + renderQuota(quota, digits);
} }
return ''; return '';
......
...@@ -646,9 +646,25 @@ export const calculateModelPrice = ({ ...@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
const numCompletion = const numCompletion =
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor; 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 { return {
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`, inputPrice: `${symbol}${numInput.toFixed(precision)}`,
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`, completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
unitLabel, unitLabel,
isPerToken: true, isPerToken: true,
usedGroup, usedGroup,
......
...@@ -64,6 +64,29 @@ export const useModelPricingData = () => { ...@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
() => statusState?.status?.usd_exchange_rate ?? priceRate, () => statusState?.status?.usd_exchange_rate ?? priceRate,
[statusState, priceRate], [statusState, priceRate],
); );
const customExchangeRate = useMemo(
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
[statusState],
);
const customCurrencySymbol = useMemo(
() => statusState?.status?.custom_currency_symbol ?? '¤',
[statusState],
);
// 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
const siteDisplayType = useMemo(
() => statusState?.status?.quota_display_type || 'USD',
[statusState],
);
useEffect(() => {
if (
siteDisplayType === 'USD' ||
siteDisplayType === 'CNY' ||
siteDisplayType === 'CUSTOM'
) {
setCurrency(siteDisplayType);
}
}, [siteDisplayType]);
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
let result = models; let result = models;
...@@ -156,6 +179,8 @@ export const useModelPricingData = () => { ...@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
if (currency === 'CNY') { if (currency === 'CNY') {
return ${(priceInUSD * usdExchangeRate).toFixed(3)}`; return ${(priceInUSD * usdExchangeRate).toFixed(3)}`;
} else if (currency === 'CUSTOM') {
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
} }
return `$${priceInUSD.toFixed(3)}`; return `$${priceInUSD.toFixed(3)}`;
}; };
......
...@@ -1810,7 +1810,10 @@ ...@@ -1810,7 +1810,10 @@
"自定义模型名称": "Custom model name", "自定义模型名称": "Custom model name",
"启用全部密钥": "Enable all keys", "启用全部密钥": "Enable all keys",
"充值价格显示": "Recharge price", "充值价格显示": "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", "美元汇率": "USD exchange rate",
"隐藏操作项": "Hide actions", "隐藏操作项": "Hide actions",
"显示操作项": "Show actions", "显示操作项": "Show actions",
......
...@@ -1806,7 +1806,10 @@ ...@@ -1806,7 +1806,10 @@
"自定义模型名称": "Nom de modèle personnalisé", "自定义模型名称": "Nom de modèle personnalisé",
"启用全部密钥": "Activer toutes les clés", "启用全部密钥": "Activer toutes les clés",
"充值价格显示": "Prix de recharge", "充值价格显示": "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", "美元汇率": "Taux de change USD",
"隐藏操作项": "Masquer les actions", "隐藏操作项": "Masquer les actions",
"显示操作项": "Afficher les actions", "显示操作项": "Afficher les actions",
...@@ -2236,4 +2239,4 @@ ...@@ -2236,4 +2239,4 @@
"重置 2FA": "Réinitialiser 2FA", "重置 2FA": "Réinitialiser 2FA",
"重置 Passkey": "Réinitialiser le Passkey", "重置 Passkey": "Réinitialiser le Passkey",
"默认使用系统名称": "Le nom du système est utilisé par défaut" "默认使用系统名称": "Le nom du système est utilisé par défaut"
} }
\ No newline at end of file
...@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef, useMemo } from 'react';
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui'; import {
Banner,
Button,
Col,
Form,
Row,
Spin,
Modal,
Select,
InputGroup,
Input,
} from '@douyinfe/semi-ui';
import { import {
compareObjects, compareObjects,
API, API,
...@@ -35,10 +46,12 @@ export default function GeneralSettings(props) { ...@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
TopUpLink: '', TopUpLink: '',
'general_setting.docs_link': '', 'general_setting.docs_link': '',
'general_setting.quota_display_type': 'USD',
'general_setting.custom_currency_symbol': '¤',
'general_setting.custom_currency_exchange_rate': '',
QuotaPerUnit: '', QuotaPerUnit: '',
RetryTimes: '', RetryTimes: '',
USDExchangeRate: '', USDExchangeRate: '',
DisplayInCurrencyEnabled: false,
DisplayTokenStatEnabled: false, DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false, DefaultCollapseSidebar: false,
DemoSiteEnabled: false, DemoSiteEnabled: false,
...@@ -88,6 +101,30 @@ export default function GeneralSettings(props) { ...@@ -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(() => { useEffect(() => {
const currentInputs = {}; const currentInputs = {};
for (let key in props.options) { for (let key in props.options) {
...@@ -95,6 +132,28 @@ export default function GeneralSettings(props) { ...@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
currentInputs[key] = props.options[key]; 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); setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs)); setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs); refForm.current.setValues(currentInputs);
...@@ -130,30 +189,7 @@ export default function GeneralSettings(props) { ...@@ -130,30 +189,7 @@ export default function GeneralSettings(props) {
showClear showClear
/> />
</Col> </Col>
{inputs.QuotaPerUnit !== '500000' && {/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
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>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input <Form.Input
field={'RetryTimes'} field={'RetryTimes'}
...@@ -164,18 +200,51 @@ export default function GeneralSettings(props) { ...@@ -164,18 +200,51 @@ export default function GeneralSettings(props) {
showClear showClear
/> />
</Col> </Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch <Form.Slot label={t('站点额度展示类型及汇率')}>
field={'DisplayInCurrencyEnabled'} <InputGroup style={{ width: '100%' }}>
label={t('以货币形式显示额度')} <Input
size='default' prefix={'1 USD = '}
checkedText='|' style={{ width: '50%' }}
uncheckedText='〇' value={combinedRate}
onChange={handleFieldChange('DisplayInCurrencyEnabled')} 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> </Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch <Form.Switch
field={'DisplayTokenStatEnabled'} field={'DisplayTokenStatEnabled'}
...@@ -196,8 +265,6 @@ export default function GeneralSettings(props) { ...@@ -196,8 +265,6 @@ export default function GeneralSettings(props) {
onChange={handleFieldChange('DefaultCollapseSidebar')} onChange={handleFieldChange('DefaultCollapseSidebar')}
/> />
</Col> </Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch <Form.Switch
field={'DemoSiteEnabled'} field={'DemoSiteEnabled'}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment