Merge branch 'main' into feat-04

This commit is contained in:
neotf
2025-06-18 15:20:24 +08:00
committed by GitHub
101 changed files with 3667 additions and 7364 deletions
+3 -1
View File
@@ -27,6 +27,9 @@
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
<a href="https://coderabbit.ai">
<img src="https://img.shields.io/coderabbit/prs/github/QuantumNous/new-api?utm_source=oss&utm_medium=github&utm_campaign=QuantumNous%2Fnew-api&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews" alt="CodeRabbit Pull Request Reviews">
</a>
</p>
</div>
@@ -180,7 +183,6 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
其他基于New API的项目:
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能优化版
- [VoAPI](https://github.com/VoAPI/VoAPI):基于New API的前端美化版本
## 帮助支持
+7
View File
@@ -1,7 +1,14 @@
package common
const (
DatabaseTypeMySQL = "mysql"
DatabaseTypeSQLite = "sqlite"
DatabaseTypePostgreSQL = "postgres"
)
var UsingSQLite = false
var UsingPostgreSQL = false
var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
+5 -1
View File
@@ -141,7 +141,11 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
txn := RDB.TxPipeline()
txn.HSet(ctx, key, data)
txn.Expire(ctx, key, expiration)
// 只有在 expiration 大于 0 时才设置过期时间
if expiration > 0 {
txn.Expire(ctx, key, expiration)
}
_, err := txn.Exec(ctx)
if err != nil {
+27 -2
View File
@@ -249,13 +249,38 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) {
}
// GetAudioDuration returns the duration of an audio file in seconds.
func GetAudioDuration(ctx context.Context, filename string) (float64, error) {
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
return strconv.ParseFloat(string(bytes.TrimSpace(output)), 64)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
return strconv.ParseFloat(durationStr, 64)
}
+4 -6
View File
@@ -2,12 +2,10 @@ package constant
import "one-api/common"
var (
TokenCacheSeconds = common.SyncFrequency
UserId2GroupCacheSeconds = common.SyncFrequency
UserId2QuotaCacheSeconds = common.SyncFrequency
UserId2StatusCacheSeconds = common.SyncFrequency
)
// 使用函数来避免初始化顺序带来的赋值问题
func RedisKeyCacheSeconds() int {
return common.SyncFrequency
}
// Cache keys
const (
+1
View File
@@ -7,6 +7,7 @@ var (
UserSettingWebhookSecret = "webhook_secret" // WebhookSecret webhook密钥
UserSettingNotificationEmail = "notification_email" // NotificationEmail 通知邮箱地址
UserAcceptUnsetRatioModel = "accept_unset_model_ratio_model" // AcceptUnsetRatioModel 是否接受未设置价格的模型
UserSettingRecordIpLog = "record_ip_log" // 是否记录请求和错误日志IP
)
var (
+3 -3
View File
@@ -165,8 +165,8 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
consumedTime := float64(milliseconds) / 1000.0
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice)
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试",
quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other)
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
@@ -312,7 +312,7 @@ func testAllChannels(notify bool) error {
channel.UpdateResponseTime(milliseconds)
time.Sleep(common.RequestInterval)
}
if notify {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
}
+21 -14
View File
@@ -43,22 +43,23 @@ type OpenAIModelsResponse struct {
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
if pageSize < 0 {
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
var total int64
if enableTagMode {
tags, err := model.GetPaginatedTags(p*pageSize, pageSize)
// tag 分页:先分页 tag,再取各 tag 下 channels
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
for _, tag := range tags {
@@ -69,21 +70,27 @@ func GetAllChannels(c *gin.Context) {
}
}
}
// 计算 tag 总数用于分页
total, _ = model.CountAllTags()
} else {
channels, err := model.GetAllChannels(p*pageSize, pageSize, false, idSort)
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountAllChannels()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": channelData,
"data": gin.H{
"items": channelData,
"total": total,
"page": p,
"page_size": pageSize,
},
})
return
}
+103
View File
@@ -0,0 +1,103 @@
// 用于迁移检测的旧键,该文件下个版本会删除
package controller
import (
"encoding/json"
"net/http"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
)
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
func MigrateConsoleSetting(c *gin.Context) {
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
// 建立 map
valMap := map[string]string{}
for _, o := range opts {
valMap[o.Key] = o.Value
}
// 处理 APIInfo
if v := valMap["ApiInfo"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
if len(arr) > 50 {
arr = arr[:50]
}
bytes, _ := json.Marshal(arr)
model.UpdateOption("console_setting.api_info", string(bytes))
}
model.UpdateOption("ApiInfo", "")
}
// Announcements 直接搬
if v := valMap["Announcements"]; v != "" {
model.UpdateOption("console_setting.announcements", v)
model.UpdateOption("Announcements", "")
}
// FAQ 转换
if v := valMap["FAQ"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
out := []map[string]interface{}{}
for _, item := range arr {
q, _ := item["question"].(string)
if q == "" {
q, _ = item["title"].(string)
}
a, _ := item["answer"].(string)
if a == "" {
a, _ = item["content"].(string)
}
if q != "" && a != "" {
out = append(out, map[string]interface{}{"question": q, "answer": a})
}
}
if len(out) > 50 {
out = out[:50]
}
bytes, _ := json.Marshal(out)
model.UpdateOption("console_setting.faq", string(bytes))
}
model.UpdateOption("FAQ", "")
}
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups
url := valMap["UptimeKumaUrl"]
slug := valMap["UptimeKumaSlug"]
if url != "" && slug != "" {
// 仅当同时存在 URL 与 Slug 时才进行迁移
groups := []map[string]interface{}{
{
"id": 1,
"categoryName": "old",
"url": url,
"slug": slug,
"description": "",
},
}
bytes, _ := json.Marshal(groups)
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
}
// 清空旧键内容
if url != "" {
model.UpdateOption("UptimeKumaUrl", "")
}
if slug != "" {
model.UpdateOption("UptimeKumaSlug", "")
}
// 删除旧键记录
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}
+8 -1
View File
@@ -1,10 +1,11 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/model"
"one-api/setting"
"github.com/gin-gonic/gin"
)
func GetGroups(c *gin.Context) {
@@ -34,6 +35,12 @@ func GetUserGroups(c *gin.Context) {
}
}
}
if setting.GroupInUserUsableGroups("auto") {
usableGroups["auto"] = map[string]interface{}{
"ratio": "自动",
"desc": setting.GetUsableGroupDescription("auto"),
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
+34 -20
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"io"
"log"
"net/http"
"one-api/common"
"one-api/dto"
@@ -215,8 +214,12 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
func GetAllMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
// 解析其他查询参数
@@ -227,31 +230,38 @@ func GetAllMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
logs := model.GetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Midjourney, 0)
}
items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams)
total := model.CountAllTasks(queryParams)
if setting.MjForwardUrlEnabled {
for i, midjourney := range logs {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
logs[i] = midjourney
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
func GetUserMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
userId := c.GetInt("id")
log.Printf("userId = %d \n", userId)
queryParams := model.TaskQueryParams{
MjID: c.Query("mj_id"),
@@ -259,19 +269,23 @@ func GetUserMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
logs := model.GetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Midjourney, 0)
}
items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
total := model.CountAllUserTask(userId, queryParams)
if setting.MjForwardUrlEnabled {
for i, midjourney := range logs {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
logs[i] = midjourney
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
+64 -43
View File
@@ -9,6 +9,7 @@ import (
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/operation_setting"
"one-api/setting/system_setting"
"strings"
@@ -37,52 +38,72 @@ func TestStatus(c *gin.Context) {
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"min_topup": setting.MinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"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,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
"uptime_kuma_enabled": cs.UptimeKumaEnabled,
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup,
}
// 根据启用状态注入可选内容
if cs.ApiInfoEnabled {
data["api_info"] = console_setting.GetApiInfo()
}
if cs.AnnouncementsEnabled {
data["announcements"] = console_setting.GetAnnouncements()
}
if cs.FAQEnabled {
data["faq"] = console_setting.GetFAQ()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"min_topup": setting.MinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup,
"api_info": setting.GetApiInfo(),
"announcements": setting.GetAnnouncements(),
"faq": setting.GetFAQ(),
},
"data": data,
})
return
}
+16 -2
View File
@@ -2,7 +2,6 @@ package controller
import (
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/constant"
@@ -15,6 +14,9 @@ import (
"one-api/relay/channel/moonshot"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/setting"
"github.com/gin-gonic/gin"
)
// https://platform.openai.com/docs/api-reference/models/list
@@ -179,7 +181,19 @@ func ListModels(c *gin.Context) {
if tokenGroup != "" {
group = tokenGroup
}
models := model.GetGroupModels(group)
var models []string
if tokenGroup == "auto" {
for _, autoGroup := range setting.AutoGroups {
groupModels := model.GetGroupModels(autoGroup)
for _, g := range groupModels {
if !common.StringsContains(models, g) {
models = append(models, g)
}
}
}
} else {
models = model.GetGroupModels(group)
}
for _, s := range models {
if _, ok := openAIModelsMap[s]; ok {
userOpenAiModels = append(userOpenAiModels, openAIModelsMap[s])
+16 -6
View File
@@ -6,6 +6,7 @@ import (
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/system_setting"
"strings"
@@ -119,8 +120,8 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "ApiInfo":
err = setting.ValidateApiInfo(option.Value)
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -128,8 +129,8 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "Announcements":
err = setting.ValidateConsoleSettings(option.Value, "Announcements")
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -137,8 +138,17 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "FAQ":
err = setting.ValidateConsoleSettings(option.Value, "FAQ")
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
+4 -3
View File
@@ -3,7 +3,6 @@ package controller
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/constant"
@@ -13,6 +12,8 @@ import (
"one-api/service"
"one-api/setting"
"time"
"github.com/gin-gonic/gin"
)
func Playground(c *gin.Context) {
@@ -57,9 +58,9 @@ func Playground(c *gin.Context) {
c.Set("group", group)
}
c.Set("token_name", "playground-"+group)
channel, err := model.CacheGetRandomSatisfiedChannel(group, playgroundRequest.Model, 0)
channel, finalGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, playgroundRequest.Model, 0)
if err != nil {
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", group, playgroundRequest.Model)
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", finalGroup, playgroundRequest.Model)
openaiErr = service.OpenAIErrorWrapperLocal(errors.New(message), "get_playground_channel_failed", http.StatusInternalServerError)
return
}
+8 -1
View File
@@ -1,10 +1,11 @@
package controller
import (
"github.com/gin-gonic/gin"
"one-api/model"
"one-api/setting"
"one-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
func GetPricing(c *gin.Context) {
@@ -20,6 +21,12 @@ func GetPricing(c *gin.Context) {
user, err := model.GetUserCache(userId.(int))
if err == nil {
group = user.Group
for g := range groupRatio {
ratio, ok := setting.GetGroupGroupRatio(group, g)
if ok {
groupRatio[g] = ratio
}
}
}
}
+39 -3
View File
@@ -5,6 +5,7 @@ import (
"one-api/common"
"one-api/model"
"strconv"
"errors"
"github.com/gin-gonic/gin"
)
@@ -126,6 +127,10 @@ func AddRedemption(c *gin.Context) {
})
return
}
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
var keys []string
for i := 0; i < redemption.Count; i++ {
key := common.GetUUID()
@@ -135,6 +140,7 @@ func AddRedemption(c *gin.Context) {
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
ExpiredTime: redemption.ExpiredTime,
}
err = cleanRedemption.Insert()
if err != nil {
@@ -191,12 +197,18 @@ func UpdateRedemption(c *gin.Context) {
})
return
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status
} else {
if statusOnly == "" {
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
cleanRedemption.ExpiredTime = redemption.ExpiredTime
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status
}
err = cleanRedemption.Update()
if err != nil {
@@ -213,3 +225,27 @@ func UpdateRedemption(c *gin.Context) {
})
return
}
func DeleteInvalidRedemption(c *gin.Context) {
rows, err := model.DeleteInvalidRedemptions()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": rows,
})
return
}
func validateExpiredTime(expired int64) error {
if expired != 0 && expired < common.GetTimestamp() {
return errors.New("过期时间不能早于当前时间")
}
return nil
}
+2 -2
View File
@@ -259,7 +259,7 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
AutoBan: &autoBanInt,
}, nil
}
channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, retryCount)
channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
return nil, errors.New(fmt.Sprintf("获取重试渠道失败: %s", err.Error()))
}
@@ -388,7 +388,7 @@ func RelayTask(c *gin.Context) {
retryTimes = 0
}
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
channel, err := model.CacheGetRandomSatisfiedChannel(group, originalModel, i)
channel, _, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error()))
break
+8
View File
@@ -75,6 +75,14 @@ func PostSetup(c *gin.Context) {
// If root doesn't exist, validate and create admin account
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
c.JSON(400, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
return
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{
+30 -14
View File
@@ -224,9 +224,14 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool
func GetAllTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
// 解析其他查询参数
@@ -237,24 +242,32 @@ func GetAllTask(c *gin.Context) {
Action: c.Query("action"),
StartTimestamp: startTimestamp,
EndTimestamp: endTimestamp,
ChannelID: c.Query("channel_id"),
}
logs := model.TaskGetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Task, 0)
}
items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams)
total := model.TaskCountAllTasks(queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
func GetUserTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
userId := c.GetInt("id")
@@ -271,14 +284,17 @@ func GetUserTask(c *gin.Context) {
EndTimestamp: endTimestamp,
}
logs := model.TaskGetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
if logs == nil {
logs = make([]*model.Task, 0)
}
items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
total := model.TaskCountAllUserTask(userId, queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": logs,
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
}
+12 -4
View File
@@ -12,15 +12,15 @@ func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
p, _ := strconv.Atoi(c.Query("p"))
size, _ := strconv.Atoi(c.Query("size"))
if p < 0 {
p = 0
if p < 1 {
p = 1
}
if size <= 0 {
size = common.ItemsPerPage
} else if size > 100 {
size = 100
}
tokens, err := model.GetAllUserTokens(userId, p*size, size)
tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -28,10 +28,18 @@ func GetAllTokens(c *gin.Context) {
})
return
}
// Get total count for pagination
total, _ := model.CountUserTokens(userId)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": tokens,
"data": gin.H{
"items": tokens,
"total": total,
"page": p,
"page_size": size,
},
})
return
}
+101 -116
View File
@@ -4,9 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/setting/console_setting"
"strconv"
"strings"
"time"
@@ -14,45 +14,25 @@ import (
"golang.org/x/sync/errgroup"
)
type UptimeKumaMonitor struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
const (
requestTimeout = 30 * time.Second
httpTimeout = 10 * time.Second
uptimeKeySuffix = "_24"
apiStatusPath = "/api/status-page/"
apiHeartbeatPath = "/api/status-page/heartbeat/"
)
type UptimeKumaGroup struct {
ID int `json:"id"`
Name string `json:"name"`
Weight int `json:"weight"`
MonitorList []UptimeKumaMonitor `json:"monitorList"`
}
type UptimeKumaHeartbeat struct {
Status int `json:"status"`
Time string `json:"time"`
Msg string `json:"msg"`
Ping *float64 `json:"ping"`
}
type UptimeKumaStatusResponse struct {
PublicGroupList []UptimeKumaGroup `json:"publicGroupList"`
}
type UptimeKumaHeartbeatResponse struct {
HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}
type MonitorStatus struct {
type Monitor struct {
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
Group string `json:"group,omitempty"`
}
var (
ErrUpstreamNon200 = errors.New("upstream non-200")
ErrTimeout = errors.New("context deadline exceeded")
)
type UptimeGroupResult struct {
CategoryName string `json:"categoryName"`
Monitors []Monitor `json:"monitors"`
}
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -62,108 +42,113 @@ func getAndDecode(ctx context.Context, client *http.Client, url string, dest int
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return ErrTimeout
}
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ErrUpstreamNon200
return errors.New("non-200 status")
}
return json.NewDecoder(resp.Body).Decode(dest)
}
func GetUptimeKumaStatus(c *gin.Context) {
common.OptionMapRWMutex.RLock()
uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"]
slug := common.OptionMap["UptimeKumaSlug"]
common.OptionMapRWMutex.RUnlock()
if uptimeKumaUrl == "" || slug == "" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": []MonitorStatus{},
})
return
func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult {
url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string)
result := UptimeGroupResult{
CategoryName: categoryName,
Monitors: []Monitor{},
}
if url == "" || slug == "" {
return result
}
uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
client := &http.Client{}
statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
var (
statusData UptimeKumaStatusResponse
heartbeatData UptimeKumaHeartbeatResponse
)
baseURL := strings.TrimSuffix(url, "/")
var statusData struct {
PublicGroupList []struct {
ID int `json:"id"`
Name string `json:"name"`
MonitorList []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"monitorList"`
} `json:"publicGroupList"`
}
var heartbeatData struct {
HeartbeatList map[string][]struct {
Status int `json:"status"`
} `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return getAndDecode(gCtx, client, statusPageUrl, &statusData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
})
if g.Wait() != nil {
return result
}
if err := g.Wait(); err != nil {
switch err {
case ErrUpstreamNon200:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "上游接口出现问题",
})
case ErrTimeout:
c.JSON(http.StatusRequestTimeout, gin.H{
"success": false,
"message": "请求上游接口超时",
})
default:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
for _, pg := range statusData.PublicGroupList {
if len(pg.MonitorList) == 0 {
continue
}
for _, m := range pg.MonitorList {
monitor := Monitor{
Name: m.Name,
Group: pg.Name,
}
monitorID := strconv.Itoa(m.ID)
if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists {
monitor.Uptime = uptime
}
if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 {
monitor.Status = heartbeats[0].Status
}
result.Monitors = append(result.Monitors, monitor)
}
}
return result
}
func GetUptimeKumaStatus(c *gin.Context) {
groups := console_setting.GetUptimeKumaGroups()
if len(groups) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}})
return
}
var monitors []MonitorStatus
for _, group := range statusData.PublicGroupList {
for _, monitor := range group.MonitorList {
monitorStatus := MonitorStatus{
Name: monitor.Name,
Uptime: 0.0,
Status: 0,
}
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel()
uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
monitorStatus.Uptime = uptime
}
heartbeatKey := fmt.Sprintf("%d", monitor.ID)
if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
latestHeartbeat := heartbeats[0]
monitorStatus.Status = latestHeartbeat.Status
}
monitors = append(monitors, monitorStatus)
}
client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups {
i, group := i, group
g.Go(func() error {
results[i] = fetchGroupData(gCtx, client, group)
return nil
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": monitors,
})
g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
}
+8
View File
@@ -226,6 +226,9 @@ func Register(c *gin.Context) {
UnlimitedQuota: true,
ModelLimitsEnabled: false,
}
if setting.DefaultUseAutoGroup {
token.Group = "auto"
}
if err := token.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -459,6 +462,9 @@ func GetSelf(c *gin.Context) {
})
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -943,6 +949,7 @@ type UpdateUserSettingRequest struct {
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
func UpdateUserSetting(c *gin.Context) {
@@ -1019,6 +1026,7 @@ func UpdateUserSetting(c *gin.Context) {
constant.UserSettingNotifyType: req.QuotaWarningType,
constant.UserSettingQuotaWarningThreshold: req.QuotaWarningThreshold,
"accept_unset_model_ratio_model": req.AcceptUnsetModelRatioModel,
constant.UserSettingRecordIpLog: req.RecordIpLog,
}
// 如果是webhook类型,添加webhook相关设置
+8 -1
View File
@@ -178,7 +178,14 @@ type ClaudeRequest struct {
type Thinking struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
}
func (c *Thinking) GetBudgetTokens() int {
if c.BudgetTokens == nil {
return 0
}
return *c.BudgetTokens
}
func (c *ClaudeRequest) IsStringSystem() bool {
+2
View File
@@ -58,6 +58,8 @@ type GeneralOpenAIRequest struct {
// OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"`
Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
}
func (r *GeneralOpenAIRequest) ToMap() map[string]any {
+3 -1
View File
@@ -105,10 +105,12 @@ func main() {
model.InitChannelCache()
}()
go model.SyncOptions(common.SyncFrequency)
go model.SyncChannelCache(common.SyncFrequency)
}
// 热更新配置
go model.SyncOptions(common.SyncFrequency)
// 数据看板
go model.UpdateQuotaData()
+11 -4
View File
@@ -49,8 +49,10 @@ func Distribute() func(c *gin.Context) {
}
// check group in common.GroupRatio
if !setting.ContainsGroupRatio(tokenGroup) {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
if tokenGroup != "auto" {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
return
}
}
userGroup = tokenGroup
}
@@ -95,9 +97,14 @@ func Distribute() func(c *gin.Context) {
}
if shouldSelectChannel {
channel, err = model.CacheGetRandomSatisfiedChannel(userGroup, modelRequest.Model, 0)
var selectGroup string
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
if err != nil {
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", userGroup, modelRequest.Model)
showGroup := userGroup
if userGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model)
// 如果错误,但是渠道不为空,说明是数据库一致性问题
if channel != nil {
common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
+21 -16
View File
@@ -8,6 +8,7 @@ import (
"github.com/samber/lo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Ability struct {
@@ -23,7 +24,7 @@ type Ability struct {
func GetGroupModels(group string) []string {
var models []string
// Find distinct models
DB.Table("abilities").Where(groupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models)
return models
}
@@ -41,16 +42,12 @@ func GetAllEnableAbilities() []Ability {
}
func getPriority(group string, model string, retry int) (int, error) {
trueVal := "1"
if common.UsingPostgreSQL {
trueVal = "true"
}
var priorities []int
err := DB.Model(&Ability{}).
Select("DISTINCT(priority)").
Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model).
Order("priority DESC"). // 按优先级降序排序
Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, commonTrueVal).
Order("priority DESC"). // 按优先级降序排序
Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中
if err != nil {
@@ -75,18 +72,14 @@ func getPriority(group string, model string, retry int) (int, error) {
}
func getChannelQuery(group string, model string, retry int) *gorm.DB {
trueVal := "1"
if common.UsingPostgreSQL {
trueVal = "true"
}
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(groupCol+" = ? and model = ? and enabled = "+trueVal, group, model)
channelQuery := DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = (?)", group, model, maxPrioritySubQuery)
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, commonTrueVal)
channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, commonTrueVal, maxPrioritySubQuery)
if retry != 0 {
priority, err := getPriority(group, model, retry)
if err != nil {
common.SysError(fmt.Sprintf("Get priority failed: %s", err.Error()))
} else {
channelQuery = DB.Where(groupCol+" = ? and model = ? and enabled = "+trueVal+" and priority = ?", group, model, priority)
channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, commonTrueVal, priority)
}
}
@@ -133,9 +126,15 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
func (channel *Channel) AddAbilities() error {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
key := group + "|" + model
if _, exists := abilitySet[key]; exists {
continue
}
abilitySet[key] = struct{}{}
ability := Ability{
Group: group,
Model: model,
@@ -152,7 +151,7 @@ func (channel *Channel) AddAbilities() error {
return nil
}
for _, chunk := range lo.Chunk(abilities, 50) {
err := DB.Create(&chunk).Error
err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
if err != nil {
return err
}
@@ -194,9 +193,15 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
// Then add new abilities
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
abilitySet := make(map[string]struct{})
abilities := make([]Ability, 0, len(models_))
for _, model := range models_ {
for _, group := range groups_ {
key := group + "|" + model
if _, exists := abilitySet[key]; exists {
continue
}
abilitySet[key] = struct{}{}
ability := Ability{
Group: group,
Model: model,
@@ -212,7 +217,7 @@ func (channel *Channel) UpdateAbilities(tx *gorm.DB) error {
if len(abilities) > 0 {
for _, chunk := range lo.Chunk(abilities, 50) {
err = tx.Create(&chunk).Error
err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
if err != nil {
if isNewTx {
tx.Rollback()
+40 -1
View File
@@ -5,10 +5,13 @@ import (
"fmt"
"math/rand"
"one-api/common"
"one-api/setting"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
var group2model2channels map[string]map[string][]*Channel
@@ -75,7 +78,43 @@ func SyncChannelCache(frequency int) {
}
}
func CacheGetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) {
var channel *Channel
var err error
selectGroup := group
if group == "auto" {
if len(setting.AutoGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
for _, autoGroup := range setting.AutoGroups {
if common.DebugEnabled {
println("autoGroup:", autoGroup)
}
channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry)
if channel == nil {
continue
} else {
c.Set("auto_group", autoGroup)
selectGroup = autoGroup
if common.DebugEnabled {
println("selectGroup:", selectGroup)
}
break
}
}
} else {
channel, err = getRandomSatisfiedChannel(group, model, retry)
if err != nil {
return nil, group, err
}
}
if channel == nil {
return nil, group, errors.New("channel not found")
}
return channel, selectGroup, nil
}
func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
if strings.HasPrefix(model, "gpt-4-gizmo") {
model = "gpt-4-gizmo-*"
}
+24 -10
View File
@@ -145,7 +145,7 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
}
// 构造基础查询
baseQuery := DB.Model(&Channel{}).Omit(keyCol)
baseQuery := DB.Model(&Channel{}).Omit("key")
// 构造WHERE子句
var whereClause string
@@ -153,15 +153,15 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?`
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?`
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
}
@@ -478,7 +478,7 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
}
// 构造基础查询
baseQuery := DB.Model(&Channel{}).Omit(keyCol)
baseQuery := DB.Model(&Channel{}).Omit("key")
// 构造WHERE子句
var whereClause string
@@ -486,15 +486,15 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
if group != "" && group != "null" {
var groupCondition string
if common.UsingMySQL {
groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?`
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
} else {
// sqlite, PostgreSQL
groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?`
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
}
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
} else {
whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
}
@@ -583,3 +583,17 @@ func BatchSetChannelTag(ids []int, tag *string) error {
// 提交事务
return tx.Commit().Error
}
// CountAllChannels returns total channels in DB
func CountAllChannels() (int64, error) {
var total int64
err := DB.Model(&Channel{}).Count(&total).Error
return total, err
}
// CountAllTags returns number of non-empty distinct tags
func CountAllTags() (int64, error) {
var total int64
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
return total, err
}
+46 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"one-api/common"
"one-api/constant"
"os"
"strings"
"time"
@@ -32,6 +33,7 @@ type Log struct {
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
Other string `json:"other"`
}
@@ -61,7 +63,7 @@ func formatUserLogs(logs []*Log) {
func GetLogByKey(key string) (logs []*Log, err error) {
if os.Getenv("LOG_SQL_DSN") != "" {
var tk Token
if err = DB.Model(&Token{}).Where(keyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil {
if err = DB.Model(&Token{}).Where(logKeyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil {
return nil, err
}
err = LOG_DB.Model(&Log{}).Where("token_id=?", tk.Id).Find(&logs).Error
@@ -95,6 +97,15 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
common.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
username := c.GetString("username")
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
if vb, ok := v.(bool); ok && vb {
needRecordIp = true
}
}
}
log := &Log{
UserId: userId,
Username: username,
@@ -111,7 +122,13 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
Other: otherStr,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
}
return ""
}(),
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -128,6 +145,15 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
}
username := c.GetString("username")
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
if vb, ok := v.(bool); ok && vb {
needRecordIp = true
}
}
}
log := &Log{
UserId: userId,
Username: username,
@@ -144,7 +170,13 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
UseTime: useTimeSeconds,
IsStream: isStream,
Group: group,
Other: otherStr,
Ip: func() string {
if needRecordIp {
return c.ClientIP()
}
return ""
}(),
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -184,7 +216,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
tx = tx.Where("logs.channel_id = ?", channel)
}
if group != "" {
tx = tx.Where("logs."+groupCol+" = ?", group)
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
err = tx.Model(&Log{}).Count(&total).Error
if err != nil {
@@ -195,13 +227,18 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
return nil, 0, err
}
channelIds := make([]int, 0)
channelIdsMap := make(map[int]struct{})
channelMap := make(map[int]string)
for _, log := range logs {
if log.ChannelId != 0 {
channelIds = append(channelIds, log.ChannelId)
channelIdsMap[log.ChannelId] = struct{}{}
}
}
channelIds := make([]int, 0, len(channelIdsMap))
for channelId := range channelIdsMap {
channelIds = append(channelIds, channelId)
}
if len(channelIds) > 0 {
var channels []struct {
Id int `gorm:"column:id"`
@@ -242,7 +279,7 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
tx = tx.Where("logs.created_at <= ?", endTimestamp)
}
if group != "" {
tx = tx.Where("logs."+groupCol+" = ?", group)
tx = tx.Where("logs."+logGroupCol+" = ?", group)
}
err = tx.Model(&Log{}).Count(&total).Error
if err != nil {
@@ -303,8 +340,8 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
}
if group != "" {
tx = tx.Where(groupCol+" = ?", group)
rpmTpmQuery = rpmTpmQuery.Where(groupCol+" = ?", group)
tx = tx.Where(logGroupCol+" = ?", group)
rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group)
}
tx = tx.Where("type = ?", LogTypeConsume)
+108 -55
View File
@@ -1,6 +1,7 @@
package model
import (
"fmt"
"log"
"one-api/common"
"one-api/constant"
@@ -15,18 +16,39 @@ import (
"gorm.io/gorm"
)
var groupCol string
var keyCol string
var commonGroupCol string
var commonKeyCol string
var commonTrueVal string
var commonFalseVal string
var logKeyCol string
var logGroupCol string
func initCol() {
// init common column names
if common.UsingPostgreSQL {
groupCol = `"group"`
keyCol = `"key"`
commonGroupCol = `"group"`
commonKeyCol = `"key"`
commonTrueVal = "true"
commonFalseVal = "false"
} else {
groupCol = "`group`"
keyCol = "`key`"
commonGroupCol = "`group`"
commonKeyCol = "`key`"
commonTrueVal = "1"
commonFalseVal = "0"
}
if os.Getenv("LOG_SQL_DSN") != "" {
switch common.LogSqlType {
case common.DatabaseTypePostgreSQL:
logGroupCol = `"group"`
logKeyCol = `"key"`
default:
logGroupCol = commonGroupCol
logKeyCol = commonKeyCol
}
}
// log sql type and database type
common.SysLog("Using Log SQL Type: " + common.LogSqlType)
}
var DB *gorm.DB
@@ -83,7 +105,7 @@ func CheckSetup() {
}
}
func chooseDB(envName string) (*gorm.DB, error) {
func chooseDB(envName string, isLog bool) (*gorm.DB, error) {
defer func() {
initCol()
}()
@@ -92,7 +114,11 @@ func chooseDB(envName string) (*gorm.DB, error) {
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
// Use PostgreSQL
common.SysLog("using PostgreSQL as database")
common.UsingPostgreSQL = true
if !isLog {
common.UsingPostgreSQL = true
} else {
common.LogSqlType = common.DatabaseTypePostgreSQL
}
return gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true, // disables implicit prepared statement usage
@@ -102,7 +128,11 @@ func chooseDB(envName string) (*gorm.DB, error) {
}
if strings.HasPrefix(dsn, "local") {
common.SysLog("SQL_DSN not set, using SQLite as database")
common.UsingSQLite = true
if !isLog {
common.UsingSQLite = true
} else {
common.LogSqlType = common.DatabaseTypeSQLite
}
return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
@@ -117,7 +147,11 @@ func chooseDB(envName string) (*gorm.DB, error) {
dsn += "?parseTime=true"
}
}
common.UsingMySQL = true
if !isLog {
common.UsingMySQL = true
} else {
common.LogSqlType = common.DatabaseTypeMySQL
}
return gorm.Open(mysql.Open(dsn), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
@@ -131,7 +165,7 @@ func chooseDB(envName string) (*gorm.DB, error) {
}
func InitDB() (err error) {
db, err := chooseDB("SQL_DSN")
db, err := chooseDB("SQL_DSN", false)
if err == nil {
if common.DebugEnabled {
db = db.Debug()
@@ -149,7 +183,7 @@ func InitDB() (err error) {
return nil
}
if common.UsingMySQL {
_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded
//_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded
}
common.SysLog("database migration started")
err = migrateDB()
@@ -165,7 +199,7 @@ func InitLogDB() (err error) {
LOG_DB = DB
return
}
db, err := chooseDB("LOG_SQL_DSN")
db, err := chooseDB("LOG_SQL_DSN", true)
if err == nil {
if common.DebugEnabled {
db = db.Debug()
@@ -198,54 +232,73 @@ func InitLogDB() (err error) {
}
func migrateDB() error {
err := DB.AutoMigrate(&Channel{})
if !common.UsingPostgreSQL {
return migrateDBFast()
}
err := DB.AutoMigrate(
&Channel{},
&Token{},
&User{},
&Option{},
&Redemption{},
&Ability{},
&Log{},
&Midjourney{},
&TopUp{},
&QuotaData{},
&Task{},
&Setup{},
)
if err != nil {
return err
}
err = DB.AutoMigrate(&Token{})
if err != nil {
return err
return nil
}
func migrateDBFast() error {
var wg sync.WaitGroup
errChan := make(chan error, 12) // Buffer size matches number of migrations
migrations := []struct {
model interface{}
name string
}{
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&Option{}, "Option"},
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},
{&Log{}, "Log"},
{&Midjourney{}, "Midjourney"},
{&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"},
{&Task{}, "Task"},
{&Setup{}, "Setup"},
}
err = DB.AutoMigrate(&User{})
if err != nil {
return err
for _, m := range migrations {
wg.Add(1)
go func(model interface{}, name string) {
defer wg.Done()
if err := DB.AutoMigrate(model); err != nil {
errChan <- fmt.Errorf("failed to migrate %s: %v", name, err)
}
}(m.model, m.name)
}
err = DB.AutoMigrate(&Option{})
if err != nil {
return err
// Wait for all migrations to complete
wg.Wait()
close(errChan)
// Check for any errors
for err := range errChan {
if err != nil {
return err
}
}
err = DB.AutoMigrate(&Redemption{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Ability{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Log{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Midjourney{})
if err != nil {
return err
}
err = DB.AutoMigrate(&TopUp{})
if err != nil {
return err
}
err = DB.AutoMigrate(&QuotaData{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Task{})
if err != nil {
return err
}
err = DB.AutoMigrate(&Setup{})
common.SysLog("database migrated")
//err = createRootAccountIfNeed()
return err
return nil
}
func migrateLOGDB() error {
+37
View File
@@ -166,3 +166,40 @@ func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error {
Where("id in (?)", taskIDs).
Updates(params).Error
}
// CountAllTasks returns total midjourney tasks for admin query
func CountAllTasks(queryParams TaskQueryParams) int64 {
var total int64
query := DB.Model(&Midjourney{})
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
// CountAllUserTask returns total midjourney tasks for user
func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 {
var total int64
query := DB.Model(&Midjourney{}).Where("user_id = ?", userId)
if queryParams.MjID != "" {
query = query.Where("mj_id = ?", queryParams.MjID)
}
if queryParams.StartTimestamp != "" {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != "" {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
+10 -4
View File
@@ -76,6 +76,8 @@ func InitOptionMap() {
common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["TelegramBotToken"] = ""
@@ -98,6 +100,7 @@ func InitOptionMap() {
common.OptionMap["ModelPrice"] = operation_setting.ModelPrice2JSONString()
common.OptionMap["CacheRatio"] = operation_setting.CacheRatio2JSONString()
common.OptionMap["GroupRatio"] = setting.GroupRatio2JSONString()
common.OptionMap["GroupGroupRatio"] = setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = operation_setting.CompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
@@ -122,9 +125,6 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["ApiInfo"] = ""
common.OptionMap["UptimeKumaUrl"] = ""
common.OptionMap["UptimeKumaSlug"] = ""
// 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs()
@@ -194,7 +194,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -263,6 +263,8 @@ func updateOptionMap(key string, value string) (err error) {
common.SMTPSSLEnabled = boolValue
case "WorkerAllowHttpImageRequestEnabled":
setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
}
}
switch key {
@@ -289,6 +291,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.PayAddress = value
case "Chats":
err = setting.UpdateChatsByJsonString(value)
case "AutoGroups":
err = setting.UpdateAutoGroupsByJsonString(value)
case "CustomCallbackAddress":
setting.CustomCallbackAddress = value
case "EpayId":
@@ -357,6 +361,8 @@ func updateOptionMap(key string, value string) (err error) {
err = operation_setting.UpdateModelRatioByJSONString(value)
case "GroupRatio":
err = setting.UpdateGroupRatioByJSONString(value)
case "GroupGroupRatio":
err = setting.UpdateGroupGroupRatioByJSONString(value)
case "UserUsableGroups":
err = setting.UpdateUserUsableGroupsByJSONString(value)
case "CompletionRatio":
+11 -1
View File
@@ -21,6 +21,7 @@ type Redemption struct {
Count int `json:"count" gorm:"-:all"` // only for api request
UsedUserId int `json:"used_user_id"`
DeletedAt gorm.DeletedAt `gorm:"index"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期
}
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
@@ -131,6 +132,9 @@ func Redeem(key string, userId int) (quota int, err error) {
if redemption.Status != common.RedemptionCodeStatusEnabled {
return errors.New("该兑换码已被使用")
}
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
return errors.New("该兑换码已过期")
}
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil {
return err
@@ -162,7 +166,7 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time").Updates(redemption).Error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
}
@@ -183,3 +187,9 @@ func DeleteRedemptionById(id int) (err error) {
}
return redemption.Delete()
}
func DeleteInvalidRedemptions() (int64, error) {
now := common.GetTimestamp()
result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{})
return result.RowsAffected, result.Error
}
+61
View File
@@ -302,3 +302,64 @@ func SumUsedTaskQuota(queryParams SyncTaskQueryParams) (stat []TaskQuotaUsage, e
err = query.Select("mode, sum(quota) as count").Group("mode").Find(&stat).Error
return stat, err
}
// TaskCountAllTasks returns total tasks that match the given query params (admin usage)
func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 {
var total int64
query := DB.Model(&Task{})
if queryParams.ChannelID != "" {
query = query.Where("channel_id = ?", queryParams.ChannelID)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.UserID != "" {
query = query.Where("user_id = ?", queryParams.UserID)
}
if len(queryParams.UserIDs) != 0 {
query = query.Where("user_id in (?)", queryParams.UserIDs)
}
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
// TaskCountAllUserTask returns total tasks for given user
func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 {
var total int64
query := DB.Model(&Task{}).Where("user_id = ?", userId)
if queryParams.TaskID != "" {
query = query.Where("task_id = ?", queryParams.TaskID)
}
if queryParams.Action != "" {
query = query.Where("action = ?", queryParams.Action)
}
if queryParams.Status != "" {
query = query.Where("status = ?", queryParams.Status)
}
if queryParams.Platform != "" {
query = query.Where("platform = ?", queryParams.Platform)
}
if queryParams.StartTimestamp != 0 {
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
}
if queryParams.EndTimestamp != 0 {
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
}
_ = query.Count(&total).Error
return total
}
+9 -2
View File
@@ -66,7 +66,7 @@ func SearchUserTokens(userId int, keyword string, token string) (tokens []*Token
if token != "" {
token = strings.Trim(token, "sk-")
}
err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(keyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error
err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(commonKeyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error
return tokens, err
}
@@ -161,7 +161,7 @@ func GetTokenByKey(key string, fromDB bool) (token *Token, err error) {
// Don't return error - fall through to DB
}
fromDB = true
err = DB.Where(keyCol+" = ?", key).First(&token).Error
err = DB.Where(commonKeyCol+" = ?", key).First(&token).Error
return token, err
}
@@ -320,3 +320,10 @@ func decreaseTokenQuota(id int, quota int) (err error) {
).Error
return err
}
// CountUserTokens returns total number of tokens for the given user, used for pagination
func CountUserTokens(userId int) (int64, error) {
var total int64
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
return total, err
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
func cacheSetToken(token Token) error {
key := common.GenerateHMAC(token.Key)
token.Clean()
err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(constant.TokenCacheSeconds)*time.Second)
err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(constant.RedisKeyCacheSeconds())*time.Second)
if err != nil {
return err
}
+5 -3
View File
@@ -41,6 +41,7 @@ type User struct {
DeletedAt gorm.DeletedAt `gorm:"index"`
LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"`
Setting string `json:"setting" gorm:"type:text;column:setting"`
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
}
func (user *User) ToBaseUser() *UserBase {
@@ -175,7 +176,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
// 如果是数字,同时搜索ID和其他字段
likeCondition = "id = ? OR " + likeCondition
if group != "" {
query = query.Where("("+likeCondition+") AND "+groupCol+" = ?",
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
} else {
query = query.Where(likeCondition,
@@ -184,7 +185,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
} else {
// 非数字关键字,只搜索字符串字段
if group != "" {
query = query.Where("("+likeCondition+") AND "+groupCol+" = ?",
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
} else {
query = query.Where(likeCondition,
@@ -366,6 +367,7 @@ func (user *User) Edit(updatePassword bool) error {
"display_name": newUser.DisplayName,
"group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark,
}
if updatePassword {
updates["password"] = newUser.Password
@@ -615,7 +617,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) {
// Don't return error - fall through to DB
}
fromDB = true
err = DB.Model(&User{}).Where("id = ?", id).Select(groupCol).Find(&group).Error
err = DB.Model(&User{}).Where("id = ?", id).Select(commonGroupCol).Find(&group).Error
if err != nil {
return "", err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func updateUserCache(user User) error {
return common.RedisHSetObj(
getUserCacheKey(user.Id),
user.ToBaseUser(),
time.Duration(constant.UserId2QuotaCacheSeconds)*time.Second,
time.Duration(constant.RedisKeyCacheSeconds())*time.Second,
)
}
+36 -37
View File
@@ -113,7 +113,7 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
// BudgetTokens 为 max_tokens 的 80%
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage),
BudgetTokens: common.GetPointer[int](int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
@@ -454,6 +454,7 @@ type ClaudeResponseInfo struct {
Model string
ResponseText strings.Builder
Usage *dto.Usage
Done bool
}
func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
@@ -461,20 +462,32 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
claudeInfo.ResponseText.WriteString(claudeResponse.Completion)
} else {
if claudeResponse.Type == "message_start" {
// message_start, 获取usage
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
// message_start, 获取usage
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != "" {
claudeInfo.ResponseText.WriteString(claudeResponse.Delta.Thinking)
}
} else if claudeResponse.Type == "message_delta" {
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
// 最终的usage获取
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
// 判断是否完整
claudeInfo.Done = true
} else if claudeResponse.Type == "content_block_start" {
} else {
return false
@@ -506,25 +519,15 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
}
if info.RelayFormat == relaycommon.RelayFormatClaude {
FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo)
if requestMode == RequestModeCompletion {
claudeInfo.ResponseText.WriteString(claudeResponse.Completion)
} else {
if claudeResponse.Type == "message_start" {
// message_start, 获取usage
info.UpstreamModelName = claudeResponse.Message.Model
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
claudeInfo.ResponseText.WriteString(claudeResponse.Delta.GetText())
} else if claudeResponse.Type == "message_delta" {
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
}
helper.ClaudeChunkData(c, claudeResponse, data)
@@ -544,29 +547,25 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done {
if common.DebugEnabled {
common.SysError("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
if info.RelayFormat == relaycommon.RelayFormatClaude {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
// 说明流模式建立失败,可能为官方出错
if claudeInfo.Usage.PromptTokens == 0 {
//usage.PromptTokens = info.PromptTokens
}
if claudeInfo.Usage.CompletionTokens == 0 {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
//
} else if info.RelayFormat == relaycommon.RelayFormatOpenAI {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage)
err := helper.ObjectData(c, response)
+1 -2
View File
@@ -3,7 +3,6 @@ package cohere
import (
"bufio"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
@@ -78,7 +77,7 @@ func stopReasonCohere2OpenAI(reason string) string {
}
func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID())
responseId := helper.GetResponseID(c)
createdTime := common.GetTimestamp()
usage := &dto.Usage{}
responseText := ""
+5 -2
View File
@@ -72,8 +72,11 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// suffix -thinking and -nothinking
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.OriginModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.OriginModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
+71 -38
View File
@@ -12,6 +12,7 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting/model_setting"
"strconv"
"strings"
"unicode/utf8"
@@ -36,6 +37,47 @@ var geminiSupportedMimeTypes = map[string]bool{
"video/flv": true,
}
// Gemini 允许的思考预算范围
const (
pro25MinBudget = 128
pro25MaxBudget = 32768
flash25MaxBudget = 24576
flash25LiteMinBudget = 512
flash25LiteMaxBudget = 24576
)
// clampThinkingBudget 根据模型名称将预算限制在允许的范围内
func clampThinkingBudget(modelName string, budget int) int {
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
if is25FlashLite {
if budget < flash25LiteMinBudget {
return flash25LiteMinBudget
}
if budget > flash25LiteMaxBudget {
return flash25LiteMaxBudget
}
} else if isNew25Pro {
if budget < pro25MinBudget {
return pro25MinBudget
}
if budget > pro25MaxBudget {
return pro25MaxBudget
}
} else { // 其他模型
if budget < 0 {
return 0
}
if budget > flash25MaxBudget {
return flash25MaxBudget
}
}
return budget
}
// Setting safety to the lowest possible values since Gemini is already powerless enough
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
@@ -57,16 +99,31 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 硬编码不支持 ThinkingBudget 的旧模型
modelName := info.OriginModelName
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
if strings.Contains(modelName, "-thinking-") {
parts := strings.SplitN(modelName, "-thinking-", 2)
if len(parts) == 2 && parts[1] != "" {
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(clampedBudget),
IncludeThoughts: true,
}
}
}
} else if strings.HasSuffix(modelName, "-thinking") {
unsupportedModels := []string{
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-03-25",
}
isUnsupported := false
for _, unsupportedModel := range unsupportedModels {
if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
if strings.HasPrefix(modelName, unsupportedModel) {
isUnsupported = true
break
}
@@ -78,39 +135,14 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if isNew25Pro {
// 新的2.5pro模型:ThinkingBudget范围为128-32768
if budgetTokens == 0 || budgetTokens < 128 {
budgetTokens = 128
} else if budgetTokens > 32768 {
budgetTokens = 32768
}
} else {
// 其他模型:ThinkingBudget范围为0-24576
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
}
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
ThinkingBudget: common.GetPointer(clampedBudget),
IncludeThoughts: true,
}
}
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if !isNew25Pro {
// 只有非新2.5pro模型才支持-nothinking
} else if strings.HasSuffix(modelName, "-nothinking") {
if !isNew25Pro && !is25FlashLite {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
@@ -283,7 +315,8 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
// 校验 MimeType 是否在 Gemini 支持的白名单中
if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok {
return nil, fmt.Errorf("MIME type '%s' from URL '%s' is not supported by Gemini. Supported types are: %v", fileData.MimeType, part.GetImageMedia().Url, getSupportedMimeTypesList())
url := part.GetImageMedia().Url
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList())
}
parts = append(parts, GeminiPart{
@@ -611,9 +644,9 @@ func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse {
}
}
func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResponse {
func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse {
fullTextResponse := dto.OpenAITextResponse{
Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
Id: helper.GetResponseID(c),
Object: "chat.completion",
Created: common.GetTimestamp(),
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
@@ -754,7 +787,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C
func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
// responseText := ""
id := fmt.Sprintf("chatcmpl-%s", common.GetUUID())
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
var usage = &dto.Usage{}
var imageCount int
@@ -849,7 +882,7 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
StatusCode: resp.StatusCode,
}, nil
}
fullTextResponse := responseGeminiChat2OpenAI(&geminiResponse)
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
+7
View File
@@ -88,6 +88,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
requestURL := strings.Split(info.RequestURLPath, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
task := strings.TrimPrefix(requestURL, "/v1/")
// 特殊处理 responses API
if info.RelayMode == constant.RelayModeResponses {
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview")
return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil
}
model_ := info.UpstreamModelName
// 2025年5月10日后创建的渠道不移除.
if info.ChannelCreateTime < constant2.AzureNoRemoveDotTime {
+5 -3
View File
@@ -8,6 +8,7 @@ import (
"math"
"mime/multipart"
"net/http"
"path/filepath"
"one-api/common"
"one-api/constant"
"one-api/dto"
@@ -345,13 +346,14 @@ func countAudioTokens(c *gin.Context) (int, error) {
if err = c.ShouldBind(&reqBody); err != nil {
return 0, errors.WithStack(err)
}
ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名
reqFp, err := reqBody.File.Open()
if err != nil {
return 0, errors.WithStack(err)
}
defer reqFp.Close()
tmpFp, err := os.CreateTemp("", "audio-*")
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.WithStack(err)
}
@@ -365,7 +367,7 @@ func countAudioTokens(c *gin.Context) (int, error) {
return 0, errors.WithStack(err)
}
duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name())
duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name(), ext)
if err != nil {
return 0, errors.WithStack(err)
}
+1 -2
View File
@@ -2,7 +2,6 @@ package palm
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
@@ -73,7 +72,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompleti
func palmStreamHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorWithStatusCode, string) {
responseText := ""
responseId := fmt.Sprintf("chatcmpl-%s", common.GetUUID())
responseId := helper.GetResponseID(c)
createdTime := common.GetTimestamp()
dataChan := make(chan string)
stopChan := make(chan bool)
+1 -1
View File
@@ -98,7 +98,7 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) {
// BudgetTokens 为 max_tokens 的 80%
textRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage),
BudgetTokens: common.GetPointer[int](int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
+2
View File
@@ -61,6 +61,7 @@ type RelayInfo struct {
TokenKey string
UserId int
Group string
UserGroup string
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
@@ -204,6 +205,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
TokenKey: tokenKey,
UserId: userId,
Group: group,
UserGroup: c.GetString(constant.ContextKeyUserGroup),
TokenUnlimited: tokenUnlimited,
StartTime: startTime,
FirstResponseTime: startTime.Add(-time.Second),
+45 -7
View File
@@ -2,14 +2,20 @@ package helper
import (
"fmt"
"github.com/gin-gonic/gin"
"one-api/common"
constant2 "one-api/constant"
relaycommon "one-api/relay/common"
"one-api/setting"
"one-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
type GroupRatioInfo struct {
GroupRatio float64
GroupSpecialRatio float64
}
type PriceData struct {
ModelPrice float64
ModelRatio float64
@@ -17,18 +23,50 @@ type PriceData struct {
CacheRatio float64
CacheCreationRatio float64
ImageRatio float64
GroupRatio float64
UsePrice bool
ShouldPreConsumedQuota int
GroupRatioInfo GroupRatioInfo
}
func (p PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
}
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.Group if present
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo {
groupRatioInfo := GroupRatioInfo{
GroupRatio: 1.0, // default ratio
GroupSpecialRatio: 1.0, // default user group ratio
}
// check auto group
autoGroup, exists := ctx.Get("auto_group")
if exists {
if common.DebugEnabled {
println(fmt.Sprintf("final group: %s", autoGroup))
}
relayInfo.Group = autoGroup.(string)
}
// check user group special ratio
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
// user group special ratio
groupRatioInfo.GroupSpecialRatio = userGroupRatio
groupRatioInfo.GroupRatio = userGroupRatio
} else {
// normal group ratio
groupRatioInfo.GroupRatio = setting.GetGroupRatio(relayInfo.Group)
}
return groupRatioInfo
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) {
modelPrice, usePrice := operation_setting.GetModelPrice(info.OriginModelName, false)
groupRatio := setting.GetGroupRatio(info.Group)
groupRatioInfo := HandleGroupRatio(c, info)
var preConsumedQuota int
var modelRatio float64
var completionRatio float64
@@ -58,17 +96,17 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
cacheRatio, _ = operation_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = operation_setting.GetCreateCacheRatio(info.OriginModelName)
imageRatio, _ = operation_setting.GetImageRatio(info.OriginModelName)
ratio := modelRatio * groupRatio
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatio)
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
}
priceData := PriceData{
ModelPrice: modelPrice,
ModelRatio: modelRatio,
CompletionRatio: completionRatio,
GroupRatio: groupRatio,
GroupRatioInfo: groupRatioInfo,
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
+14
View File
@@ -136,6 +136,20 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
adaptor.Init(relayInfo)
// Clean up empty system instruction
if req.SystemInstructions != nil {
hasContent := false
for _, part := range req.SystemInstructions.Parts {
if part.Text != "" {
hasContent = true
break
}
}
if !hasContent {
req.SystemInstructions = nil
}
}
requestBody, err := json.Marshal(req)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "marshal_text_request_failed", http.StatusInternalServerError)
+1 -1
View File
@@ -162,7 +162,7 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
// reset model price
priceData.ModelPrice *= sizeRatio * qualityRatio * float64(imageRequest.N)
quota = int(priceData.ModelPrice * priceData.GroupRatio * common.QuotaPerUnit)
quota = int(priceData.ModelPrice * priceData.GroupRatioInfo.GroupRatio * common.QuotaPerUnit)
userQuota, err = model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "get_user_quota_failed", http.StatusInternalServerError)
+6 -5
View File
@@ -90,15 +90,16 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
// get & validate textRequest 获取并验证文本请求
textRequest, err := getAndValidateTextRequest(c, relayInfo)
if textRequest.WebSearchOptions != nil {
c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize)
}
if err != nil {
common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
return service.OpenAIErrorWrapperLocal(err, "invalid_text_request", http.StatusBadRequest)
}
if textRequest.WebSearchOptions != nil {
c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize)
}
if setting.ShouldCheckPromptSensitive() {
words, err := checkRequestSensitive(textRequest, relayInfo)
if err != nil {
@@ -361,7 +362,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
cacheRatio := priceData.CacheRatio
imageRatio := priceData.ImageRatio
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
modelPrice := priceData.ModelPrice
// Convert values to decimal for precise calculation
@@ -510,7 +511,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
if extraContent != "" {
logContent += ", " + extraContent
}
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
if imageTokens != 0 {
other["image"] = true
other["image_ratio"] = imageRatio
+6 -37
View File
@@ -6,12 +6,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
"one-api/setting/operation_setting"
)
func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWithStatusCode) {
@@ -39,43 +37,14 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWi
//isModelMapped = true
}
}
//relayInfo.UpstreamModelName = textRequest.Model
modelPrice, getModelPriceSuccess := operation_setting.GetModelPrice(relayInfo.UpstreamModelName, false)
groupRatio := setting.GetGroupRatio(relayInfo.Group)
var preConsumedQuota int
var ratio float64
var modelRatio float64
//err := service.SensitiveWordsCheck(textRequest)
//if constant.ShouldCheckPromptSensitive() {
// err = checkRequestSensitive(textRequest, relayInfo)
// if err != nil {
// return service.OpenAIErrorWrapperLocal(err, "sensitive_words_detected", http.StatusBadRequest)
// }
//}
//promptTokens, err := getWssPromptTokens(realtimeEvent, relayInfo)
//// count messages token error 计算promptTokens错误
//if err != nil {
// return service.OpenAIErrorWrapper(err, "count_token_messages_failed", http.StatusInternalServerError)
//}
//
if !getModelPriceSuccess {
preConsumedTokens := common.PreConsumedQuota
//if realtimeEvent.Session.MaxResponseOutputTokens != 0 {
// preConsumedTokens = promptTokens + int(realtimeEvent.Session.MaxResponseOutputTokens)
//}
modelRatio, _ = operation_setting.GetModelRatio(relayInfo.UpstreamModelName)
ratio = modelRatio * groupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatio)
relayInfo.UsePrice = true
priceData, err := helper.ModelPriceHelper(c, relayInfo, 0, 0)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
}
// pre-consume quota 预消耗配额
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, preConsumedQuota, relayInfo)
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if openaiErr != nil {
return openaiErr
}
@@ -113,6 +82,6 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (openaiErr *dto.OpenAIErrorWi
return openaiErr
}
service.PostWssConsumeQuota(c, relayInfo, relayInfo.UpstreamModelName, usage.(*dto.RealtimeUsage), preConsumedQuota,
userQuota, modelRatio, groupRatio, modelPrice, getModelPriceSuccess, "")
userQuota, priceData, "")
return nil
}
+2
View File
@@ -81,6 +81,7 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
@@ -126,6 +127,7 @@ func SetApiRouter(router *gin.Engine) {
redemptionRoute.GET("/:id", controller.GetRedemption)
redemptionRoute.POST("/", controller.AddRedemption)
redemptionRoute.PUT("/", controller.UpdateRedemption)
redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
}
logRoute := apiRouter.Group("/log")
+2
View File
@@ -59,6 +59,8 @@ func ShouldDisableChannel(channelType int, err *dto.OpenAIErrorWithStatusCode) b
return true
case "billing_not_active":
return true
case "pre_consume_token_quota_failed":
return true
}
switch err.Error.Type {
case "insufficient_quota":
+2 -2
View File
@@ -21,10 +21,10 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
isOpenRouter := info.ChannelType == common.ChannelTypeOpenRouter
if claudeRequest.Thinking != nil {
if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" {
if isOpenRouter {
reasoning := openrouter.RequestReasoning{
MaxTokens: claudeRequest.Thinking.BudgetTokens,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
reasoningJSON, err := json.Marshal(reasoning)
if err != nil {
+10 -6
View File
@@ -29,9 +29,11 @@ func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int)
func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode {
text := err.Error()
lowerText := strings.ToLower(text)
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") {
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
}
}
openAIError := dto.OpenAIError{
Message: text,
@@ -53,9 +55,11 @@ func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAI
func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode {
text := err.Error()
lowerText := strings.ToLower(text)
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
if !strings.HasPrefix(lowerText, "get file base64 from url") {
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
}
}
claudeError := dto.ClaudeError{
Message: text,
+98 -1
View File
@@ -4,8 +4,10 @@ import (
"encoding/base64"
"fmt"
"io"
"one-api/common"
"one-api/constant"
"one-api/dto"
"strings"
)
func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) {
@@ -30,9 +32,104 @@ func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) {
// Convert to base64
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
mimeType := resp.Header.Get("Content-Type")
if len(strings.Split(mimeType, ";")) > 1 {
// If Content-Type has parameters, take the first part
mimeType = strings.Split(mimeType, ";")[0]
}
if mimeType == "application/octet-stream" {
if common.DebugEnabled {
println("MIME type is application/octet-stream, trying to guess from URL or filename")
}
// try to guess the MIME type from the url last segment
urlParts := strings.Split(url, "/")
if len(urlParts) > 0 {
lastSegment := urlParts[len(urlParts)-1]
if strings.Contains(lastSegment, ".") {
// Extract the file extension
filename := strings.Split(lastSegment, ".")
if len(filename) > 1 {
ext := strings.ToLower(filename[len(filename)-1])
// Guess MIME type based on file extension
mimeType = GetMimeTypeByExtension(ext)
}
}
} else {
// try to guess the MIME type from the file extension
fileName := resp.Header.Get("Content-Disposition")
if fileName != "" {
// Extract the filename from the Content-Disposition header
parts := strings.Split(fileName, ";")
for _, part := range parts {
if strings.HasPrefix(strings.TrimSpace(part), "filename=") {
fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename="))
// Remove quotes if present
if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' {
fileName = fileName[1 : len(fileName)-1]
}
// Guess MIME type based on file extension
if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" {
mimeType = GetMimeTypeByExtension(ext)
}
break
}
}
}
}
}
return &dto.LocalFileData{
Base64Data: base64Data,
MimeType: resp.Header.Get("Content-Type"),
MimeType: mimeType,
Size: int64(len(fileBytes)),
}, nil
}
func GetMimeTypeByExtension(ext string) string {
// Convert to lowercase for case-insensitive comparison
ext = strings.ToLower(ext)
switch ext {
// Text files
case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm":
return "text/plain"
// Image files
case "jpg", "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
// Audio files
case "mp3":
return "audio/mp3"
case "wav":
return "audio/wav"
case "mpeg":
return "audio/mpeg"
// Video files
case "mp4":
return "video/mp4"
case "wmv":
return "video/wmv"
case "flv":
return "video/flv"
case "mov":
return "video/mov"
case "mpg":
return "video/mpg"
case "avi":
return "video/avi"
case "mpegps":
return "video/mpegps"
// Document files
case "pdf":
return "application/pdf"
default:
return "application/octet-stream" // Default for unknown types
}
}
+8 -7
View File
@@ -8,7 +8,7 @@ import (
)
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, modelPrice float64) map[string]interface{} {
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
other := make(map[string]interface{})
other["model_ratio"] = modelRatio
other["group_ratio"] = groupRatio
@@ -16,6 +16,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["cache_tokens"] = cacheTokens
other["cache_ratio"] = cacheRatio
other["model_price"] = modelPrice
other["user_group_ratio"] = userGroupRatio
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.ReasoningEffort != "" {
other["reasoning_effort"] = relayInfo.ReasoningEffort
@@ -30,8 +31,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
return other
}
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info["ws"] = true
info["audio_input"] = usage.InputTokenDetails.AudioTokens
info["audio_output"] = usage.OutputTokenDetails.AudioTokens
@@ -42,8 +43,8 @@ func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
return info
}
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info["audio"] = true
info["audio_input"] = usage.PromptTokensDetails.AudioTokens
info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
@@ -55,8 +56,8 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
info["claude"] = true
info["cache_creation_tokens"] = cacheCreationTokens
info["cache_creation_ratio"] = cacheCreationRatio
+26 -9
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"log"
"math"
"one-api/common"
constant2 "one-api/constant"
@@ -97,6 +98,19 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
groupRatio := setting.GetGroupRatio(relayInfo.Group)
modelRatio, _ := operation_setting.GetModelRatio(modelName)
autoGroup, exists := ctx.Get("auto_group")
if exists {
groupRatio = setting.GetGroupRatio(autoGroup.(string))
log.Printf("final group ratio: %f", groupRatio)
relayInfo.Group = autoGroup.(string)
}
actualGroupRatio := groupRatio
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
if ok {
actualGroupRatio = userGroupRatio
}
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
@@ -109,7 +123,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
ModelName: modelName,
UsePrice: relayInfo.UsePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
GroupRatio: actualGroupRatio,
}
quota := calculateAudioQuota(quotaInfo)
@@ -131,8 +145,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
}
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, modelRatio float64, groupRatio float64,
modelPrice float64, usePrice bool, extraContent string) {
usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) {
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.InputTokenDetails.TextTokens
@@ -146,6 +159,11 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName))
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
modelPrice := priceData.ModelPrice
usePrice := priceData.UsePrice
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
@@ -190,7 +208,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
logContent += ", " + extraContent
}
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
@@ -206,9 +224,8 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
tokenName := ctx.GetString("token_name")
completionRatio := priceData.CompletionRatio
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
modelPrice := priceData.ModelPrice
cacheRatio := priceData.CacheRatio
cacheTokens := usage.PromptTokensDetails.CachedTokens
@@ -262,7 +279,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice)
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
@@ -304,7 +321,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(relayInfo.OriginModelName))
modelRatio := priceData.ModelRatio
groupRatio := priceData.GroupRatio
groupRatio := priceData.GroupRatioInfo.GroupRatio
modelPrice := priceData.ModelPrice
usePrice := priceData.UsePrice
@@ -360,7 +377,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
logContent += ", " + extraContent
}
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
+31
View File
@@ -0,0 +1,31 @@
package setting
import "encoding/json"
var AutoGroups = []string{
"default",
}
var DefaultUseAutoGroup = false
func ContainsAutoGroup(group string) bool {
for _, autoGroup := range AutoGroups {
if autoGroup == group {
return true
}
}
return false
}
func UpdateAutoGroupsByJsonString(jsonString string) error {
AutoGroups = make([]string, 0)
return json.Unmarshal([]byte(jsonString), &AutoGroups)
}
func AutoGroups2JsonString() string {
jsonBytes, err := json.Marshal(AutoGroups)
if err != nil {
return "[]"
}
return string(jsonBytes)
}
-327
View File
@@ -1,327 +0,0 @@
package setting
import (
"encoding/json"
"fmt"
"net/url"
"one-api/common"
"regexp"
"sort"
"strings"
"time"
)
// ValidateConsoleSettings 验证控制台设置信息格式
func ValidateConsoleSettings(settingsStr string, settingType string) error {
if settingsStr == "" {
return nil // 空字符串是合法的
}
switch settingType {
case "ApiInfo":
return validateApiInfo(settingsStr)
case "Announcements":
return validateAnnouncements(settingsStr)
case "FAQ":
return validateFAQ(settingsStr)
default:
return fmt.Errorf("未知的设置类型:%s", settingType)
}
}
// validateApiInfo 验证API信息格式
func validateApiInfo(apiInfoStr string) error {
var apiInfoList []map[string]interface{}
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
return fmt.Errorf("API信息格式错误:%s", err.Error())
}
// 验证数组长度
if len(apiInfoList) > 50 {
return fmt.Errorf("API信息数量不能超过50个")
}
// 允许的颜色值
validColors := map[string]bool{
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
"violet": true, "grey": true,
}
// URL正则表达式,支持域名和IP地址格式
// 域名格式:https://example.com 或 https://sub.example.com:8080
// IP地址格式:https://192.168.1.1 或 https://192.168.1.1:8080
urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?::[0-9]{1,5})?(?:/.*)?$`)
for i, apiInfo := range apiInfoList {
// 检查必填字段
urlStr, ok := apiInfo["url"].(string)
if !ok || urlStr == "" {
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
}
route, ok := apiInfo["route"].(string)
if !ok || route == "" {
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
}
description, ok := apiInfo["description"].(string)
if !ok || description == "" {
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
}
color, ok := apiInfo["color"].(string)
if !ok || color == "" {
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
}
// 验证URL格式
if !urlRegex.MatchString(urlStr) {
return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
}
// 验证URL可解析性
if _, err := url.Parse(urlStr); err != nil {
return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
}
// 验证字段长度
if len(urlStr) > 500 {
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
}
if len(route) > 100 {
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
}
if len(description) > 200 {
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
}
// 验证颜色值
if !validColors[color] {
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
}
// 检查并过滤危险字符(防止XSS
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(description), dangerous) {
return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(route), dangerous) {
return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
}
}
}
return nil
}
// ValidateApiInfo 保持向后兼容的函数
func ValidateApiInfo(apiInfoStr string) error {
return validateApiInfo(apiInfoStr)
}
// GetApiInfo 获取API信息列表
func GetApiInfo() []map[string]interface{} {
// 从OptionMap中获取API信息,如果不存在则返回空数组
common.OptionMapRWMutex.RLock()
apiInfoStr, exists := common.OptionMap["ApiInfo"]
common.OptionMapRWMutex.RUnlock()
if !exists || apiInfoStr == "" {
// 如果没有配置,返回空数组
return []map[string]interface{}{}
}
// 解析存储的API信息
var apiInfo []map[string]interface{}
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
// 如果解析失败,返回空数组
return []map[string]interface{}{}
}
return apiInfo
}
// validateAnnouncements 验证系统公告格式
func validateAnnouncements(announcementsStr string) error {
var announcementsList []map[string]interface{}
if err := json.Unmarshal([]byte(announcementsStr), &announcementsList); err != nil {
return fmt.Errorf("系统公告格式错误:%s", err.Error())
}
// 验证数组长度
if len(announcementsList) > 100 {
return fmt.Errorf("系统公告数量不能超过100个")
}
// 允许的类型值
validTypes := map[string]bool{
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
}
for i, announcement := range announcementsList {
// 检查必填字段
content, ok := announcement["content"].(string)
if !ok || content == "" {
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
}
// 检查发布日期字段
publishDate, exists := announcement["publishDate"]
if !exists {
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
}
publishDateStr, ok := publishDate.(string)
if !ok || publishDateStr == "" {
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
}
// 验证ISO日期格式
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
}
// 验证可选字段
if announcementType, exists := announcement["type"]; exists {
if typeStr, ok := announcementType.(string); ok {
if !validTypes[typeStr] {
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
}
}
}
// 验证字段长度
if len(content) > 500 {
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
}
if extra, exists := announcement["extra"]; exists {
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
}
}
// 检查并过滤危险字符(防止XSS
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(content), dangerous) {
return fmt.Errorf("第%d个公告的内容包含不允许的内容", i+1)
}
}
}
return nil
}
// validateFAQ 验证常见问答格式
func validateFAQ(faqStr string) error {
var faqList []map[string]interface{}
if err := json.Unmarshal([]byte(faqStr), &faqList); err != nil {
return fmt.Errorf("常见问答格式错误:%s", err.Error())
}
// 验证数组长度
if len(faqList) > 100 {
return fmt.Errorf("常见问答数量不能超过100个")
}
for i, faq := range faqList {
// 检查必填字段
title, ok := faq["title"].(string)
if !ok || title == "" {
return fmt.Errorf("第%d个问答缺少标题字段", i+1)
}
content, ok := faq["content"].(string)
if !ok || content == "" {
return fmt.Errorf("第%d个问答缺少内容字段", i+1)
}
// 验证字段长度
if len(title) > 200 {
return fmt.Errorf("第%d个问答的标题长度不能超过200字符", i+1)
}
if len(content) > 1000 {
return fmt.Errorf("第%d个问答的内容长度不能超过1000字符", i+1)
}
// 检查并过滤危险字符(防止XSS
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(title), dangerous) {
return fmt.Errorf("第%d个问答的标题包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(content), dangerous) {
return fmt.Errorf("第%d个问答的内容包含不允许的内容", i+1)
}
}
}
return nil
}
// GetAnnouncements 获取系统公告列表(返回最新的前20条)
func GetAnnouncements() []map[string]interface{} {
common.OptionMapRWMutex.RLock()
announcementsStr, exists := common.OptionMap["Announcements"]
common.OptionMapRWMutex.RUnlock()
if !exists || announcementsStr == "" {
return []map[string]interface{}{}
}
var announcements []map[string]interface{}
if err := json.Unmarshal([]byte(announcementsStr), &announcements); err != nil {
return []map[string]interface{}{}
}
// 按发布日期降序排序(最新的在前)
sort.Slice(announcements, func(i, j int) bool {
dateI, okI := announcements[i]["publishDate"].(string)
dateJ, okJ := announcements[j]["publishDate"].(string)
if !okI || !okJ {
return false
}
timeI, errI := time.Parse(time.RFC3339, dateI)
timeJ, errJ := time.Parse(time.RFC3339, dateJ)
if errI != nil || errJ != nil {
return false
}
return timeI.After(timeJ)
})
// 限制返回前20条
if len(announcements) > 20 {
announcements = announcements[:20]
}
return announcements
}
// GetFAQ 获取常见问答列表
func GetFAQ() []map[string]interface{} {
common.OptionMapRWMutex.RLock()
faqStr, exists := common.OptionMap["FAQ"]
common.OptionMapRWMutex.RUnlock()
if !exists || faqStr == "" {
return []map[string]interface{}{}
}
var faq []map[string]interface{}
if err := json.Unmarshal([]byte(faqStr), &faq); err != nil {
return []map[string]interface{}{}
}
return faq
}
+39
View File
@@ -0,0 +1,39 @@
package console_setting
import "one-api/setting/config"
type ConsoleSetting struct {
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
}
// 默认配置
var defaultConsoleSetting = ConsoleSetting{
ApiInfo: "",
UptimeKumaGroups: "",
Announcements: "",
FAQ: "",
ApiInfoEnabled: true,
UptimeKumaEnabled: true,
AnnouncementsEnabled: true,
FAQEnabled: true,
}
// 全局实例
var consoleSetting = defaultConsoleSetting
func init() {
// 注册到全局配置管理器,键名为 console_setting
config.GlobalConfig.Register("console_setting", &consoleSetting)
}
// GetConsoleSetting 获取 ConsoleSetting 配置实例
func GetConsoleSetting() *ConsoleSetting {
return &consoleSetting
}
+304
View File
@@ -0,0 +1,304 @@
package console_setting
import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"time"
"sort"
)
var (
urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
validColors = map[string]bool{
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
"violet": true, "grey": true,
}
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
)
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
var list []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
}
return list, nil
}
func validateURL(urlStr string, index int, itemType string) error {
if !urlRegex.MatchString(urlStr) {
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
}
if _, err := url.Parse(urlStr); err != nil {
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
}
return nil
}
func checkDangerousContent(content string, index int, itemType string) error {
lower := strings.ToLower(content)
for _, d := range dangerousChars {
if strings.Contains(lower, d) {
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
}
}
return nil
}
func getJSONList(jsonStr string) []map[string]interface{} {
if jsonStr == "" {
return []map[string]interface{}{}
}
var list []map[string]interface{}
json.Unmarshal([]byte(jsonStr), &list)
return list
}
func ValidateConsoleSettings(settingsStr string, settingType string) error {
if settingsStr == "" {
return nil
}
switch settingType {
case "ApiInfo":
return validateApiInfo(settingsStr)
case "Announcements":
return validateAnnouncements(settingsStr)
case "FAQ":
return validateFAQ(settingsStr)
case "UptimeKumaGroups":
return validateUptimeKumaGroups(settingsStr)
default:
return fmt.Errorf("未知的设置类型:%s", settingType)
}
}
func validateApiInfo(apiInfoStr string) error {
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
if err != nil {
return err
}
if len(apiInfoList) > 50 {
return fmt.Errorf("API信息数量不能超过50个")
}
for i, apiInfo := range apiInfoList {
urlStr, ok := apiInfo["url"].(string)
if !ok || urlStr == "" {
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
}
route, ok := apiInfo["route"].(string)
if !ok || route == "" {
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
}
description, ok := apiInfo["description"].(string)
if !ok || description == "" {
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
}
color, ok := apiInfo["color"].(string)
if !ok || color == "" {
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
}
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
return err
}
if len(urlStr) > 500 {
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
}
if len(route) > 100 {
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
}
if len(description) > 200 {
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
}
if !validColors[color] {
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
}
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
return err
}
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
return err
}
}
return nil
}
func GetApiInfo() []map[string]interface{} {
return getJSONList(GetConsoleSetting().ApiInfo)
}
func validateAnnouncements(announcementsStr string) error {
list, err := parseJSONArray(announcementsStr, "系统公告")
if err != nil {
return err
}
if len(list) > 100 {
return fmt.Errorf("系统公告数量不能超过100个")
}
validTypes := map[string]bool{
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
}
for i, ann := range list {
content, ok := ann["content"].(string)
if !ok || content == "" {
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
}
publishDateAny, exists := ann["publishDate"]
if !exists {
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
}
publishDateStr, ok := publishDateAny.(string)
if !ok || publishDateStr == "" {
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
}
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
}
if t, exists := ann["type"]; exists {
if typeStr, ok := t.(string); ok {
if !validTypes[typeStr] {
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
}
}
}
if len(content) > 500 {
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
}
if extra, exists := ann["extra"]; exists {
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
}
}
}
return nil
}
func validateFAQ(faqStr string) error {
list, err := parseJSONArray(faqStr, "FAQ信息")
if err != nil {
return err
}
if len(list) > 100 {
return fmt.Errorf("FAQ数量不能超过100个")
}
for i, faq := range list {
question, ok := faq["question"].(string)
if !ok || question == "" {
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
}
answer, ok := faq["answer"].(string)
if !ok || answer == "" {
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
}
if len(question) > 200 {
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
}
if len(answer) > 1000 {
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
}
}
return nil
}
func getPublishTime(item map[string]interface{}) time.Time {
if v, ok := item["publishDate"]; ok {
if s, ok2 := v.(string); ok2 {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
}
return time.Time{}
}
func GetAnnouncements() []map[string]interface{} {
list := getJSONList(GetConsoleSetting().Announcements)
sort.SliceStable(list, func(i, j int) bool {
return getPublishTime(list[i]).After(getPublishTime(list[j]))
})
return list
}
func GetFAQ() []map[string]interface{} {
return getJSONList(GetConsoleSetting().FAQ)
}
func validateUptimeKumaGroups(groupsStr string) error {
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
if err != nil {
return err
}
if len(groups) > 20 {
return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
}
nameSet := make(map[string]bool)
for i, group := range groups {
categoryName, ok := group["categoryName"].(string)
if !ok || categoryName == "" {
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
}
if nameSet[categoryName] {
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
}
nameSet[categoryName] = true
urlStr, ok := group["url"].(string)
if !ok || urlStr == "" {
return fmt.Errorf("第%d个分组缺少URL字段", i+1)
}
slug, ok := group["slug"].(string)
if !ok || slug == "" {
return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
}
description, ok := group["description"].(string)
if !ok {
description = ""
}
if err := validateURL(urlStr, i+1, "分组"); err != nil {
return err
}
if len(categoryName) > 50 {
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
}
if len(urlStr) > 500 {
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
}
if len(slug) > 100 {
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
}
if len(description) > 200 {
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
}
if !slugRegex.MatchString(slug) {
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
}
if err := checkDangerousContent(description, i+1, "分组"); err != nil {
return err
}
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
return err
}
}
return nil
}
func GetUptimeKumaGroups() []map[string]interface{} {
return getJSONList(GetConsoleSetting().UptimeKumaGroups)
}
+48 -5
View File
@@ -14,10 +14,19 @@ var groupRatio = map[string]float64{
}
var groupRatioMutex sync.RWMutex
var (
GroupGroupRatio = map[string]map[string]float64{
"vip": {
"edit_this": 0.9,
},
}
groupGroupRatioMutex sync.RWMutex
)
func GetGroupRatioCopy() map[string]float64 {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
groupRatioCopy := make(map[string]float64)
for k, v := range groupRatio {
groupRatioCopy[k] = v
@@ -28,7 +37,7 @@ func GetGroupRatioCopy() map[string]float64 {
func ContainsGroupRatio(name string) bool {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
_, ok := groupRatio[name]
return ok
}
@@ -36,7 +45,7 @@ func ContainsGroupRatio(name string) bool {
func GroupRatio2JSONString() string {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
jsonBytes, err := json.Marshal(groupRatio)
if err != nil {
common.SysError("error marshalling model ratio: " + err.Error())
@@ -47,7 +56,7 @@ func GroupRatio2JSONString() string {
func UpdateGroupRatioByJSONString(jsonStr string) error {
groupRatioMutex.Lock()
defer groupRatioMutex.Unlock()
groupRatio = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &groupRatio)
}
@@ -55,7 +64,7 @@ func UpdateGroupRatioByJSONString(jsonStr string) error {
func GetGroupRatio(name string) float64 {
groupRatioMutex.RLock()
defer groupRatioMutex.RUnlock()
ratio, ok := groupRatio[name]
if !ok {
common.SysError("group ratio not found: " + name)
@@ -64,6 +73,40 @@ func GetGroupRatio(name string) float64 {
return ratio
}
func GetGroupGroupRatio(group, name string) (float64, bool) {
groupGroupRatioMutex.RLock()
defer groupGroupRatioMutex.RUnlock()
gp, ok := GroupGroupRatio[group]
if !ok {
return -1, false
}
ratio, ok := gp[name]
if !ok {
return -1, false
}
return ratio, true
}
func GroupGroupRatio2JSONString() string {
groupGroupRatioMutex.RLock()
defer groupGroupRatioMutex.RUnlock()
jsonBytes, err := json.Marshal(GroupGroupRatio)
if err != nil {
common.SysError("error marshalling group-group ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateGroupGroupRatioByJSONString(jsonStr string) error {
groupGroupRatioMutex.Lock()
defer groupGroupRatioMutex.Unlock()
GroupGroupRatio = make(map[string]map[string]float64)
return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio)
}
func CheckGroupRatio(jsonStr string) error {
checkGroupRatio := make(map[string]float64)
err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio)
+17 -2
View File
@@ -142,6 +142,11 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-preview-04-17": 0.075,
"gemini-2.5-flash-preview-04-17-thinking": 0.075,
"gemini-2.5-flash-preview-04-17-nothinking": 0.075,
"gemini-2.5-flash-preview-05-20": 0.075,
"gemini-2.5-flash-preview-05-20-thinking": 0.075,
"gemini-2.5-flash-preview-05-20-nothinking": 0.075,
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -342,10 +347,20 @@ func UpdateModelRatioByJSONString(jsonStr string) error {
return json.Unmarshal([]byte(jsonStr), &modelRatioMap)
}
// 处理带有思考预算的模型名称,方便统一定价
func handleThinkingBudgetModel(name, prefix, wildcard string) string {
if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") {
return wildcard
}
return name
}
func GetModelRatio(name string) (float64, bool) {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
@@ -470,9 +485,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.0") {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.5-pro-preview") {
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
return 8, true
} else if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 同上
if strings.HasSuffix(name, "-nothinking") {
return 4, false
} else {
+7
View File
@@ -50,3 +50,10 @@ func GroupInUserUsableGroups(groupName string) bool {
_, ok := userUsableGroups[groupName]
return ok
}
func GetUsableGroupDescription(groupName string) string {
if desc, ok := userUsableGroups[groupName]; ok {
return desc
}
return groupName
}
-21
View File
@@ -1,21 +0,0 @@
# React Template
## Basic Usages
```shell
# Runs the app in the development mode
npm start
# Builds the app for production to the `build` folder
npm run build
```
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
for example: `REACT_APP_SERVER=http://your.domain.com`.
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
## Reference
1. https://github.com/OIerDb-ng/OIerDb
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
-2
View File
@@ -37,8 +37,6 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"semantic-ui-offline": "^2.5.0",
"semantic-ui-react": "^2.1.3",
"sse.js": "^2.6.0",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
-5584
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -64,11 +64,7 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="max-h-[60vh] overflow-y-auto pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--semi-color-tertiary) transparent'
}}
className="notice-content-scroll max-h-[60vh] overflow-y-auto pr-2"
/>
);
};
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers';
import React, { useEffect, useState, useMemo } from 'react';
import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
@@ -8,6 +8,16 @@ import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma
const DashboardSetting = () => {
let [inputs, setInputs] = useState({
'console_setting.api_info': '',
'console_setting.announcements': '',
'console_setting.faq': '',
'console_setting.uptime_kuma_groups': '',
'console_setting.api_info_enabled': '',
'console_setting.announcements_enabled': '',
'console_setting.faq_enabled': '',
'console_setting.uptime_kuma_enabled': '',
// 用于迁移检测的旧键,下个版本会删除
ApiInfo: '',
Announcements: '',
FAQ: '',
@@ -16,6 +26,7 @@ const DashboardSetting = () => {
});
let [loading, setLoading] = useState(false);
const [showMigrateModal, setShowMigrateModal] = useState(false); // 下个版本会删除
const getOptions = async () => {
const res = await API.get('/api/option/');
@@ -49,9 +60,52 @@ const DashboardSetting = () => {
onRefresh();
}, []);
// 用于迁移检测的旧键,下个版本会删除
const hasLegacyData = useMemo(() => {
const legacyKeys = ['ApiInfo', 'Announcements', 'FAQ', 'UptimeKumaUrl', 'UptimeKumaSlug'];
return legacyKeys.some(k => inputs[k]);
}, [inputs]);
useEffect(() => {
if (hasLegacyData) {
setShowMigrateModal(true);
}
}, [hasLegacyData]);
const handleMigrate = async () => {
try {
setLoading(true);
await API.post('/api/option/migrate_console_setting');
showSuccess('旧配置迁移完成');
await onRefresh();
setShowMigrateModal(false);
} catch (err) {
console.error(err);
showError('迁移失败: ' + (err.message || '未知错误'));
} finally {
setLoading(false);
}
};
return (
<>
<Spin spinning={loading} size='large'>
{/* 用于迁移检测的旧键模态框,下个版本会删除 */}
<Modal
title="配置迁移确认"
visible={showMigrateModal}
onOk={handleMigrate}
onCancel={() => setShowMigrateModal(false)}
confirmLoading={loading}
okText="确认迁移"
cancelText="取消"
>
<p>检测到旧版本的配置数据是否要迁移到新的配置格式</p>
<p style={{ color: '#f57c00', marginTop: '10px' }}>
<strong>注意</strong>
</p>
</Modal>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
@@ -30,6 +30,9 @@ const OperationSetting = () => {
CompletionRatio: '',
ModelPrice: '',
GroupRatio: '',
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
UserUsableGroups: '',
TopUpLink: '',
'general_setting.docs_link': '',
@@ -74,6 +77,8 @@ const OperationSetting = () => {
if (
item.key === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'GroupGroupRatio' ||
item.key === 'AutoGroups' ||
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
@@ -83,7 +88,8 @@ const OperationSetting = () => {
}
if (
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
['DefaultCollapseSidebar'].includes(item.key) ||
['DefaultUseAutoGroup'].includes(item.key)
) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
+52 -9
View File
@@ -103,6 +103,7 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
const [modelsLoading, setModelsLoading] = useState(true);
const [showWebhookDocs, setShowWebhookDocs] = useState(true);
@@ -147,6 +148,7 @@ const PersonalSetting = () => {
notificationEmail: settings.notification_email || '',
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
});
}
}, [userState?.user?.setting]);
@@ -346,7 +348,7 @@ const PersonalSetting = () => {
const handleNotificationSettingChange = (type, value) => {
setNotificationSettings((prev) => ({
...prev,
[type]: value.target ? value.target.value : value, // 处理 Radio 事件对象
[type]: value.target ? value.target.value !== undefined ? value.target.value : value.target.checked : value, // handle checkbox properly
}));
};
@@ -362,16 +364,17 @@ const PersonalSetting = () => {
notification_email: notificationSettings.notificationEmail,
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
});
if (res.data.success) {
showSuccess(t('通知设置已更新'));
showSuccess(t('设置保存成功'));
await getUserData();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('更新通知设置失败'));
showError(t('设置保存失败'));
}
};
@@ -1063,7 +1066,7 @@ const PersonalSetting = () => {
tab={
<div className="flex items-center">
<Bell size={16} className="mr-2" />
{t('通知设置')}
{t('其他设置')}
</div>
}
itemKey='notification'
@@ -1228,28 +1231,68 @@ const PersonalSetting = () => {
<TabPane
tab={t('价格设置')}
itemKey='price'
>
<div className="py-4">
<div className="space-y-4">
{/* 接受未设置价格模型 */}
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
<Shield size={20} className="text-slate-600" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<Typography.Text strong className="block mb-2">
{t('接受未设置价格模型')}
</Typography.Text>
<div className="text-gray-500 text-sm">
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
</div>
</div>
<Checkbox
checked={notificationSettings.acceptUnsetModelRatioModel}
onChange={(e) =>
handleNotificationSettingChange(
'acceptUnsetModelRatioModel',
e.target.checked,
)
}
className="ml-4"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</TabPane>
<TabPane
tab={t('IP记录')}
itemKey='ip'
>
<div className="py-4">
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
<Shield size={20} className="text-slate-600" />
<ShieldCheck size={20} className="text-slate-600" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<Typography.Text strong className="block mb-2">
{t('接受未设置价格模型')}
{t('记录请求与错误日志 IP')}
</Typography.Text>
<div className="text-gray-500 text-sm">
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
{t('开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址')}
</div>
</div>
<Checkbox
checked={notificationSettings.acceptUnsetModelRatioModel}
checked={notificationSettings.recordIpLog}
onChange={(e) =>
handleNotificationSettingChange(
'acceptUnsetModelRatioModel',
'recordIpLog',
e.target.checked,
)
}
+14 -33
View File
@@ -865,32 +865,22 @@ const ChannelsTable = () => {
tagChannelDates.response_time = tagChannelDates.response_time / 2;
}
}
// data.key = '' + data.id
setChannels(channelDates);
if (channelDates.length >= pageSize) {
setChannelCount(channelDates.length + pageSize);
} else {
setChannelCount(channelDates.length);
}
};
const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => {
const loadChannels = async (page, pageSize, idSort, enableTagMode) => {
setLoading(true);
const res = await API.get(
`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
);
if (res === undefined) {
return;
}
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setChannelFormat(data, enableTagMode);
} else {
let newChannels = [...channels];
newChannels.splice(startIdx * pageSize, data.length, ...data);
setChannelFormat(newChannels, enableTagMode);
}
const { items, total } = data;
setChannelFormat(items, enableTagMode);
setChannelCount(total);
} else {
showError(message);
}
@@ -903,7 +893,6 @@ const ChannelsTable = () => {
channelToCopy.created_time = null;
channelToCopy.balance = 0;
channelToCopy.used_quota = 0;
// 删除可能导致类型不匹配的字段
delete channelToCopy.test_time;
delete channelToCopy.response_time;
if (!channelToCopy) {
@@ -927,7 +916,7 @@ const ChannelsTable = () => {
const refresh = async () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
await loadChannels(activePage, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode);
}
@@ -944,7 +933,7 @@ const ChannelsTable = () => {
setPageSize(localPageSize);
setEnableTagMode(localEnableTagMode);
setEnableBatchDelete(localEnableBatchDelete);
loadChannels(0, localPageSize, localIdSort, localEnableTagMode)
loadChannels(1, localPageSize, localIdSort, localEnableTagMode)
.then()
.catch((reason) => {
showError(reason);
@@ -1052,7 +1041,6 @@ const ChannelsTable = () => {
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
// setActivePage(1);
return;
}
@@ -1191,24 +1179,18 @@ const ChannelsTable = () => {
}
};
let pageData = channels.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
let pageData = channels;
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(channels.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { });
}
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadChannels(0, size, idSort, enableTagMode)
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
@@ -1218,8 +1200,6 @@ const ChannelsTable = () => {
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
// add 'all' option
// res.data.data.unshift('all');
if (res === undefined) {
return;
}
@@ -1514,7 +1494,7 @@ const ChannelsTable = () => {
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(0, pageSize, v, enableTagMode);
loadChannels(activePage, pageSize, v, enableTagMode);
}}
/>
</div>
@@ -1541,7 +1521,8 @@ const ChannelsTable = () => {
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
setEnableTagMode(v);
loadChannels(0, pageSize, idSort, v);
setActivePage(1);
loadChannels(1, pageSize, idSort, v);
}}
/>
</div>
@@ -1703,7 +1684,7 @@ const ChannelsTable = () => {
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: channels.length,
total: channelCount,
}),
onPageSizeChange: (size) => {
handlePageSizeChange(size);
+127 -58
View File
@@ -20,7 +20,7 @@ import {
renderQuota,
stringToColor,
getLogOther,
renderModelTag
renderModelTag,
} from '../../helpers';
import {
@@ -39,15 +39,15 @@ import {
Card,
Typography,
Divider,
Form
Form,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSetting, IconSearch } from '@douyinfe/semi-icons';
import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
import { Route } from 'lucide-react';
const { Text } = Typography;
@@ -192,7 +192,7 @@ const LogsTable = () => {
if (!modelMapped) {
return renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
copyText(event, record.model_name).then((r) => {});
},
});
} else {
@@ -209,7 +209,7 @@ const LogsTable = () => {
</Text>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
copyText(event, record.model_name).then((r) => {});
},
})}
</div>
@@ -220,7 +220,7 @@ const LogsTable = () => {
{renderModelTag(other.upstream_model_name, {
onClick: (event) => {
copyText(event, other.upstream_model_name).then(
(r) => { },
(r) => {},
);
},
})}
@@ -231,7 +231,7 @@ const LogsTable = () => {
>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
copyText(event, record.model_name).then((r) => {});
},
suffixIcon: (
<Route
@@ -260,6 +260,7 @@ const LogsTable = () => {
COMPLETION: 'completion',
COST: 'cost',
RETRY: 'retry',
IP: 'ip',
DETAILS: 'details',
};
@@ -301,6 +302,7 @@ const LogsTable = () => {
[COLUMN_KEYS.COMPLETION]: true,
[COLUMN_KEYS.COST]: true,
[COLUMN_KEYS.RETRY]: isAdminUser,
[COLUMN_KEYS.IP]: true,
[COLUMN_KEYS.DETAILS]: true,
};
};
@@ -485,6 +487,9 @@ const LogsTable = () => {
title: t('用时/首字'),
dataIndex: 'use_time',
render: (text, record, index) => {
if (!(record.type === 2 || record.type === 5)) {
return <></>;
}
if (record.is_stream) {
let other = getLogOther(record.other);
return (
@@ -545,12 +550,45 @@ const LogsTable = () => {
);
},
},
{
key: COLUMN_KEYS.IP,
title: (
<div className="flex items-center gap-1">
{t('IP')}
<Tooltip content={t('只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录')}>
<IconHelpCircle className="text-gray-400 cursor-help" />
</Tooltip>
</div>
),
dataIndex: 'ip',
render: (text, record, index) => {
return (record.type === 2 || record.type === 5) && text ? (
<Tooltip content={text}>
<Tag
color='orange'
size='large'
shape='circle'
onClick={(event) => {
copyText(event, text);
}}
>
{text}
</Tag>
</Tooltip>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.RETRY,
title: t('重试'),
dataIndex: 'retry',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
if (!(record.type === 2 || record.type === 5)) {
return <></>;
}
let content = t('渠道') + `${record.channel}`;
if (record.other !== '') {
let other = JSON.parse(record.other);
@@ -598,21 +636,23 @@ const LogsTable = () => {
}
let content = other?.claude
? renderClaudeModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
return (
<Paragraph
ellipsis={{
@@ -742,7 +782,7 @@ const LogsTable = () => {
group: '',
dateRange: [
timestamp2string(getTodayStartTimestamp()),
timestamp2string(now.getTime() / 1000 + 3600)
timestamp2string(now.getTime() / 1000 + 3600),
],
logType: '0',
};
@@ -763,7 +803,11 @@ const LogsTable = () => {
let start_timestamp = timestamp2string(getTodayStartTimestamp());
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
if (
formValues.dateRange &&
Array.isArray(formValues.dateRange) &&
formValues.dateRange.length === 2
) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
@@ -941,27 +985,27 @@ const LogsTable = () => {
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
undefined,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
});
}
if (logs[i].type === 2) {
@@ -992,6 +1036,7 @@ const LogsTable = () => {
other?.audio_ratio,
other?.audio_completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
);
@@ -1003,6 +1048,7 @@ const LogsTable = () => {
other.model_price,
other.completion_ratio,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
@@ -1016,6 +1062,7 @@ const LogsTable = () => {
other?.model_price,
other?.completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
other?.image || false,
@@ -1066,7 +1113,12 @@ const LogsTable = () => {
} = getFormValues();
// 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
const currentLogType = customLogType !== null ? customLogType : formLogType !== undefined ? formLogType : logType;
const currentLogType =
customLogType !== null
? customLogType
: formLogType !== undefined
? formLogType
: logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
@@ -1093,7 +1145,7 @@ const LogsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
};
const handlePageSizeChange = async (size) => {
@@ -1208,9 +1260,9 @@ const LogsTable = () => {
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
autoComplete='off'
layout='vertical'
trigger='change'
stopValidateWithError={false}
>
<div className='flex flex-col gap-4'>
@@ -1294,12 +1346,24 @@ const LogsTable = () => {
}, 0);
}}
>
<Form.Select.Option value='0'>{t('全部')}</Form.Select.Option>
<Form.Select.Option value='1'>{t('充值')}</Form.Select.Option>
<Form.Select.Option value='2'>{t('消费')}</Form.Select.Option>
<Form.Select.Option value='3'>{t('管理')}</Form.Select.Option>
<Form.Select.Option value='4'>{t('系统')}</Form.Select.Option>
<Form.Select.Option value='5'>{t('错误')}</Form.Select.Option>
<Form.Select.Option value='0'>
{t('全部')}
</Form.Select.Option>
<Form.Select.Option value='1'>
{t('充值')}
</Form.Select.Option>
<Form.Select.Option value='2'>
{t('消费')}
</Form.Select.Option>
<Form.Select.Option value='3'>
{t('管理')}
</Form.Select.Option>
<Form.Select.Option value='4'>
{t('系统')}
</Form.Select.Option>
<Form.Select.Option value='5'>
{t('错误')}
</Form.Select.Option>
</Form.Select>
</div>
@@ -1351,7 +1415,8 @@ const LogsTable = () => {
{...(hasExpandableRows() && {
expandedRowRender: expandRowRender,
expandRowByClick: true,
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
rowExpandable: (record) =>
expandData[record.key] && expandData[record.key].length > 0,
})}
dataSource={logs}
rowKey='key'
@@ -1361,8 +1426,12 @@ const LogsTable = () => {
size='middle'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
image={
<IllustrationNoResult style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
+27 -45
View File
@@ -601,7 +601,7 @@ const LogsTable = () => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logCount, setLogCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false);
@@ -649,69 +649,53 @@ const LogsTable = () => {
};
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + pageSize);
// console.log(logCount);
const enrichLogs = (items) => {
return items.map((log) => ({
...log,
timestamp2string: timestamp2string(log.created_at),
key: '' + log.id,
}));
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
const syncPageData = (payload) => {
const items = enrichLogs(payload.items || []);
setLogs(items);
setLogCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
let url = '';
const loadLogs = async (page = 1, size = pageSize) => {
setLoading(true);
const { channel_id, mj_id, start_timestamp, end_timestamp } = getFormValues();
let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp);
if (isAdminUser) {
url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const url = isAdminUser
? `/api/mj/?p=${page}&page_size=${size}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
: `/api/mj/self/?p=${page}&page_size=${size}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const pageData = logs;
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadLogs(page - 1, pageSize).then((r) => { });
}
loadLogs(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('mj-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
await loadLogs(1, size);
};
const refresh = async () => {
// setLoading(true);
setActivePage(1);
await loadLogs(0, pageSize);
await loadLogs(1, pageSize);
};
const copyText = async (text) => {
@@ -726,7 +710,7 @@ const LogsTable = () => {
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
loadLogs(1, localPageSize).then();
}, []);
useEffect(() => {
@@ -936,7 +920,7 @@ const LogsTable = () => {
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
@@ -962,9 +946,7 @@ const LogsTable = () => {
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
/>
+79 -36
View File
@@ -13,7 +13,8 @@ import {
XCircle,
Minus,
HelpCircle,
Coins
Coins,
Ticket
} from 'lucide-react';
import { ITEMS_PER_PAGE } from '../../constants';
@@ -58,7 +59,16 @@ function renderTimestamp(timestamp) {
const RedemptionsTable = () => {
const { t } = useTranslation();
const renderStatus = (status) => {
const isExpired = (rec) => {
return rec.status === 1 && rec.expired_time !== 0 && rec.expired_time < Math.floor(Date.now() / 1000);
};
const renderStatus = (status, record) => {
if (isExpired(record)) {
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Minus size={14} />}>{t('已过期')}</Tag>
);
}
switch (status) {
case 1:
return (
@@ -101,7 +111,7 @@ const RedemptionsTable = () => {
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
return <div>{renderStatus(text, record)}</div>;
},
},
{
@@ -124,6 +134,13 @@ const RedemptionsTable = () => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('过期时间'),
dataIndex: 'expired_time',
render: (text) => {
return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
},
},
{
title: t('兑换人ID'),
dataIndex: 'used_user_id',
@@ -157,8 +174,7 @@ const RedemptionsTable = () => {
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
if (record.status === 1 && !isExpired(record)) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
@@ -168,7 +184,7 @@ const RedemptionsTable = () => {
manageRedemption(record.id, 'disable', record);
},
});
} else {
} else if (!isExpired(record)) {
moreMenuItems.push({
node: 'item',
name: t('启用'),
@@ -435,7 +451,7 @@ const RedemptionsTable = () => {
};
const handleRow = (record, index) => {
if (record.status !== 1) {
if (record.status !== 1 || isExpired(record)) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
@@ -450,7 +466,7 @@ const RedemptionsTable = () => {
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-orange-500">
<IconEyeOpened className="mr-2" />
<Ticket size={16} className="mr-2" />
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</div>
</div>
@@ -458,39 +474,66 @@ const RedemptionsTable = () => {
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex gap-2 w-full sm:w-auto">
<Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full sm:w-auto"
onClick={() => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
icon={<IconCopy />}
className="!rounded-full w-full sm:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
</Button>
</div>
<Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
type='danger'
icon={<IconDelete />}
className="!rounded-full w-full sm:w-auto"
onClick={() => {
setEditingRedemption({
id: undefined,
Modal.confirm({
title: t('确定清除所有失效兑换码?'),
content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
onOk: async () => {
setLoading(true);
const res = await API.delete('/api/redemption/invalid');
const { success, message, data } = res.data;
if (success) {
showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
await refresh();
} else {
showError(message);
}
setLoading(false);
},
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
icon={<IconCopy />}
className="!rounded-full w-full md:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
{t('清除失效兑换码')}
</Button>
</div>
+35 -51
View File
@@ -451,10 +451,16 @@ const LogsTable = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logCount, setLogCount] = useState(0);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(1, localPageSize).then();
}, []);
let now = new Date();
// 初始化start_timestamp为前一天
@@ -494,67 +500,53 @@ const LogsTable = () => {
};
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + ITEMS_PER_PAGE);
// console.log(logCount);
const enrichLogs = (items) => {
return items.map((log) => ({
...log,
timestamp2string: timestamp2string(log.created_at),
key: '' + log.id,
}));
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
const syncPageData = (payload) => {
const items = enrichLogs(payload.items || []);
setLogs(items);
setLogCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
let url = '';
const loadLogs = async (page = 1, size = pageSize) => {
setLoading(true);
const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues();
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
if (isAdminUser) {
url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
let url = isAdminUser
? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
: `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
let { success, message, data } = res.data;
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
syncPageData(data);
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const pageData = logs;
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
loadLogs(page - 1, pageSize).then((r) => { });
}
loadLogs(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('task-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
await loadLogs(1, size);
};
const refresh = async () => {
setActivePage(1);
await loadLogs(0, pageSize);
await loadLogs(1, pageSize);
};
const copyText = async (text) => {
@@ -565,12 +557,6 @@ const LogsTable = () => {
}
};
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, []);
// 列选择器模态框
const renderColumnSelector = () => {
return (
@@ -763,7 +749,7 @@ const LogsTable = () => {
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
@@ -789,9 +775,7 @@ const LogsTable = () => {
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
/>
+42 -40
View File
@@ -14,6 +14,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Divider,
Dropdown,
Empty,
Form,
@@ -21,7 +22,8 @@ import {
Space,
SplitButtonGroup,
Table,
Tag
Tag,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -36,7 +38,8 @@ import {
Gauge,
HelpCircle,
Infinity,
Coins
Coins,
Key
} from 'lucide-react';
import {
@@ -54,6 +57,8 @@ import {
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
@@ -408,31 +413,20 @@ const TokensTable = () => {
}, 500);
};
const setTokensFormat = (tokens) => {
setTokens(tokens);
if (tokens.length >= pageSize) {
setTokenCount(tokens.length + pageSize);
} else {
setTokenCount(tokens.length);
}
// 将后端返回的数据写入状态
const syncPageData = (payload) => {
setTokens(payload.items || []);
setTokenCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
};
let pageData = tokens.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const loadTokens = async (startIdx) => {
const loadTokens = async (page = 1, size = pageSize) => {
setLoading(true);
const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
const res = await API.get(`/api/token/?p=${page}&size=${size}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setTokensFormat(data);
} else {
let newTokens = [...tokens];
newTokens.splice(startIdx * pageSize, data.length, ...data);
setTokensFormat(newTokens);
}
syncPageData(data);
} else {
showError(message);
}
@@ -440,7 +434,7 @@ const TokensTable = () => {
};
const refresh = async () => {
await loadTokens(activePage - 1);
await loadTokens(1);
};
const copyText = async (text) => {
@@ -473,7 +467,7 @@ const TokensTable = () => {
};
useEffect(() => {
loadTokens(0)
loadTokens(1)
.then()
.catch((reason) => {
showError(reason);
@@ -487,7 +481,7 @@ const TokensTable = () => {
if (idx > -1) {
newDataSource.splice(idx, 1);
setTokensFormat(newDataSource);
setTokens(newDataSource);
}
}
};
@@ -518,7 +512,7 @@ const TokensTable = () => {
} else {
record.status = token.status;
}
setTokensFormat(newTokens);
setTokens(newTokens);
} else {
showError(message);
}
@@ -528,8 +522,7 @@ const TokensTable = () => {
const searchTokens = async () => {
const { searchKeyword, searchToken } = getFormValues();
if (searchKeyword === '' && searchToken === '') {
await loadTokens(0);
setActivePage(1);
await loadTokens(1);
return;
}
setSearching(true);
@@ -538,7 +531,8 @@ const TokensTable = () => {
);
const { success, message, data } = res.data;
if (success) {
setTokensFormat(data);
setTokens(data);
setTokenCount(data.length);
setActivePage(1);
} else {
showError(message);
@@ -561,10 +555,12 @@ const TokensTable = () => {
};
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(tokens.length / pageSize) + 1) {
loadTokens(page - 1).then((r) => { });
}
loadTokens(page, pageSize).then();
};
const handlePageSizeChange = async (size) => {
setPageSize(size);
await loadTokens(1, size);
};
const rowSelection = {
@@ -589,6 +585,15 @@ const TokensTable = () => {
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Key size={16} className="mr-2" />
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
@@ -663,7 +668,7 @@ const TokensTable = () => {
<Button
type="primary"
htmlType="submit"
loading={searching}
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
@@ -707,7 +712,7 @@ const TokensTable = () => {
>
<Table
columns={columns}
dataSource={pageData}
dataSource={tokens}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
@@ -719,12 +724,9 @@ const TokensTable = () => {
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokens.length,
total: tokenCount,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
},
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
loading={loading}
+22
View File
@@ -26,6 +26,7 @@ import {
Space,
Table,
Tag,
Tooltip,
Typography
} from '@douyinfe/semi-ui';
import {
@@ -110,6 +111,27 @@ const UsersTable = () => {
{
title: t('用户名'),
dataIndex: 'username',
render: (text, record) => {
const remark = record.remark;
if (!remark) {
return <span>{text}</span>;
}
const maxLen = 10;
const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark;
return (
<Space spacing={2}>
<span>{text}</span>
<Tooltip content={remark} position="top" showArrow>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: '#10b981' }} />
{displayRemark}
</div>
</Tag>
</Tooltip>
</Space>
);
},
},
{
title: t('分组'),
+1
View File
@@ -83,6 +83,7 @@ export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled
const payload = {
model: inputs.model,
messages: processedMessages,
group: inputs.group,
stream: inputs.stream,
};
+124 -80
View File
@@ -30,7 +30,7 @@ import {
Dify,
Coze,
SiliconCloud,
FastGPT
FastGPT,
} from '@lobehub/icons';
import {
@@ -46,7 +46,7 @@ import {
Gift,
User,
Settings,
CircleUser
CircleUser,
} from 'lucide-react';
// 侧边栏图标颜色映射
@@ -315,7 +315,6 @@ export const getModelCategories = (() => {
};
})();
/**
* 根据渠道类型返回对应的厂商图标
* @param {number} channelType - 渠道类型值
@@ -868,6 +867,30 @@ export function renderQuota(quota, digits = 2) {
return renderNumber(quota);
}
function isValidGroupRatio(ratio) {
return Number.isFinite(ratio) && ratio !== -1;
}
/**
* Helper function to get effective ratio and label
* @param {number} groupRatio - The default group ratio
* @param {number} user_group_ratio - The user-specific group ratio
* @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
*/
function getEffectiveRatio(groupRatio, user_group_ratio) {
const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率')
: i18next.t('分组倍率');
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
return {
ratio: effectiveRatio,
label: ratioLabel,
useUserGroupRatio: useUserGroupRatio
};
}
export function renderModelPrice(
inputTokens,
completionTokens,
@@ -875,6 +898,7 @@ export function renderModelPrice(
modelPrice = -1,
completionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
image = false,
@@ -890,13 +914,17 @@ export function renderModelPrice(
audioInputTokens = 0,
audioInputPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t(
'模型价格:${{price}} * 分组倍率{{ratio}} = ${{total}}',
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
{
price: modelPrice,
ratio: groupRatio,
total: modelPrice * groupRatio,
ratioType: ratioLabel,
},
);
} else {
@@ -1033,11 +1061,12 @@ export function renderModelPrice(
// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
);
@@ -1045,23 +1074,25 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
},
)
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
},
)
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
].join('');
@@ -1091,16 +1122,12 @@ export function renderLogContent(
user_group_ratio,
image = false,
imageRatio = 1.0,
useUserGroupRatio = undefined,
webSearch = false,
webSearchCallCount = 0,
fileSearch = false,
fileSearchCallCount = 0,
) {
const ratioLabel = useUserGroupRatio
? i18next.t('专属倍率')
: i18next.t('分组倍率');
const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
const { ratio, label: ratioLabel, useUserGroupRatio: useUserGroupRatio } = getEffectiveRatio(groupRatio, user_group_ratio);
if (modelPrice !== -1) {
return i18next.t('模型价格 ${{price}}{{ratioType}} {{ratio}}', {
@@ -1149,14 +1176,18 @@ export function renderModelPriceSimple(
modelRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t('价格:${{price}} * 分组{{ratio}}', {
return i18next.t('价格:${{price}} * {{ratioType}}{{ratio}}', {
price: modelPrice,
ratioType: ratioLabel,
ratio: groupRatio,
});
} else {
@@ -1191,8 +1222,9 @@ export function renderModelPriceSimple(
},
);
} else {
return i18next.t('模型: {{ratio}} * 分组: {{groupRatio}}', {
return i18next.t('模型: {{ratio}} * {{ratioType}}{{groupRatio}}', {
ratio: modelRatio,
ratioType: ratioLabel,
groupRatio: groupRatio,
});
}
@@ -1210,17 +1242,21 @@ export function renderAudioModelPrice(
audioRatio,
audioCompletionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
// 1 ratio = $0.002 / 1K tokens
if (modelPrice !== -1) {
return i18next.t(
'模型价格:${{price}} * 分组倍率{{ratio}} = ${{total}}',
'模型价格:${{price}} * {{ratioType}}{{ratio}} = ${{total}}',
{
price: modelPrice,
ratio: groupRatio,
total: modelPrice * groupRatio,
ratioType: ratioLabel,
},
);
} else {
@@ -1245,10 +1281,10 @@ export function renderAudioModelPrice(
let audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
let price = textPrice + audioPrice;
return (
<>
@@ -1304,27 +1340,27 @@ export function renderAudioModelPrice(
<p>
{cacheTokens > 0
? i18next.t(
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)
: i18next.t(
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)}
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)}
</p>
<p>
{i18next.t(
@@ -1374,12 +1410,14 @@ export function renderClaudeModelPrice(
modelPrice = -1,
completionRatio,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
) {
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t(
@@ -1461,33 +1499,35 @@ export function renderClaudeModelPrice(
<p>
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)}
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
ratioType: ratioLabel,
total: price.toFixed(6),
},
)}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
@@ -1501,10 +1541,12 @@ export function renderClaudeLogContent(
completionRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheRatio = 1.0,
cacheCreationRatio = 1.0,
) {
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t('模型价格 ${{price}}{{ratioType}} {{ratio}}', {
@@ -1531,12 +1573,14 @@ export function renderClaudeModelPriceSimple(
modelRatio,
modelPrice = -1,
groupRatio,
user_group_ratio,
cacheTokens = 0,
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
) {
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组');
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
if (modelPrice !== -1) {
return i18next.t('价格:${{price}} * {{ratioType}}{{ratio}}', {
+6 -7
View File
@@ -6,14 +6,13 @@ import { API } from './api';
*/
export async function fetchTokenKeys() {
try {
const response = await API.get('/api/token/?p=0&size=100');
const response = await API.get('/api/token/?p=1&size=10');
const { success, data } = response.data;
if (success) {
const activeTokens = data.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} else {
throw new Error('Failed to fetch token keys');
}
if (!success) throw new Error('Failed to fetch token keys');
const tokenItems = Array.isArray(data) ? data : data.items || [];
const activeTokens = tokenItems.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
+30 -11
View File
@@ -1373,6 +1373,12 @@
"示例": "Example",
"缺省 MaxTokens": "Default MaxTokens",
"启用Claude思考适配(-thinking后缀)": "Enable Claude thinking adaptation (-thinking suffix)",
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,": "Unlike Claude, Gemini's thinking model automatically decides whether to think by default, and can be used normally even without enabling the adaptation model.",
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置。": "If you need billing, it is recommended to set the no-suffix model price according to the thinking price.",
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Supports using gemini-2.5-pro-preview-06-05-thinking-128 format to precisely pass thinking budget.",
"启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation",
"适配-thinking、-thinking-预算数字和-nothinking后缀": "Adapt -thinking, -thinking-budgetNumber, and -nothinking suffixes",
"思考预算占比": "Thinking budget ratio",
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage",
"思考适配 BudgetTokens 百分比": "Thinking adaptation BudgetTokens percentage",
"0.1-1之间的小数": "Decimal between 0.1 and 1",
@@ -1598,6 +1604,7 @@
"请联系管理员在系统设置中配置API信息": "Please contact the administrator to configure API information in the system settings.",
"请联系管理员在系统设置中配置公告信息": "Please contact the administrator to configure notice information in the system settings.",
"请联系管理员在系统设置中配置常见问答": "Please contact the administrator to configure FAQ information in the system settings.",
"请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings.",
"确定要删除此API信息吗?": "Are you sure you want to delete this API information?",
"测速": "Speed Test",
"批量删除": "Batch Delete",
@@ -1628,16 +1635,15 @@
"常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
"暂无常见问答": "No FAQ",
"显示最新20条": "Display latest 20",
"Uptime Kuma 服务地址": "Uptime Kuma service address",
"状态页面 Slug": "Status page slug",
"请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com",
"请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status",
"Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty",
"请输入有效的 URL 地址": "Please enter a valid URL address",
"状态页面 Slug 不能为空": "Status page slug cannot be empty",
"Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens",
"请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address",
"请输入状态页面 Slug": "Please enter the status page slug",
"Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma monitoring category management, you can configure multiple monitoring categories for service status display (maximum 20)",
"添加分类": "Add Category",
"分类名称": "Category Name",
"Uptime Kuma地址": "Uptime Kuma Address",
"状态页面Slug": "Status Page Slug",
"请输入分类名称,如:OpenAI、Claude等": "Please enter the category name, such as: OpenAI, Claude, etc.",
"请输入Uptime Kuma服务地址,如:https://status.example.com": "Please enter the Uptime Kuma service address, such as: https://status.example.com",
"请输入状态页面的Slug,如:my-status": "Please enter the slug for the status page, such as: my-status",
"确定要删除此分类吗?": "Are you sure you want to delete this category?",
"配置": "Configure",
"服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
"服务可用性": "Service Status",
@@ -1646,5 +1652,18 @@
"高延迟": "High latency",
"维护中": "Maintenance",
"暂无监控数据": "No monitoring data",
"请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings."
"IP记录": "IP Record",
"记录请求与错误日志 IP": "Record request and error log IP",
"开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址": "After enabling, only \"consumption\" and \"error\" logs will record your client IP address",
"只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录": "Only when the user sets IP recording, the IP recording of request and error type logs will be performed",
"设置保存成功": "Settings saved successfully",
"设置保存失败": "Settings save failed",
"已新增 {{count}} 个模型:{{list}}": "Added {{count}} models: {{list}}",
"未发现新增模型": "No new models were added",
"令牌用于API访问认证,可以设置额度限制和模型权限。": "Tokens are used for API access authentication, and can set quota limits and model permissions.",
"清除失效兑换码": "Clear invalid redemption codes",
"确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.",
"选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)",
"请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)"
}
+11 -9
View File
@@ -375,23 +375,25 @@ code {
}
/* 隐藏卡片内容区域的滚动条 */
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
.custom-request-textarea textarea::-webkit-scrollbar {
display: none;
}
.card-content-scroll,
.model-settings-scroll,
.thinking-content-scroll,
.custom-request-textarea .semi-input,
.custom-request-textarea textarea {
.custom-request-textarea textarea,
.notice-content-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
.custom-request-textarea textarea::-webkit-scrollbar,
.notice-content-scroll::-webkit-scrollbar {
display: none;
}
/* 图片列表滚动条样式 */
.image-list-scroll::-webkit-scrollbar {
width: 6px;
+13 -6
View File
@@ -385,7 +385,7 @@ const EditChannel = (props) => {
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
let hasError = false;
const addedModels = [];
modelArray.forEach((model) => {
if (model && !localModels.includes(model)) {
@@ -395,17 +395,24 @@ const EditChannel = (props) => {
text: model,
value: model,
});
} else if (model) {
showError(t('某些模型已存在!'));
hasError = true;
addedModels.push(model);
}
});
if (hasError) return;
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
if (addedModels.length > 0) {
showSuccess(
t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length,
list: addedModels.join(', '),
})
);
} else {
showInfo(t('未发现新增模型'));
}
};
return (
+13 -6
View File
@@ -229,7 +229,7 @@ const EditTagModal = (props) => {
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
let hasError = false;
const addedModels = [];
modelArray.forEach((model) => {
// 检查模型是否已存在,且模型名称非空
@@ -241,18 +241,25 @@ const EditTagModal = (props) => {
text: model,
value: model,
});
} else if (model) {
showError('某些模型已存在!');
hasError = true;
addedModels.push(model);
}
});
if (hasError) return; // 如果有错误则终止操作
// 更新状态值
setModelOptions(localModelOptions);
setCustomModel('');
handleInputChange('models', localModels);
if (addedModels.length > 0) {
showSuccess(
t('已新增 {{count}} 个模型:{{list}}', {
count: addedModels.length,
list: addedModels.join(', '),
})
);
} else {
showInfo(t('未发现新增模型'));
}
};
return (
+327 -213
View File
@@ -16,7 +16,8 @@ import {
Tag,
Timeline,
Collapse,
Progress
Progress,
Divider
} from '@douyinfe/semi-ui';
import {
IconRefresh,
@@ -86,10 +87,21 @@ const Detail = (props) => {
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
const FLEX_CENTER_GAP2 = "flex items-center gap-2";
const ILLUSTRATION_SIZE = { width: 96, height: 96 };
// ========== Constants ==========
let now = new Date();
const isAdminUser = isAdmin();
// ========== Panel enable flags ==========
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
const announcementsEnabled = statusState?.status?.announcements_enabled ?? true;
const faqEnabled = statusState?.status?.faq_enabled ?? true;
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
const hasApiInfoPanel = apiInfoEnabled;
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
// ========== Helper Functions ==========
const getDefaultTime = useCallback(() => {
return localStorage.getItem('data_export_default_time') || 'hour';
@@ -206,6 +218,7 @@ const Detail = (props) => {
const announcementScrollRef = useRef(null);
const faqScrollRef = useRef(null);
const uptimeScrollRef = useRef(null);
const uptimeTabScrollRefs = useRef({});
// ========== Additional State for scroll hints ==========
const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
@@ -215,6 +228,7 @@ const Detail = (props) => {
// ========== Uptime data ==========
const [uptimeData, setUptimeData] = useState([]);
const [uptimeLoading, setUptimeLoading] = useState(false);
const [activeUptimeTab, setActiveUptimeTab] = useState('');
// ========== Props Destructuring ==========
const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
@@ -570,6 +584,9 @@ const Detail = (props) => {
const { success, message, data } = res.data;
if (success) {
setUptimeData(data || []);
if (data && data.length > 0 && !activeUptimeTab) {
setActiveUptimeTab(data[0].categoryName);
}
} else {
showError(message);
}
@@ -578,7 +595,7 @@ const Detail = (props) => {
} finally {
setUptimeLoading(false);
}
}, []);
}, [activeUptimeTab]);
const refresh = useCallback(async () => {
await Promise.all([loadQuotaData(), loadUptimeData()]);
@@ -635,10 +652,18 @@ const Detail = (props) => {
checkApiScrollable();
checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
if (uptimeData.length === 1) {
checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
} else if (uptimeData.length > 1 && activeUptimeTab) {
const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab];
if (activeTabRef) {
checkCardScrollable(activeTabRef, setShowUptimeScrollHint);
}
}
}, 100);
return () => clearTimeout(timer);
}, [uptimeData]);
}, [uptimeData, activeUptimeTab]);
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
@@ -874,7 +899,6 @@ const Detail = (props) => {
const announcementData = useMemo(() => {
const announcements = statusState?.status?.announcements || [];
// 处理后台配置的公告数据,自动生成相对时间
return announcements.map(item => ({
...item,
time: getRelativeTime(item.publishDate)
@@ -885,6 +909,67 @@ const Detail = (props) => {
return statusState?.status?.faq || [];
}, [statusState?.status?.faq]);
const renderMonitorList = useCallback((monitors) => {
if (!monitors || monitors.length === 0) {
return (
<div className="flex justify-center items-center py-4">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无监控数据')}
/>
</div>
);
}
const grouped = {};
monitors.forEach((m) => {
const g = m.group || '';
if (!grouped[g]) grouped[g] = [];
grouped[g].push(m);
});
const renderItem = (monitor, idx) => (
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
/>
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
</div>
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
<div className="flex-1">
<Progress
percent={(monitor.uptime || 0) * 100}
showInfo={false}
aria-label={`${monitor.name} uptime`}
stroke={getUptimeStatusColor(monitor.status)}
/>
</div>
</div>
</div>
);
return Object.entries(grouped).map(([gname, list]) => (
<div key={gname || 'default'} className="mb-2">
{gname && (
<>
<div className="text-md font-semibold text-gray-500 px-2 py-1">
{gname}
</div>
<Divider />
</>
)}
{list.map(renderItem)}
</div>
));
}, [t, getUptimeStatusColor, getUptimeStatusText]);
// ========== Hooks - Effects ==========
useEffect(() => {
getUserData();
@@ -1015,10 +1100,10 @@ const Detail = (props) => {
</div>
<div className="mb-4">
<div className={`grid grid-cols-1 gap-4 ${!statusState?.status?.self_use_mode_enabled ? 'lg:grid-cols-4' : ''}`}>
<div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
<Card
{...CARD_PROPS}
className={`shadow-sm !rounded-2xl ${!statusState?.status?.self_use_mode_enabled ? 'lg:col-span-3' : ''}`}
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
<div className={FLEX_CENTER_GAP2}>
@@ -1061,7 +1146,7 @@ const Detail = (props) => {
</div>
</Card>
{!statusState?.status?.self_use_mode_enabled && (
{hasApiInfoPanel && (
<Card
{...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl"
@@ -1118,11 +1203,10 @@ const Detail = (props) => {
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
style={{ padding: '12px' }}
/>
</div>
)}
@@ -1138,220 +1222,250 @@ const Detail = (props) => {
</div>
{/* 系统公告和常见问答卡片 */}
{!statusState?.status?.self_use_mode_enabled && (
{hasInfoPanels && (
<div className="mb-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* 公告卡片 */}
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2"
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
<div className="flex items-center gap-2">
<Bell size={16} />
{t('系统公告')}
<Tag size="small" color="grey" shape="circle">
{t('显示最新20条')}
</Tag>
</div>
{/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs">
{announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
legend.color === 'blue' ? '#3b82f6' :
legend.color === 'green' ? '#10b981' :
legend.color === 'orange' ? '#f59e0b' :
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
}}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</div>
}
>
<div className="card-content-container">
<div
ref={announcementScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
>
{announcementData.length > 0 ? (
<Timeline
mode="alternate"
dataSource={announcementData}
/>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
style={{ padding: '12px' }}
/>
{announcementsEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2"
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
<div className="flex items-center gap-2">
<Bell size={16} />
{t('系统公告')}
<Tag size="small" color="grey" shape="circle">
{t('显示最新20条')}
</Tag>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
/>
</div>
</Card>
{/* 常见问答卡片 */}
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
{t('常见问答')}
</div>
}
>
<div className="card-content-container">
<div
ref={faqScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
>
{faqData.length > 0 ? (
<Collapse
accordion
expandIcon={<IconPlus />}
collapseIcon={<IconMinus />}
>
{faqData.map((item, index) => (
<Collapse.Panel
key={index}
header={item.title}
itemKey={index.toString()}
>
<p>{item.content}</p>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
style={{ padding: '12px' }}
/>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showFaqScrollHint ? 1 : 0 }}
/>
</div>
</Card>
{/* 服务可用性卡片 */}
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Gauge size={16} />
{t('服务可用性')}
</div>
<IconButton
icon={<IconRefresh />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
/>
</div>
}
footer={uptimeData.length > 0 ? (
<Card
shadows="always"
bordered={false}
className="!rounded-full backdrop-blur"
>
<div className="flex flex-wrap gap-3 text-xs justify-center">
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: legend.color }}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</Card>
) : null}
footerStyle={uptimeData.length > 0 ? { marginTop: '-100px' } : undefined}
>
<div className="card-content-container">
<Spin spinning={uptimeLoading}>
<div
ref={uptimeScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
>
{uptimeData.length > 0 ? (
uptimeData.map((monitor, idx) => (
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{
backgroundColor: getUptimeStatusColor(monitor.status)
}}
/>
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
</div>
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
<div className="flex-1">
<Progress
percent={(monitor.uptime || 0) * 100}
showInfo={false}
aria-label={`${monitor.name} uptime`}
stroke={getUptimeStatusColor(monitor.status)}
/>
</div>
</div>
{/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs">
{announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
legend.color === 'blue' ? '#3b82f6' :
legend.color === 'green' ? '#10b981' :
legend.color === 'orange' ? '#f59e0b' :
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
}}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))
))}
</div>
</div>
}
>
<div className="card-content-container">
<div
ref={announcementScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
>
{announcementData.length > 0 ? (
<Timeline
mode="alternate"
dataSource={announcementData}
/>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
style={{ padding: '12px' }}
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
/>
</div>
)}
</div>
</Spin>
<div
className="card-content-fade-indicator"
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
/>
</div>
</Card>
<div
className="card-content-fade-indicator"
style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
/>
</div>
</Card>
)}
{/* 常见问答卡片 */}
{faqEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
{t('常见问答')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="card-content-container">
<div
ref={faqScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
>
{faqData.length > 0 ? (
<Collapse
accordion
expandIcon={<IconPlus />}
collapseIcon={<IconMinus />}
>
{faqData.map((item, index) => (
<Collapse.Panel
key={index}
header={item.question}
itemKey={index.toString()}
>
<p>{item.answer}</p>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
/>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showFaqScrollHint ? 1 : 0 }}
/>
</div>
</Card>
)}
{/* 服务可用性卡片 */}
{uptimeEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Gauge size={16} />
{t('服务可用性')}
</div>
<IconButton
icon={<IconRefresh />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
/>
</div>
}
bodyStyle={{ padding: 0 }}
>
{/* 内容区域 */}
<div className="flex-1 relative">
<Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? (
uptimeData.length === 1 ? (
<div className="card-content-container">
<div
ref={uptimeScrollRef}
className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
>
{renderMonitorList(uptimeData[0].monitors)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
/>
</div>
) : (
<Tabs
type="card"
collapsible
activeKey={activeUptimeTab}
onChange={setActiveUptimeTab}
size="small"
>
{uptimeData.map((group, groupIdx) => {
if (!uptimeTabScrollRefs.current[group.categoryName]) {
uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
}
const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
return (
<TabPane
tab={
<span className="flex items-center gap-2">
<Gauge size={14} />
{group.categoryName}
<Tag
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
size='small'
shape='circle'
>
{group.monitors ? group.monitors.length : 0}
</Tag>
</span>
}
itemKey={group.categoryName}
key={groupIdx}
>
<div className="card-content-container">
<div
ref={tabScrollRef}
className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
>
{renderMonitorList(group.monitors)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
/>
</div>
</TabPane>
);
})}
</Tabs>
)
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
/>
</div>
)}
</Spin>
</div>
{/* 固定在底部的图例 */}
{uptimeData.length > 0 && (
<div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
<div className="flex flex-wrap gap-3 text-xs justify-center">
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: legend.color }}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</div>
)}
</Card>
)}
</div>
</div>
)}
+26 -1
View File
@@ -20,6 +20,8 @@ import {
Typography,
Card,
Tag,
Form,
DatePicker,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
@@ -40,9 +42,10 @@ const EditRedemption = (props) => {
name: '',
quota: 100000,
count: 1,
expired_time: 0,
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs;
const { name, quota, count, expired_time } = inputs;
const handleCancel = () => {
props.handleClose();
@@ -85,6 +88,9 @@ const EditRedemption = (props) => {
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
localInputs.name = name;
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
localInputs.expired_time = 0;
}
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, {
@@ -220,6 +226,25 @@ const EditRedemption = (props) => {
required={!isEdit}
/>
</div>
<div>
<Text strong className="block mb-2">{t('过期时间')}</Text>
<DatePicker
type="dateTime"
placeholder={t('选择过期时间(可选,留空为永久)')}
showClear
value={expired_time ? new Date(expired_time * 1000) : null}
onChange={(value) => {
if (value === null || value === undefined) {
handleInputChange('expired_time', 0);
} else {
const timestamp = Math.floor(value.getTime() / 1000);
handleInputChange('expired_time', timestamp);
}
}}
size="large"
className="!rounded-lg w-full"
/>
</div>
</div>
</Card>
@@ -9,7 +9,8 @@ import {
Divider,
Avatar,
Modal,
Tag
Tag,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -48,6 +49,9 @@ const SettingsAPIInfo = ({ options, refresh }) => {
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态 state
const [panelEnabled, setPanelEnabled] = useState(true);
const colorOptions = [
{ value: 'blue', label: 'blue' },
{ value: 'green', label: 'green' },
@@ -85,7 +89,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
try {
setLoading(true);
const apiInfoJson = JSON.stringify(apiInfoList);
await updateOption('ApiInfo', apiInfoJson);
await updateOption('console_setting.api_info', apiInfoJson);
setHasChanges(false);
} catch (error) {
console.error('API信息更新失败', error);
@@ -185,10 +189,35 @@ const SettingsAPIInfo = ({ options, refresh }) => {
};
useEffect(() => {
if (options.ApiInfo !== undefined) {
parseApiInfo(options.ApiInfo);
const apiInfoStr = options['console_setting.api_info'] ?? options.ApiInfo;
if (apiInfoStr !== undefined) {
parseApiInfo(apiInfoStr);
}
}, [options.ApiInfo]);
}, [options['console_setting.api_info'], options.ApiInfo]);
useEffect(() => {
const enabledStr = options['console_setting.api_info_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.api_info_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.api_info_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const columns = [
{
@@ -324,6 +353,15 @@ const SettingsAPIInfo = ({ options, refresh }) => {
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch
checked={panelEnabled}
onChange={handleToggleEnabled}
/>
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
@@ -367,7 +405,11 @@ const SettingsAPIInfo = ({ options, refresh }) => {
total: apiInfoList.length,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => t(`${total} 条记录,显示第 ${range[0]}-${range[1]}`),
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: apiInfoList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -8,7 +8,8 @@ import {
Empty,
Divider,
Modal,
Tag
Tag,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -47,6 +48,9 @@ const SettingsAnnouncements = ({ options, refresh }) => {
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态
const [panelEnabled, setPanelEnabled] = useState(true);
const typeOptions = [
{ value: 'default', label: t('默认') },
{ value: 'ongoing', label: t('进行中') },
@@ -176,7 +180,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
try {
setLoading(true);
const announcementsJson = JSON.stringify(announcementsList);
await updateOption('Announcements', announcementsJson);
await updateOption('console_setting.announcements', announcementsJson);
setHasChanges(false);
} catch (error) {
console.error('系统公告更新失败', error);
@@ -288,10 +292,35 @@ const SettingsAnnouncements = ({ options, refresh }) => {
};
useEffect(() => {
if (options.Announcements !== undefined) {
parseAnnouncements(options.Announcements);
const annStr = options['console_setting.announcements'] ?? options.Announcements;
if (annStr !== undefined) {
parseAnnouncements(annStr);
}
}, [options.Announcements]);
}, [options['console_setting.announcements'], options.Announcements]);
useEffect(() => {
const enabledStr = options['console_setting.announcements_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.announcements_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.announcements_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
@@ -349,6 +378,12 @@ const SettingsAnnouncements = ({ options, refresh }) => {
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
@@ -392,7 +427,11 @@ const SettingsAnnouncements = ({ options, refresh }) => {
total: announcementsList.length,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => t(`${total} 条记录,显示第 ${range[0]}-${range[1]}`),
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: announcementsList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
+59 -21
View File
@@ -7,7 +7,8 @@ import {
Typography,
Empty,
Divider,
Modal
Modal,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -37,18 +38,21 @@ const SettingsFAQ = ({ options, refresh }) => {
const [loading, setLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [faqForm, setFaqForm] = useState({
title: '',
content: ''
question: '',
answer: ''
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 面板启用状态
const [panelEnabled, setPanelEnabled] = useState(true);
const columns = [
{
title: t('问题标题'),
dataIndex: 'title',
key: 'title',
dataIndex: 'question',
key: 'question',
render: (text) => (
<div style={{
maxWidth: '300px',
@@ -61,8 +65,8 @@ const SettingsFAQ = ({ options, refresh }) => {
},
{
title: t('回答内容'),
dataIndex: 'content',
key: 'content',
dataIndex: 'answer',
key: 'answer',
render: (text) => (
<div style={{
maxWidth: '400px',
@@ -124,7 +128,7 @@ const SettingsFAQ = ({ options, refresh }) => {
try {
setLoading(true);
const faqJson = JSON.stringify(faqList);
await updateOption('FAQ', faqJson);
await updateOption('console_setting.faq', faqJson);
setHasChanges(false);
} catch (error) {
console.error('常见问答更新失败', error);
@@ -137,8 +141,8 @@ const SettingsFAQ = ({ options, refresh }) => {
const handleAddFaq = () => {
setEditingFaq(null);
setFaqForm({
title: '',
content: ''
question: '',
answer: ''
});
setShowFaqModal(true);
};
@@ -146,8 +150,8 @@ const SettingsFAQ = ({ options, refresh }) => {
const handleEditFaq = (faq) => {
setEditingFaq(faq);
setFaqForm({
title: faq.title,
content: faq.content
question: faq.question,
answer: faq.answer
});
setShowFaqModal(true);
};
@@ -169,7 +173,7 @@ const SettingsFAQ = ({ options, refresh }) => {
};
const handleSaveFaq = async () => {
if (!faqForm.title || !faqForm.content) {
if (!faqForm.question || !faqForm.answer) {
showError('请填写完整的问答信息');
return;
}
@@ -226,10 +230,34 @@ const SettingsFAQ = ({ options, refresh }) => {
};
useEffect(() => {
if (options.FAQ !== undefined) {
parseFAQ(options.FAQ);
if (options['console_setting.faq'] !== undefined) {
parseFAQ(options['console_setting.faq']);
}
}, [options.FAQ]);
}, [options['console_setting.faq']]);
useEffect(() => {
const enabledStr = options['console_setting.faq_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.faq_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.faq_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
@@ -287,6 +315,12 @@ const SettingsFAQ = ({ options, refresh }) => {
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
@@ -330,7 +364,11 @@ const SettingsFAQ = ({ options, refresh }) => {
total: faqList.length,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => t(`${total} 条记录,显示第 ${range[0]}-${range[1]}`),
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: faqList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
@@ -368,21 +406,21 @@ const SettingsFAQ = ({ options, refresh }) => {
>
<Form layout='vertical' initValues={faqForm} key={editingFaq ? editingFaq.id : 'new'}>
<Form.Input
field='title'
field='question'
label={t('问题标题')}
placeholder={t('请输入问题标题')}
maxLength={200}
rules={[{ required: true, message: t('请输入问题标题') }]}
onChange={(value) => setFaqForm({ ...faqForm, title: value })}
onChange={(value) => setFaqForm({ ...faqForm, question: value })}
/>
<Form.TextArea
field='content'
field='answer'
label={t('回答内容')}
placeholder={t('请输入回答内容')}
maxCount={1000}
rows={6}
rules={[{ required: true, message: t('请输入回答内容') }]}
onChange={(value) => setFaqForm({ ...faqForm, content: value })}
onChange={(value) => setFaqForm({ ...faqForm, answer: value })}
/>
</Form>
</Modal>
@@ -1,12 +1,23 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import React, { useEffect, useState } from 'react';
import {
Form,
Button,
Space,
Table,
Form,
Typography,
Row,
Col,
Empty,
Divider,
Modal,
Switch
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Activity
} from 'lucide-react';
@@ -18,168 +29,453 @@ const { Text } = Typography;
const SettingsUptimeKuma = ({ options, refresh }) => {
const { t } = useTranslation();
const [uptimeGroupsList, setUptimeGroupsList] = useState([]);
const [showUptimeModal, setShowUptimeModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingGroup, setDeletingGroup] = useState(null);
const [editingGroup, setEditingGroup] = useState(null);
const [modalLoading, setModalLoading] = useState(false);
const [loading, setLoading] = useState(false);
const formApiRef = useRef(null);
const [hasChanges, setHasChanges] = useState(false);
const [uptimeForm, setUptimeForm] = useState({
categoryName: '',
url: '',
slug: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [panelEnabled, setPanelEnabled] = useState(true);
const initValues = useMemo(() => ({
uptimeKumaUrl: options?.UptimeKumaUrl || '',
uptimeKumaSlug: options?.UptimeKumaSlug || ''
}), [options?.UptimeKumaUrl, options?.UptimeKumaSlug]);
useEffect(() => {
if (formApiRef.current) {
formApiRef.current.setValues(initValues, { isOverride: true });
const columns = [
{
title: t('分类名称'),
dataIndex: 'categoryName',
key: 'categoryName',
render: (text) => (
<div style={{
fontWeight: 'bold',
color: 'var(--semi-color-text-0)'
}}>
{text}
</div>
)
},
{
title: t('Uptime Kuma地址'),
dataIndex: 'url',
key: 'url',
render: (text) => (
<div style={{
maxWidth: '300px',
wordBreak: 'break-all',
fontFamily: 'monospace',
color: 'var(--semi-color-primary)'
}}>
{text}
</div>
)
},
{
title: t('状态页面Slug'),
dataIndex: 'slug',
key: 'slug',
render: (text) => (
<div style={{
fontFamily: 'monospace',
color: 'var(--semi-color-text-1)'
}}>
{text}
</div>
)
},
{
title: t('操作'),
key: 'action',
fixed: 'right',
width: 150,
render: (text, record) => (
<Space>
<Button
icon={<Edit size={14} />}
theme='light'
type='tertiary'
size='small'
className="!rounded-full"
onClick={() => handleEditGroup(record)}
>
{t('编辑')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
size='small'
className="!rounded-full"
onClick={() => handleDeleteGroup(record)}
>
{t('删除')}
</Button>
</Space>
)
}
}, [initValues]);
];
const handleSave = async () => {
const api = formApiRef.current;
if (!api) {
showError(t('表单未初始化'));
return;
const updateOption = async (key, value) => {
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
showSuccess('Uptime Kuma配置已更新');
if (refresh) refresh();
} else {
showError(message);
}
};
const submitUptimeGroups = async () => {
try {
setLoading(true);
const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate();
const trimmedUrl = (uptimeKumaUrl || '').trim();
const trimmedSlug = (uptimeKumaSlug || '').trim();
if (trimmedUrl === options?.UptimeKumaUrl && trimmedSlug === options?.UptimeKumaSlug) {
showSuccess(t('无需保存,配置未变动'));
return;
}
const [urlRes, slugRes] = await Promise.all([
trimmedUrl === options?.UptimeKumaUrl ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
key: 'UptimeKumaUrl',
value: trimmedUrl
}),
trimmedSlug === options?.UptimeKumaSlug ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
key: 'UptimeKumaSlug',
value: trimmedSlug
})
]);
if (!urlRes.data.success) throw new Error(urlRes.data.message || t('URL 保存失败'));
if (!slugRes.data.success) throw new Error(slugRes.data.message || t('Slug 保存失败'));
showSuccess(t('Uptime Kuma 设置保存成功'));
refresh?.();
} catch (err) {
console.error(err);
showError(err.message || t('保存失败,请重试'));
const groupsJson = JSON.stringify(uptimeGroupsList);
await updateOption('console_setting.uptime_kuma_groups', groupsJson);
setHasChanges(false);
} catch (error) {
console.error('Uptime Kuma配置更新失败', error);
showError('Uptime Kuma配置更新失败');
} finally {
setLoading(false);
}
};
const isValidUrl = useCallback((string) => {
try {
new URL(string);
return true;
} catch (_) {
return false;
const handleAddGroup = () => {
setEditingGroup(null);
setUptimeForm({
categoryName: '',
url: '',
slug: '',
});
setShowUptimeModal(true);
};
const handleEditGroup = (group) => {
setEditingGroup(group);
setUptimeForm({
categoryName: group.categoryName,
url: group.url,
slug: group.slug,
});
setShowUptimeModal(true);
};
const handleDeleteGroup = (group) => {
setDeletingGroup(group);
setShowDeleteModal(true);
};
const confirmDeleteGroup = () => {
if (deletingGroup) {
const newList = uptimeGroupsList.filter(item => item.id !== deletingGroup.id);
setUptimeGroupsList(newList);
setHasChanges(true);
showSuccess('分类已删除,请及时点击“保存设置”进行保存');
}
}, []);
setShowDeleteModal(false);
setDeletingGroup(null);
};
const handleSaveGroup = async () => {
if (!uptimeForm.categoryName || !uptimeForm.url || !uptimeForm.slug) {
showError('请填写完整的分类信息');
return;
}
try {
new URL(uptimeForm.url);
} catch (error) {
showError('请输入有效的URL地址');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(uptimeForm.slug)) {
showError('Slug只能包含字母、数字、下划线和连字符');
return;
}
try {
setModalLoading(true);
let newList;
if (editingGroup) {
newList = uptimeGroupsList.map(item =>
item.id === editingGroup.id
? { ...item, ...uptimeForm }
: item
);
} else {
const newId = Math.max(...uptimeGroupsList.map(item => item.id), 0) + 1;
const newGroup = {
id: newId,
...uptimeForm
};
newList = [...uptimeGroupsList, newGroup];
}
setUptimeGroupsList(newList);
setHasChanges(true);
setShowUptimeModal(false);
showSuccess(editingGroup ? '分类已更新,请及时点击“保存设置”进行保存' : '分类已添加,请及时点击“保存设置”进行保存');
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
setModalLoading(false);
}
};
const parseUptimeGroups = (groupsStr) => {
if (!groupsStr) {
setUptimeGroupsList([]);
return;
}
try {
const parsed = JSON.parse(groupsStr);
const list = Array.isArray(parsed) ? parsed : [];
const listWithIds = list.map((item, index) => ({
...item,
id: item.id || index + 1
}));
setUptimeGroupsList(listWithIds);
} catch (error) {
console.error('解析Uptime Kuma配置失败:', error);
setUptimeGroupsList([]);
}
};
useEffect(() => {
const groupsStr = options['console_setting.uptime_kuma_groups'];
if (groupsStr !== undefined) {
parseUptimeGroups(groupsStr);
}
}, [options['console_setting.uptime_kuma_groups']]);
useEffect(() => {
const enabledStr = options['console_setting.uptime_kuma_enabled'];
setPanelEnabled(enabledStr === undefined ? true : enabledStr === 'true' || enabledStr === true);
}, [options['console_setting.uptime_kuma_enabled']]);
const handleToggleEnabled = async (checked) => {
const newValue = checked ? 'true' : 'false';
try {
const res = await API.put('/api/option/', {
key: 'console_setting.uptime_kuma_enabled',
value: newValue,
});
if (res.data.success) {
setPanelEnabled(checked);
showSuccess(t('设置已保存'));
refresh?.();
} else {
showError(res.data.message);
}
} catch (err) {
showError(err.message);
}
};
const handleBatchDelete = () => {
if (selectedRowKeys.length === 0) {
showError('请先选择要删除的分类');
return;
}
const newList = uptimeGroupsList.filter(item => !selectedRowKeys.includes(item.id));
setUptimeGroupsList(newList);
setSelectedRowKeys([]);
setHasChanges(true);
showSuccess(`已删除 ${selectedRowKeys.length} 个分类,请及时点击“保存设置”进行保存`);
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-2">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Activity size={16} className="mr-2" />
<Text>
{t('配置')}&nbsp;
<a
href="https://github.com/louislam/uptime-kuma"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Uptime&nbsp;Kuma
</a>
&nbsp;{t('服务监控地址,用于展示服务状态信息')}
</Text>
<Text>{t('Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)')}</Text>
</div>
</div>
<div className="flex gap-2">
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="!rounded-full w-full md:w-auto"
onClick={handleAddGroup}
>
{t('添加分类')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
className="!rounded-full w-full md:w-auto"
>
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
<Button
icon={<Save size={14} />}
theme='solid'
type='primary'
onClick={handleSave}
onClick={submitUptimeGroups}
loading={loading}
className="!rounded-full"
disabled={!hasChanges}
type='secondary'
className="!rounded-full w-full md:w-auto"
>
{t('保存设置')}
</Button>
</div>
{/* 启用开关 */}
<div className="order-1 md:order-2 flex items-center gap-2">
<Switch checked={panelEnabled} onChange={handleToggleEnabled} />
<Text>{panelEnabled ? t('已启用') : t('已禁用')}</Text>
</div>
</div>
</div>
);
const getCurrentPageData = () => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return uptimeGroupsList.slice(startIndex, endIndex);
};
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
onSelect: (record, selected, selectedRows) => {
console.log(`选择行: ${selected}`, record);
},
onSelectAll: (selected, selectedRows) => {
console.log(`全选: ${selected}`, selectedRows);
},
getCheckboxProps: (record) => ({
disabled: false,
name: record.id,
}),
};
return (
<Form.Section text={renderHeader()}>
<Form
layout="vertical"
autoScrollToError
initValues={initValues}
getFormApi={(api) => {
formApiRef.current = api;
<>
<Form.Section text={renderHeader()}>
<Table
columns={columns}
dataSource={getCurrentPageData()}
rowSelection={rowSelection}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: uptimeGroupsList.length,
showSizeChanger: true,
showQuickJumper: true,
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: uptimeGroupsList.length,
}),
pageSizeOptions: ['5', '10', '20', '50'],
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (current, size) => {
setCurrentPage(1);
setPageSize(size);
}
}}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('暂无监控数据')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
/>
</Form.Section>
<Modal
title={editingGroup ? t('编辑分类') : t('添加分类')}
visible={showUptimeModal}
onOk={handleSaveGroup}
onCancel={() => setShowUptimeModal(false)}
okText={t('保存')}
cancelText={t('取消')}
className="rounded-xl"
confirmLoading={modalLoading}
width={600}
>
<Form layout='vertical' initValues={uptimeForm} key={editingGroup ? editingGroup.id : 'new'}>
<Form.Input
field='categoryName'
label={t('分类名称')}
placeholder={t('请输入分类名称,如:OpenAI、Claude等')}
maxLength={50}
rules={[{ required: true, message: t('请输入分类名称') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, categoryName: value })}
/>
<Form.Input
field='url'
label={t('Uptime Kuma地址')}
placeholder={t('请输入Uptime Kuma服务地址,如:https://status.example.com')}
maxLength={500}
rules={[{ required: true, message: t('请输入Uptime Kuma地址') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, url: value })}
/>
<Form.Input
field='slug'
label={t('状态页面Slug')}
placeholder={t('请输入状态页面的Slug,如:my-status')}
maxLength={100}
rules={[{ required: true, message: t('请输入状态页面Slug') }]}
onChange={(value) => setUptimeForm({ ...uptimeForm, slug: value })}
/>
</Form>
</Modal>
<Modal
title={t('确认删除')}
visible={showDeleteModal}
onOk={confirmDeleteGroup}
onCancel={() => {
setShowDeleteModal(false);
setDeletingGroup(null);
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
className="rounded-xl"
okButtonProps={{
type: 'danger',
theme: 'solid'
}}
>
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Form.Input
showClear
field="uptimeKumaUrl"
label={{ text: t("Uptime Kuma 服务地址") }}
placeholder={t("请输入 Uptime Kuma 服务地址")}
style={{ fontFamily: 'monospace' }}
helpText={t("请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com")}
rules={[
{
validator: (_, value) => {
const url = (value || '').trim();
if (url && !isValidUrl(url)) {
return Promise.reject(t('请输入有效的 URL 地址'));
}
return Promise.resolve();
}
}
]}
/>
</Col>
<Col xs={24} md={12}>
<Form.Input
showClear
field="uptimeKumaSlug"
label={{ text: t("状态页面 Slug") }}
placeholder={t("请输入状态页面 Slug")}
style={{ fontFamily: 'monospace' }}
helpText={t("请输入状态页面的 slug 标识符,例如:my-status")}
rules={[
{
validator: (_, value) => {
const slug = (value || '').trim();
if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) {
return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符'));
}
return Promise.resolve();
}
}
]}
/>
</Col>
</Row>
</Form>
</Form.Section>
<Text>{t('确定要删除此分类吗?')}</Text>
</Modal>
</>
);
};
@@ -173,7 +173,8 @@ export default function SettingGeminiModel(props) {
<Text>
{t(
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用," +
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置"
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置" +
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。"
)}
</Text>
</Col>
@@ -183,7 +184,7 @@ export default function SettingGeminiModel(props) {
<Form.Switch
label={t('启用Gemini思考后缀适配')}
field={'gemini.thinking_adapter_enabled'}
extraText={"适配-thinking-nothinking后缀"}
extraText={t('适配 -thinking、-thinking-预算数字 和 -nothinking 后缀')}
onChange={(value) =>
setInputs({
...inputs,
@@ -205,7 +206,7 @@ export default function SettingGeminiModel(props) {
<Row>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
label={t('请求模型带-thinking后缀的BudgetTokens数(超出24576的部分将被忽略)')}
label={t('思考预算占比')}
field={'gemini.thinking_adapter_budget_tokens_percentage'}
initValue={''}
extraText={t('0.1-1之间的小数')}
@@ -16,6 +16,9 @@ export default function GroupRatioSettings(props) {
const [inputs, setInputs] = useState({
GroupRatio: '',
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
@@ -99,6 +102,9 @@ export default function GroupRatioSettings(props) {
<Form.TextArea
label={t('分组倍率')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
extraText={t(
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5test 分组的倍率为 1',
)}
field={'GroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
@@ -120,6 +126,9 @@ export default function GroupRatioSettings(props) {
<Form.TextArea
label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
extraText={t(
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
)}
field={'UserUsableGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
@@ -136,6 +145,83 @@ export default function GroupRatioSettings(props) {
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('分组特殊倍率')}
placeholder={t('为一个 JSON 文本')}
extraText={t(
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
)}
field={'GroupGroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupGroupRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('自动分组auto,从第一个开始选择')}
placeholder={t('为一个 JSON 文本')}
field={'AutoGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
if (!value || value.trim() === '') {
return true; // Allow empty values
}
// First check if it's valid JSON
try {
const parsed = JSON.parse(value);
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
// Check if every element is a string
return parsed.every(item => typeof item === 'string');
} catch (error) {
return false;
}
},
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, AutoGroups: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch
label={t(
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
)}
field={'DefaultUseAutoGroup'}
onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value })
}
/>
</Col>
</Row>
</Form.Section>
</Form>
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
+233 -129
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import {
API,
@@ -7,7 +7,7 @@ import {
showSuccess,
timestamp2string,
renderGroupOption,
renderQuotaWithPrompt
renderQuotaWithPrompt,
} from '../../helpers';
import {
AutoComplete,
@@ -37,11 +37,13 @@ import {
IconPlusCircle,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../context/Status';
const { Text, Title } = Typography;
const EditToken = (props) => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [isEdit, setIsEdit] = useState(false);
const [loading, setLoading] = useState(isEdit);
const originInputs = {
@@ -119,7 +121,19 @@ const EditToken = (props) => {
value: group,
ratio: info.ratio,
}));
if (statusState?.status?.default_use_auto_group) {
// if contain auto, add it to the first position
if (localGroupOptions.some((group) => group.value === 'auto')) {
// 排序
localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
} else {
localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
}
}
setGroups(localGroupOptions);
if (statusState?.status?.default_use_auto_group) {
setInputs({ ...inputs, group: 'auto' });
}
} else {
showError(t(message));
}
@@ -268,32 +282,37 @@ const EditToken = (props) => {
placement={isEdit ? 'right' : 'left'}
title={
<Space>
{isEdit ?
<Tag color="blue" shape="circle">{t('更新')}</Tag> :
<Tag color="green" shape="circle">{t('新')}</Tag>
}
<Title heading={4} className="m-0">
{isEdit ? (
<Tag color='blue' shape='circle'>
{t('新')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('新建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{isEdit ? t('更新令牌信息') : t('创建新的令牌')}
</Title>
</Space>
}
headerStyle={{
borderBottom: '1px solid var(--semi-color-border)',
padding: '24px'
padding: '24px',
}}
bodyStyle={{
backgroundColor: 'var(--semi-color-bg-0)',
padding: '0'
padding: '0',
}}
visible={props.visiable}
width={isMobile() ? '100%' : 600}
footer={
<div className="flex justify-end bg-white">
<div className='flex justify-end bg-white'>
<Space>
<Button
theme="solid"
size="large"
className="!rounded-full"
theme='solid'
size='large'
className='!rounded-full'
onClick={submit}
icon={<IconSave />}
loading={loading}
@@ -301,10 +320,10 @@ const EditToken = (props) => {
{t('提交')}
</Button>
<Button
theme="light"
size="large"
className="!rounded-full"
type="primary"
theme='light'
size='large'
className='!rounded-full'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
@@ -317,87 +336,107 @@ const EditToken = (props) => {
onCancel={() => handleCancel()}
>
<Spin spinning={loading}>
<div className="p-6">
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
position: 'relative'
}}>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<div className='p-6'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
position: 'relative',
}}
>
<div className='absolute inset-0 overflow-hidden'>
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconPlusCircle size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconPlusCircle size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('基本信息')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的基本信息')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('基本信息')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的基本信息')}
</div>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2">{t('名称')}</Text>
<Text strong className='block mb-2'>
{t('名称')}
</Text>
<Input
placeholder={t('请输入名称')}
onChange={(value) => handleInputChange('name', value)}
value={name}
autoComplete="new-password"
size="large"
className="!rounded-lg"
autoComplete='new-password'
size='large'
className='!rounded-lg'
showClear
required
/>
</div>
<div>
<Text strong className="block mb-2">{t('过期时间')}</Text>
<div className="mb-2">
<Text strong className='block mb-2'>
{t('过期时间')}
</Text>
<div className='mb-2'>
<DatePicker
placeholder={t('请选择过期时间')}
onChange={(value) => handleInputChange('expired_time', value)}
onChange={(value) =>
handleInputChange('expired_time', value)
}
value={expired_time}
autoComplete="new-password"
type="dateTime"
className="w-full !rounded-lg"
size="large"
autoComplete='new-password'
type='dateTime'
className='w-full !rounded-lg'
size='large'
prefix={<IconCalendar />}
/>
</div>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
<Button
theme="light"
type="primary"
theme='light'
type='primary'
onClick={() => setExpiredTime(0, 0, 0, 0)}
className="!rounded-full"
className='!rounded-full'
>
{t('永不过期')}
</Button>
<Button
theme="light"
type="tertiary"
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 0, 1, 0)}
className="!rounded-full"
className='!rounded-full'
icon={<IconClock />}
>
{t('一小时')}
</Button>
<Button
theme="light"
type="tertiary"
theme='light'
type='tertiary'
onClick={() => setExpiredTime(0, 1, 0, 0)}
className="!rounded-full"
className='!rounded-full'
icon={<IconCalendar />}
>
{t('一天')}
</Button>
<Button
theme="light"
type="tertiary"
theme='light'
type='tertiary'
onClick={() => setExpiredTime(1, 0, 0, 0)}
className="!rounded-full"
className='!rounded-full'
icon={<IconCalendar />}
>
{t('一个月')}
@@ -407,44 +446,62 @@ const EditToken = (props) => {
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
position: 'relative'
}}>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
position: 'relative',
}}
>
<div className='absolute inset-0 overflow-hidden'>
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconCreditCard size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconCreditCard size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('额度设置')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌可用额度和数量')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('额度设置')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌可用额度和数量')}
</div>
</div>
</div>
<Banner
type="warning"
description={t('注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。')}
className="mb-4 !rounded-lg"
type='warning'
description={t(
'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。',
)}
className='mb-4 !rounded-lg'
/>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<div className="flex justify-between mb-2">
<div className='flex justify-between mb-2'>
<Text strong>{t('额度')}</Text>
<Text type="tertiary">{renderQuotaWithPrompt(remain_quota)}</Text>
<Text type='tertiary'>
{renderQuotaWithPrompt(remain_quota)}
</Text>
</div>
<AutoComplete
placeholder={t('请输入额度')}
onChange={(value) => handleInputChange('remain_quota', value)}
value={remain_quota}
autoComplete="new-password"
type="number"
size="large"
className="w-full !rounded-lg"
autoComplete='new-password'
type='number'
size='large'
className='w-full !rounded-lg'
prefix={<IconCreditCard />}
data={[
{ value: 500000, label: '1$' },
@@ -460,16 +517,18 @@ const EditToken = (props) => {
{!isEdit && (
<div>
<Text strong className="block mb-2">{t('新建数量')}</Text>
<Text strong className='block mb-2'>
{t('新建数量')}
</Text>
<AutoComplete
placeholder={t('请选择或输入创建令牌的数量')}
onChange={(value) => handleTokenCountChange(value)}
onSelect={(value) => handleTokenCountChange(value)}
value={tokenCount.toString()}
autoComplete="off"
type="number"
className="w-full !rounded-lg"
size="large"
autoComplete='off'
type='number'
className='w-full !rounded-lg'
size='large'
prefix={<IconPlusCircle />}
data={[
{ value: 10, label: t('10个') },
@@ -482,12 +541,12 @@ const EditToken = (props) => {
</div>
)}
<div className="flex justify-end">
<div className='flex justify-end'>
<Button
theme="light"
type={unlimited_quota ? "danger" : "warning"}
theme='light'
type={unlimited_quota ? 'danger' : 'warning'}
onClick={setUnlimitedQuota}
className="!rounded-full"
className='!rounded-full'
>
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
</Button>
@@ -495,92 +554,137 @@ const EditToken = (props) => {
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative',
}}
>
<div className='absolute inset-0 overflow-hidden'>
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconLink size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconLink size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('访问限制')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的访问限制')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('访问限制')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的访问限制')}
</div>
</div>
</div>
<div className="space-y-4">
<div className='space-y-4'>
<div>
<Text strong className="block mb-2">{t('IP白名单')}</Text>
<Text strong className='block mb-2'>
{t('IP白名单')}
</Text>
<TextArea
placeholder={t('允许的IP,一行一个,不填写则不限制')}
onChange={(value) => handleInputChange('allow_ips', value)}
value={inputs.allow_ips}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
className="!rounded-lg"
className='!rounded-lg'
rows={4}
/>
<Text type="tertiary" className="mt-1 block text-xs">{t('请勿过度信任此功能,IP可能被伪造')}</Text>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('请勿过度信任此功能,IP可能被伪造')}
</Text>
</div>
<div>
<div className="flex items-center mb-2">
<div className='flex items-center mb-2'>
<Checkbox
checked={model_limits_enabled}
onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
onChange={(e) =>
handleInputChange(
'model_limits_enabled',
e.target.checked,
)
}
>
<Text strong>{t('模型限制')}</Text>
</Checkbox>
</div>
<Select
placeholder={model_limits_enabled ? t('请选择该渠道所支持的模型') : t('勾选启用模型限制后可选择')}
placeholder={
model_limits_enabled
? t('请选择该渠道所支持的模型')
: t('勾选启用模型限制后可选择')
}
onChange={(value) => handleInputChange('model_limits', value)}
value={inputs.model_limits}
multiple
size="large"
className="w-full !rounded-lg"
size='large'
className='w-full !rounded-lg'
prefix={<IconServer />}
optionList={models}
disabled={!model_limits_enabled}
maxTagCount={3}
/>
<Text type="tertiary" className="mt-1 block text-xs">{t('非必要,不建议启用模型限制')}</Text>
<Text type='tertiary' className='mt-1 block text-xs'>
{t('非必要,不建议启用模型限制')}
</Text>
</div>
</div>
</Card>
<Card className="!rounded-2xl shadow-sm border-0">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
position: 'relative'
}}>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
<Card className='!rounded-2xl shadow-sm border-0'>
<div
className='flex items-center mb-4 p-6 rounded-xl'
style={{
background:
'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
position: 'relative',
}}
>
<div className='absolute inset-0 overflow-hidden'>
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconUserGroup size="large" style={{ color: '#ffffff' }} />
<div className='w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative'>
<IconUserGroup size='large' style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('分组信息')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的分组')}</div>
<div className='relative'>
<Text
style={{ color: '#ffffff' }}
className='text-lg font-medium'
>
{t('分组信息')}
</Text>
<div
style={{ color: '#ffffff' }}
className='text-sm opacity-80'
>
{t('设置令牌的分组')}
</div>
</div>
</div>
<div>
<Text strong className="block mb-2">{t('令牌分组')}</Text>
<Text strong className='block mb-2'>
{t('令牌分组')}
</Text>
{groups.length > 0 ? (
<Select
placeholder={t('令牌分组,默认为用户的分组')}
onChange={(value) => handleInputChange('group', value)}
renderOptionItem={renderGroupOption}
value={inputs.group}
size="large"
className="w-full !rounded-lg"
size='large'
className='w-full !rounded-lg'
prefix={<IconUserGroup />}
optionList={groups}
/>
@@ -588,8 +692,8 @@ const EditToken = (props) => {
<Select
placeholder={t('管理员未设置用户可选分组')}
disabled={true}
size="large"
className="w-full !rounded-lg"
size='large'
className='w-full !rounded-lg'
prefix={<IconUserGroup />}
/>
)}
+17 -1
View File
@@ -16,6 +16,7 @@ import {
IconClose,
IconKey,
IconUserAdd,
IconEdit,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
@@ -27,10 +28,11 @@ const AddUser = (props) => {
username: '',
display_name: '',
password: '',
remark: '',
};
const [inputs, setInputs] = useState(originInputs);
const [loading, setLoading] = useState(false);
const { username, display_name, password } = inputs;
const { username, display_name, password, remark } = inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -175,6 +177,20 @@ const AddUser = (props) => {
required
/>
</div>
<div>
<Text strong className="block mb-2">{t('备注')}</Text>
<Input
placeholder={t('请输入备注(仅管理员可见)')}
onChange={(value) => handleInputChange('remark', value)}
value={remark}
autoComplete="off"
size="large"
className="!rounded-lg"
prefix={<IconEdit />}
showClear
/>
</div>
</div>
</Card>
</div>
+17
View File
@@ -22,6 +22,7 @@ import {
IconLink,
IconUserGroup,
IconPlus,
IconEdit,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
@@ -42,6 +43,7 @@ const EditUser = (props) => {
email: '',
quota: 0,
group: 'default',
remark: '',
});
const [groupOptions, setGroupOptions] = useState([]);
const {
@@ -55,6 +57,7 @@ const EditUser = (props) => {
email,
quota,
group,
remark,
} = inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -247,6 +250,20 @@ const EditUser = (props) => {
showClear
/>
</div>
<div>
<Text strong className="block mb-2">{t('备注')}</Text>
<Input
placeholder={t('请输入备注(仅管理员可见)')}
onChange={(value) => handleInputChange('remark', value)}
value={remark}
autoComplete="off"
size="large"
className="!rounded-lg"
prefix={<IconEdit />}
showClear
/>
</div>
</div>
</Card>

Some files were not shown because too many files have changed in this diff Show More