Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef4531a0f | |||
| cc08347caf | |||
| 221d2b5552 | |||
| e234ad0740 | |||
| 416a357c34 | |||
| d3602cbb39 | |||
| 39fc38662e | |||
| 0cad369fda | |||
| 256b9a9fe5 | |||
| f2dd64a3b1 | |||
| 0900f43c2a | |||
| b785071ca6 | |||
| f80eb95864 | |||
| a976d9d366 | |||
| 1275667175 | |||
| 7630838743 | |||
| dabd9a9a23 | |||
| 06b7b595c6 | |||
| 8fb5cb6686 | |||
| f9971df2ef | |||
| 038202b1f6 | |||
| 6d2f34a889 | |||
| 3a5618ede9 | |||
| 70a4b5765d | |||
| 04d525e317 | |||
| 6220b3ee0f | |||
| 87e90e5219 | |||
| 51086c3ba6 | |||
| 7a4c213b65 | |||
| 610853e9c8 | |||
| 62daf16b19 | |||
| d19ab54e32 | |||
| 8f0f0c0d27 | |||
| 9bf32ef581 | |||
| 9469c4973c | |||
| 4949d986c7 | |||
| 949d462534 | |||
| fc2e2c1aff | |||
| e7506ee9cf | |||
| 13fd901d17 | |||
| 34693e338f | |||
| bce87295b6 | |||
| 0ac7406db8 | |||
| ef9c5b3acb | |||
| c9529d00d5 | |||
| f73da57acb | |||
| 8a1e437ce9 | |||
| 6cac7d96e7 | |||
| 2df9ed2892 | |||
| 6bd4e202a1 | |||
| 9d3b84158c | |||
| 32366d1e1b | |||
| 20e233ea8a | |||
| 9121a593d7 | |||
| d11798cc68 | |||
| 2d305d5bb1 | |||
| 1ad9c97d7c | |||
| e5fe592953 | |||
| 2c20827a27 | |||
| d084083e05 | |||
| 877529ef6b | |||
| 2dee2300eb | |||
| 131b62d065 | |||
| 93fb5bab6b | |||
| 45543411cb | |||
| e69f4199ea | |||
| 8da8c2f503 | |||
| 49458a3a64 | |||
| ff85dfd851 | |||
| 7de52a0c0d | |||
| 1add4bf937 | |||
| 3019336161 | |||
| cfef9a3b09 | |||
| 8248185e33 | |||
| 6f4f05f284 | |||
| d244915111 | |||
| 4307065c6c | |||
| 277ffdcb76 | |||
| 23cd90a803 | |||
| 5e501c22f1 | |||
| d09292bdd9 | |||
| 01064fb5d0 | |||
| 2ccae2a281 | |||
| 4e9d8f1c46 | |||
| 3170f26a64 | |||
| 726c8a7110 | |||
| c60d87067d | |||
| 40136f6267 | |||
| c9712a0a47 | |||
| 58358e38c6 | |||
| cb3658b61b | |||
| caf71c9793 | |||
| 9f3cb43905 | |||
| d54930efef | |||
| ff3faf735f | |||
| 97e51e442f | |||
| 50907a5758 | |||
| 984152c772 | |||
| 685a65c722 | |||
| ecacff87ad | |||
| c787f59822 | |||
| 33e46cabb6 | |||
| 9bc9d40891 | |||
| 49592897d5 | |||
| d8790963ae | |||
| 9cfc7ad3bd | |||
| 342af15f73 | |||
| 0a3414bf26 | |||
| 1cff048cb7 | |||
| 1ce5c34703 | |||
| 3e3b1d0ae3 | |||
| dde8c49a2e | |||
| c0df38767c | |||
| ce21b247bd | |||
| 9369c4e3b7 | |||
| 607d5fc25e | |||
| 3654169939 |
+1
-1
@@ -59,7 +59,7 @@
|
||||
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
|
||||
# DIFY_DEBUG=true
|
||||
# 设置流式一次回复的超时时间
|
||||
# STREAMING_TIMEOUT=90
|
||||
# STREAMING_TIMEOUT=120
|
||||
|
||||
|
||||
# 节点类型
|
||||
|
||||
+1
-2
@@ -24,8 +24,7 @@ RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-
|
||||
|
||||
FROM alpine
|
||||
|
||||
RUN apk update \
|
||||
&& apk upgrade \
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --no-cache ca-certificates tzdata ffmpeg \
|
||||
&& update-ca-certificates
|
||||
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay
|
||||
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 60 seconds
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
|
||||
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
||||
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
|
||||
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
||||
|
||||
@@ -103,7 +103,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认60秒
|
||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
|
||||
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
||||
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
|
||||
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
||||
|
||||
@@ -242,6 +242,7 @@ const (
|
||||
ChannelTypeXai = 48
|
||||
ChannelTypeCoze = 49
|
||||
ChannelTypeKling = 50
|
||||
ChannelTypeJimeng = 51
|
||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||
|
||||
)
|
||||
@@ -298,4 +299,5 @@ var ChannelBaseURLs = []string{
|
||||
"https://api.x.ai", //48
|
||||
"https://api.coze.cn", //49
|
||||
"https://api.klingai.com", //50
|
||||
"https://visual.volcengineapi.com", //51
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ const (
|
||||
ContextKeyUserStatus = "user_status"
|
||||
ContextKeyUserEmail = "user_email"
|
||||
ContextKeyUserGroup = "user_group"
|
||||
ContextKeyUsingGroup = "group"
|
||||
)
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ var ErrorLogEnabled bool
|
||||
//}
|
||||
|
||||
func InitEnv() {
|
||||
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60)
|
||||
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 120)
|
||||
DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
||||
MaxFileDownloadMB = common.GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
|
||||
@@ -6,6 +6,7 @@ const (
|
||||
TaskPlatformSuno TaskPlatform = "suno"
|
||||
TaskPlatformMidjourney = "mj"
|
||||
TaskPlatformKling TaskPlatform = "kling"
|
||||
TaskPlatformJimeng TaskPlatform = "jimeng"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/shopspring/decimal"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/service"
|
||||
"one-api/setting"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -304,6 +306,40 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) {
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
|
||||
url := "https://api.moonshot.cn/v1/users/me/balance"
|
||||
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
type MoonshotBalanceData struct {
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
VoucherBalance float64 `json:"voucher_balance"`
|
||||
CashBalance float64 `json:"cash_balance"`
|
||||
}
|
||||
|
||||
type MoonshotBalanceResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data MoonshotBalanceData `json:"data"`
|
||||
Scode string `json:"scode"`
|
||||
Status bool `json:"status"`
|
||||
}
|
||||
|
||||
response := MoonshotBalanceResponse{}
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !response.Status || response.Code != 0 {
|
||||
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
|
||||
}
|
||||
availableBalanceCny := response.Data.AvailableBalance
|
||||
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
|
||||
channel.UpdateBalance(availableBalanceUsd)
|
||||
return availableBalanceUsd, nil
|
||||
}
|
||||
|
||||
func updateChannelBalance(channel *model.Channel) (float64, error) {
|
||||
baseURL := common.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() == "" {
|
||||
@@ -332,6 +368,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
|
||||
return updateChannelDeepSeekBalance(channel)
|
||||
case common.ChannelTypeOpenRouter:
|
||||
return updateChannelOpenRouterBalance(channel)
|
||||
case common.ChannelTypeMoonshot:
|
||||
return updateChannelMoonshotBalance(channel)
|
||||
default:
|
||||
return 0, errors.New("尚未实现")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
|
||||
if channel.Type == common.ChannelTypeKling {
|
||||
return errors.New("kling channel test is not supported"), nil
|
||||
}
|
||||
if channel.Type == common.ChannelTypeJimeng {
|
||||
return errors.New("jimeng channel test is not supported"), nil
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -171,7 +174,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
|
||||
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)
|
||||
quota, "模型测试", 0, quota, int(consumedTime), false, info.UsingGroup, other)
|
||||
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+125
-28
@@ -40,6 +40,17 @@ type OpenAIModelsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func parseStatusFilter(statusParam string) int {
|
||||
switch strings.ToLower(statusParam) {
|
||||
case "enabled", "1":
|
||||
return common.ChannelStatusEnabled
|
||||
case "disabled", "0":
|
||||
return 0
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllChannels(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
@@ -52,6 +63,9 @@ func GetAllChannels(c *gin.Context) {
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
statusParam := c.Query("status")
|
||||
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
// type filter
|
||||
typeStr := c.Query("type")
|
||||
typeFilter := -1
|
||||
@@ -64,42 +78,75 @@ func GetAllChannels(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
if enableTagMode {
|
||||
// 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()})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
}
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
filtered := make([]*model.Channel, 0)
|
||||
for _, ch := range tagChannels {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if typeFilter >= 0 && ch.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = append(channelData, filtered...)
|
||||
}
|
||||
// 计算 tag 总数用于分页
|
||||
total, _ = model.CountAllTags()
|
||||
} else if typeFilter >= 0 {
|
||||
channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
channelData = channels
|
||||
total, _ = model.CountChannelsByType(typeFilter)
|
||||
} else {
|
||||
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
|
||||
baseQuery := model.DB.Model(&model.Channel{})
|
||||
if typeFilter >= 0 {
|
||||
baseQuery = baseQuery.Where("type = ?", typeFilter)
|
||||
}
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
|
||||
baseQuery.Count(&total)
|
||||
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
|
||||
err := baseQuery.Order(order).Limit(pageSize).Offset((p-1)*pageSize).Omit("key").Find(&channelData).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
channelData = channels
|
||||
total, _ = model.CountAllChannels()
|
||||
}
|
||||
|
||||
// calculate type counts
|
||||
typeCounts, _ := model.CountChannelsGroupByType()
|
||||
countQuery := model.DB.Model(&model.Channel{})
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
var results []struct {
|
||||
Type int64
|
||||
Count int64
|
||||
}
|
||||
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, r := range results {
|
||||
typeCounts[r.Type] = r.Count
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -134,13 +181,6 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
//if channel.Type != common.ChannelTypeOpenAI {
|
||||
// c.JSON(http.StatusOK, gin.H{
|
||||
// "success": false,
|
||||
// "message": "仅支持 OpenAI 类型渠道",
|
||||
// })
|
||||
// return
|
||||
//}
|
||||
baseURL := common.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
@@ -206,6 +246,8 @@ func SearchChannels(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
group := c.Query("group")
|
||||
modelKeyword := c.Query("model")
|
||||
statusParam := c.Query("status")
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
channelData := make([]*model.Channel, 0)
|
||||
@@ -238,17 +280,71 @@ func SearchChannels(c *gin.Context) {
|
||||
channelData = channels
|
||||
}
|
||||
|
||||
if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 {
|
||||
filtered := make([]*model.Channel, 0, len(channelData))
|
||||
for _, ch := range channelData {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = filtered
|
||||
}
|
||||
|
||||
// calculate type counts for search results
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, channel := range channelData {
|
||||
typeCounts[int64(channel.Type)]++
|
||||
}
|
||||
|
||||
typeParam := c.Query("type")
|
||||
typeFilter := -1
|
||||
if typeParam != "" {
|
||||
if tp, err := strconv.Atoi(typeParam); err == nil {
|
||||
typeFilter = tp
|
||||
}
|
||||
}
|
||||
|
||||
if typeFilter >= 0 {
|
||||
filtered := make([]*model.Channel, 0, len(channelData))
|
||||
for _, ch := range channelData {
|
||||
if ch.Type == typeFilter {
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
}
|
||||
channelData = filtered
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("p", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
total := len(channelData)
|
||||
startIdx := (page - 1) * pageSize
|
||||
if startIdx > total {
|
||||
startIdx = total
|
||||
}
|
||||
endIdx := startIdx + pageSize
|
||||
if endIdx > total {
|
||||
endIdx = total
|
||||
}
|
||||
|
||||
pagedData := channelData[startIdx:endIdx]
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"items": channelData,
|
||||
"items": pagedData,
|
||||
"total": total,
|
||||
"type_counts": typeCounts,
|
||||
},
|
||||
})
|
||||
@@ -546,6 +642,7 @@ func UpdateChannel(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
channel.Key = ""
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
||||
+163
-11
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -43,7 +44,17 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
|
||||
var upstreams []dto.UpstreamDTO
|
||||
|
||||
if len(req.ChannelIDs) > 0 {
|
||||
if len(req.Upstreams) > 0 {
|
||||
for _, u := range req.Upstreams {
|
||||
if strings.HasPrefix(u.BaseURL, "http") {
|
||||
if u.Endpoint == "" {
|
||||
u.Endpoint = defaultEndpoint
|
||||
}
|
||||
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
|
||||
upstreams = append(upstreams, u)
|
||||
}
|
||||
}
|
||||
} else if len(req.ChannelIDs) > 0 {
|
||||
intIds := make([]int, 0, len(req.ChannelIDs))
|
||||
for _, id64 := range req.ChannelIDs {
|
||||
intIds = append(intIds, int(id64))
|
||||
@@ -57,6 +68,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
for _, ch := range dbChannels {
|
||||
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
|
||||
upstreams = append(upstreams, dto.UpstreamDTO{
|
||||
ID: ch.Id,
|
||||
Name: ch.Name,
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
Endpoint: "",
|
||||
@@ -93,43 +105,125 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
}
|
||||
fullURL := chItem.BaseURL + endpoint
|
||||
|
||||
uniqueName := chItem.Name
|
||||
if chItem.ID != 0 {
|
||||
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
|
||||
ch <- upstreamResult{Name: chItem.Name, Err: err.Error()}
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
|
||||
ch <- upstreamResult{Name: chItem.Name, Err: err.Error()}
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
|
||||
ch <- upstreamResult{Name: chItem.Name, Err: resp.Status}
|
||||
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
|
||||
return
|
||||
}
|
||||
// 兼容两种上游接口格式:
|
||||
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
|
||||
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
||||
ch <- upstreamResult{Name: chItem.Name, Err: err.Error()}
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
}
|
||||
|
||||
if !body.Success {
|
||||
ch <- upstreamResult{Name: chItem.Name, Err: body.Message}
|
||||
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
|
||||
return
|
||||
}
|
||||
ch <- upstreamResult{Name: chItem.Name, Data: body.Data}
|
||||
|
||||
// 尝试按 type1 解析
|
||||
var type1Data map[string]any
|
||||
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
|
||||
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
|
||||
isType1 := false
|
||||
for _, rt := range ratioTypes {
|
||||
if _, ok := type1Data[rt]; ok {
|
||||
isType1 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isType1 {
|
||||
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
|
||||
var pricingItems []struct {
|
||||
ModelName string `json:"model_name"`
|
||||
QuotaType int `json:"quota_type"`
|
||||
ModelRatio float64 `json:"model_ratio"`
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
}
|
||||
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
|
||||
common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
|
||||
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
|
||||
return
|
||||
}
|
||||
|
||||
modelRatioMap := make(map[string]float64)
|
||||
completionRatioMap := make(map[string]float64)
|
||||
modelPriceMap := make(map[string]float64)
|
||||
|
||||
for _, item := range pricingItems {
|
||||
if item.QuotaType == 1 {
|
||||
modelPriceMap[item.ModelName] = item.ModelPrice
|
||||
} else {
|
||||
modelRatioMap[item.ModelName] = item.ModelRatio
|
||||
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
|
||||
completionRatioMap[item.ModelName] = item.CompletionRatio
|
||||
}
|
||||
}
|
||||
|
||||
converted := make(map[string]any)
|
||||
|
||||
if len(modelRatioMap) > 0 {
|
||||
ratioAny := make(map[string]any, len(modelRatioMap))
|
||||
for k, v := range modelRatioMap {
|
||||
ratioAny[k] = v
|
||||
}
|
||||
converted["model_ratio"] = ratioAny
|
||||
}
|
||||
|
||||
if len(completionRatioMap) > 0 {
|
||||
compAny := make(map[string]any, len(completionRatioMap))
|
||||
for k, v := range completionRatioMap {
|
||||
compAny[k] = v
|
||||
}
|
||||
converted["completion_ratio"] = compAny
|
||||
}
|
||||
|
||||
if len(modelPriceMap) > 0 {
|
||||
priceAny := make(map[string]any, len(modelPriceMap))
|
||||
for k, v := range modelPriceMap {
|
||||
priceAny[k] = v
|
||||
}
|
||||
converted["model_price"] = priceAny
|
||||
}
|
||||
|
||||
ch <- upstreamResult{Name: uniqueName, Data: converted}
|
||||
}(chn)
|
||||
}
|
||||
|
||||
@@ -202,6 +296,43 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||
}
|
||||
}
|
||||
|
||||
confidenceMap := make(map[string]map[string]bool)
|
||||
|
||||
// 预处理阶段:检查pricing接口的可信度
|
||||
for _, channel := range successfulChannels {
|
||||
confidenceMap[channel.name] = make(map[string]bool)
|
||||
|
||||
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
|
||||
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
|
||||
|
||||
if hasModelRatio && hasCompletionRatio {
|
||||
// 遍历所有模型,检查是否满足不可信条件
|
||||
for modelName := range allModels {
|
||||
// 默认为可信
|
||||
confidenceMap[channel.name][modelName] = true
|
||||
|
||||
// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
|
||||
if modelRatioVal, ok := modelRatios[modelName]; ok {
|
||||
if completionRatioVal, ok := completionRatios[modelName]; ok {
|
||||
// 转换为float64进行比较
|
||||
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
|
||||
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
|
||||
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
|
||||
confidenceMap[channel.name][modelName] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果不是从pricing接口获取的数据,则全部标记为可信
|
||||
for modelName := range allModels {
|
||||
confidenceMap[channel.name][modelName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for modelName := range allModels {
|
||||
for _, ratioType := range ratioTypes {
|
||||
var localValue interface{} = nil
|
||||
@@ -214,6 +345,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||
}
|
||||
|
||||
upstreamValues := make(map[string]interface{})
|
||||
confidenceValues := make(map[string]bool)
|
||||
hasUpstreamValue := false
|
||||
hasDifference := false
|
||||
|
||||
@@ -241,6 +373,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||
}
|
||||
|
||||
upstreamValues[channel.name] = upstreamValue
|
||||
|
||||
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
|
||||
}
|
||||
|
||||
shouldInclude := false
|
||||
@@ -262,6 +396,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||
differences[modelName][ratioType] = dto.DifferenceItem{
|
||||
Current: localValue,
|
||||
Upstreams: upstreamValues,
|
||||
Confidence: confidenceValues,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,9 +418,26 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||
for chName := range item.Upstreams {
|
||||
if !channelHasDiff[chName] {
|
||||
delete(item.Upstreams, chName)
|
||||
delete(item.Confidence, chName)
|
||||
}
|
||||
}
|
||||
differences[modelName][ratioType] = item
|
||||
|
||||
allSame := true
|
||||
for _, v := range item.Upstreams {
|
||||
if v != "same" {
|
||||
allSame = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(item.Upstreams) == 0 || allSame {
|
||||
delete(ratioMap, ratioType)
|
||||
} else {
|
||||
differences[modelName][ratioType] = item
|
||||
}
|
||||
}
|
||||
|
||||
if len(ratioMap) == 0 {
|
||||
delete(differences, modelName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -74,8 +74,8 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][
|
||||
//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
|
||||
case constant.TaskPlatformSuno:
|
||||
_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
|
||||
case constant.TaskPlatformKling:
|
||||
_ = UpdateVideoTaskAll(context.Background(), taskChannelM, taskM)
|
||||
case constant.TaskPlatformKling, constant.TaskPlatformJimeng:
|
||||
_ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM)
|
||||
default:
|
||||
common.SysLog("未知平台")
|
||||
}
|
||||
|
||||
+60
-62
@@ -2,27 +2,26 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/model"
|
||||
"one-api/relay"
|
||||
"one-api/relay/channel"
|
||||
"time"
|
||||
)
|
||||
|
||||
func UpdateVideoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||
for channelId, taskIds := range taskChannelM {
|
||||
if err := updateVideoTaskAll(ctx, channelId, taskIds, taskM); err != nil {
|
||||
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
|
||||
common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||
common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
return nil
|
||||
@@ -39,7 +38,7 @@ func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, ta
|
||||
}
|
||||
return fmt.Errorf("CacheGetChannel failed: %w", err)
|
||||
}
|
||||
adaptor := relay.GetTaskAdaptor(constant.TaskPlatformKling)
|
||||
adaptor := relay.GetTaskAdaptor(platform)
|
||||
if adaptor == nil {
|
||||
return fmt.Errorf("video adaptor not found")
|
||||
}
|
||||
@@ -56,70 +55,64 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
|
||||
"task_id": taskId,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("FetchTask failed for task %s: %w", taskId, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Get Video Task status code: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReadAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
var responseItem map[string]interface{}
|
||||
err = json.Unmarshal(responseBody, &responseItem)
|
||||
if err != nil {
|
||||
common.LogError(ctx, fmt.Sprintf("Failed to parse video task response body: %v, body: %s", err, string(responseBody)))
|
||||
return fmt.Errorf("Unmarshal failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
code, _ := responseItem["code"].(float64)
|
||||
if code != 0 {
|
||||
return fmt.Errorf("video task fetch failed for task %s", taskId)
|
||||
}
|
||||
|
||||
data, ok := responseItem["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
common.LogError(ctx, fmt.Sprintf("Video task data format error: %s", string(responseBody)))
|
||||
return fmt.Errorf("video task data format error for task %s", taskId)
|
||||
}
|
||||
|
||||
task := taskM[taskId]
|
||||
if task == nil {
|
||||
common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
|
||||
return fmt.Errorf("task %s not found", taskId)
|
||||
}
|
||||
|
||||
if status, ok := data["task_status"].(string); ok {
|
||||
switch status {
|
||||
case "submitted", "queued":
|
||||
task.Status = model.TaskStatusSubmitted
|
||||
case "processing":
|
||||
task.Status = model.TaskStatusInProgress
|
||||
case "succeed":
|
||||
task.Status = model.TaskStatusSuccess
|
||||
task.Progress = "100%"
|
||||
if url, err := adaptor.ParseResultUrl(responseItem); err == nil {
|
||||
task.FailReason = url
|
||||
} else {
|
||||
common.LogWarn(ctx, fmt.Sprintf("Failed to get url from body for task %s: %s", task.TaskID, err.Error()))
|
||||
}
|
||||
case "failed":
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = "100%"
|
||||
if reason, ok := data["fail_reason"].(string); ok {
|
||||
task.FailReason = reason
|
||||
}
|
||||
}
|
||||
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
|
||||
"task_id": taskId,
|
||||
"action": task.Action,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
|
||||
}
|
||||
//if resp.StatusCode != http.StatusOK {
|
||||
//return fmt.Errorf("get Video Task status code: %d", resp.StatusCode)
|
||||
//}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
// If task failed, refund quota
|
||||
if task.Status == model.TaskStatusFailure {
|
||||
taskResult, err := adaptor.ParseTaskResult(responseBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
||||
}
|
||||
//if taskResult.Code != 0 {
|
||||
// return fmt.Errorf("video task fetch failed for task %s", taskId)
|
||||
//}
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
return fmt.Errorf("task %s status is empty", taskId)
|
||||
}
|
||||
task.Status = model.TaskStatus(taskResult.Status)
|
||||
switch taskResult.Status {
|
||||
case model.TaskStatusSubmitted:
|
||||
task.Progress = "10%"
|
||||
case model.TaskStatusQueued:
|
||||
task.Progress = "20%"
|
||||
case model.TaskStatusInProgress:
|
||||
task.Progress = "30%"
|
||||
if task.StartTime == 0 {
|
||||
task.StartTime = now
|
||||
}
|
||||
case model.TaskStatusSuccess:
|
||||
task.Progress = "100%"
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
task.FailReason = taskResult.Url
|
||||
case model.TaskStatusFailure:
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = "100%"
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
task.FailReason = taskResult.Reason
|
||||
common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
|
||||
quota := task.Quota
|
||||
if quota != 0 {
|
||||
@@ -129,6 +122,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
|
||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
|
||||
}
|
||||
if taskResult.Progress != "" {
|
||||
task.Progress = taskResult.Progress
|
||||
}
|
||||
|
||||
task.Data = responseBody
|
||||
|
||||
@@ -258,3 +258,32 @@ func UpdateToken(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type TokenBatch struct {
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
func DeleteTokenBatch(c *gin.Context) {
|
||||
tokenBatch := TokenBatch{}
|
||||
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
|
||||
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": count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type ImageRequest struct {
|
||||
Background string `json:"background,omitempty"`
|
||||
Moderation string `json:"moderation,omitempty"`
|
||||
OutputFormat string `json:"output_format,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
}
|
||||
|
||||
type ImageResponse struct {
|
||||
|
||||
@@ -57,6 +57,7 @@ type GeneralOpenAIRequest struct {
|
||||
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// 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"`
|
||||
@@ -645,4 +646,6 @@ type ResponsesToolsCall struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
Function json.RawMessage `json:"function,omitempty"`
|
||||
Container json.RawMessage `json:"container,omitempty"`
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type OpenAITextResponse struct {
|
||||
Id string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Created any `json:"created"`
|
||||
Choices []OpenAITextResponseChoice `json:"choices"`
|
||||
Error *OpenAIError `json:"error,omitempty"`
|
||||
Usage `json:"usage"`
|
||||
@@ -178,6 +178,8 @@ type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
|
||||
// OpenRouter Params
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
type InputTokenDetails struct {
|
||||
|
||||
+3
-14
@@ -1,18 +1,7 @@
|
||||
package dto
|
||||
|
||||
// UpstreamDTO 提交到后端同步倍率的上游渠道信息
|
||||
// Endpoint 可以为空,后端会默认使用 /api/ratio_config
|
||||
// BaseURL 必须以 http/https 开头,不要以 / 结尾
|
||||
// 例如: https://api.example.com
|
||||
// Endpoint: /api/ratio_config
|
||||
// 提交示例:
|
||||
// {
|
||||
// "name": "openai",
|
||||
// "base_url": "https://api.openai.com",
|
||||
// "endpoint": "/ratio_config"
|
||||
// }
|
||||
|
||||
type UpstreamDTO struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
@@ -20,6 +9,7 @@ type UpstreamDTO struct {
|
||||
|
||||
type UpstreamRequest struct {
|
||||
ChannelIDs []int64 `json:"channel_ids"`
|
||||
Upstreams []UpstreamDTO `json:"upstreams"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
@@ -37,10 +27,9 @@ type TestResult struct {
|
||||
type DifferenceItem struct {
|
||||
Current interface{} `json:"current"`
|
||||
Upstreams map[string]interface{} `json:"upstreams"`
|
||||
Confidence map[string]bool `json:"confidence"`
|
||||
}
|
||||
|
||||
// SyncableChannel 可同步的渠道信息(base_url 不为空)
|
||||
|
||||
type SyncableChannel struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
+1
-1
@@ -184,7 +184,7 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
// gemini api 从query中获取key
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
|
||||
skKey := c.Query("key")
|
||||
if skKey != "" {
|
||||
c.Request.Header.Set("Authorization", "Bearer "+skKey)
|
||||
|
||||
@@ -57,7 +57,7 @@ func Distribute() func(c *gin.Context) {
|
||||
}
|
||||
userGroup = tokenGroup
|
||||
}
|
||||
c.Set("group", userGroup)
|
||||
c.Set(constant.ContextKeyUsingGroup, userGroup)
|
||||
if ok {
|
||||
id, err := strconv.Atoi(channelId.(string))
|
||||
if err != nil {
|
||||
@@ -171,15 +171,25 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
c.Set("platform", string(constant.TaskPlatformSuno))
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
|
||||
relayMode := relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
|
||||
if relayMode == relayconstant.RelayModeKlingFetchByID {
|
||||
shouldSelectChannel = false
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
var platform string
|
||||
var relayMode int
|
||||
if strings.HasPrefix(modelRequest.Model, "jimeng") {
|
||||
platform = string(constant.TaskPlatformJimeng)
|
||||
relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path)
|
||||
if relayMode == relayconstant.RelayModeJimengFetchByID {
|
||||
shouldSelectChannel = false
|
||||
}
|
||||
} else {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
platform = string(constant.TaskPlatformKling)
|
||||
relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
|
||||
if relayMode == relayconstant.RelayModeKlingFetchByID {
|
||||
shouldSelectChannel = false
|
||||
}
|
||||
}
|
||||
c.Set("platform", string(constant.TaskPlatformKling))
|
||||
c.Set("platform", platform)
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
|
||||
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
|
||||
relayMode := relayconstant.RelayModeGemini
|
||||
modelName := extractModelNameFromGeminiPath(c.Request.URL.Path)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
func KlingRequestConvert() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var originalReq map[string]interface{}
|
||||
if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
model, _ := originalReq["model"].(string)
|
||||
prompt, _ := originalReq["prompt"].(string)
|
||||
|
||||
unifiedReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"metadata": originalReq,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(unifiedReq)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Rewrite request body and path
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
|
||||
c.Request.URL.Path = "/v1/video/generations"
|
||||
if image := originalReq["image"]; image == "" {
|
||||
c.Set("action", "textGenerate")
|
||||
}
|
||||
|
||||
// We have to reset the request body for the next handlers
|
||||
c.Set(common.KeyRequestBody, jsonData)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -327,3 +327,37 @@ func CountUserTokens(userId int) (int64, error) {
|
||||
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量
|
||||
func BatchDeleteTokens(ids []int, userId int) (int, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, errors.New("ids 不能为空!")
|
||||
}
|
||||
|
||||
tx := DB.Begin()
|
||||
|
||||
var tokens []Token
|
||||
if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if common.RedisEnabled {
|
||||
gopool.Go(func() {
|
||||
for _, t := range tokens {
|
||||
_ = cacheDeleteToken(t.Key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return len(tokens), nil
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@ type TaskAdaptor interface {
|
||||
// FetchTask
|
||||
FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error)
|
||||
|
||||
ParseResultUrl(resp map[string]any) (string, error)
|
||||
ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel/openrouter"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
@@ -122,6 +123,21 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
}
|
||||
|
||||
if textRequest.Reasoning != nil {
|
||||
var reasoning openrouter.RequestReasoning
|
||||
if err := common.DecodeJson(textRequest.Reasoning, &reasoning); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
budgetTokens := reasoning.MaxTokens
|
||||
if budgetTokens > 0 {
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: &budgetTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if textRequest.Stop != nil {
|
||||
// stop maybe string/array string, convert to array string
|
||||
switch textRequest.Stop.(type) {
|
||||
|
||||
@@ -78,32 +78,12 @@ func clampThinkingBudget(modelName string, budget int) int {
|
||||
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) {
|
||||
|
||||
geminiRequest := GeminiChatRequest{
|
||||
Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)),
|
||||
GenerationConfig: GeminiChatGenerationConfig{
|
||||
Temperature: textRequest.Temperature,
|
||||
TopP: textRequest.TopP,
|
||||
MaxOutputTokens: textRequest.MaxTokens,
|
||||
Seed: int64(textRequest.Seed),
|
||||
},
|
||||
}
|
||||
|
||||
if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) {
|
||||
geminiRequest.GenerationConfig.ResponseModalities = []string{
|
||||
"TEXT",
|
||||
"IMAGE",
|
||||
}
|
||||
}
|
||||
|
||||
func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) {
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
modelName := info.UpstreamModelName
|
||||
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)
|
||||
@@ -134,21 +114,46 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
} else {
|
||||
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(clampedBudget),
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
if geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
|
||||
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
|
||||
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget)
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(modelName, "-nothinking") {
|
||||
if !isNew25Pro && !is25FlashLite {
|
||||
if !isNew25Pro {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setting safety to the lowest possible values since Gemini is already powerless enough
|
||||
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
|
||||
|
||||
geminiRequest := GeminiChatRequest{
|
||||
Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)),
|
||||
GenerationConfig: GeminiChatGenerationConfig{
|
||||
Temperature: textRequest.Temperature,
|
||||
TopP: textRequest.TopP,
|
||||
MaxOutputTokens: textRequest.MaxTokens,
|
||||
Seed: int64(textRequest.Seed),
|
||||
},
|
||||
}
|
||||
|
||||
if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) {
|
||||
geminiRequest.GenerationConfig.ResponseModalities = []string{
|
||||
"TEXT",
|
||||
"IMAGE",
|
||||
}
|
||||
}
|
||||
|
||||
ThinkingAdaptor(&geminiRequest, info)
|
||||
|
||||
safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList))
|
||||
for _, category := range SafetySettingList {
|
||||
|
||||
@@ -159,6 +159,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if info.ChannelType != common.ChannelTypeOpenAI && info.ChannelType != common.ChannelTypeAzure {
|
||||
request.StreamOptions = nil
|
||||
}
|
||||
if info.ChannelType == common.ChannelTypeOpenRouter {
|
||||
if len(request.Usage) == 0 {
|
||||
request.Usage = json.RawMessage(`{"include":true}`)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(request.Model, "o") {
|
||||
if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 {
|
||||
request.MaxCompletionTokens = request.MaxTokens
|
||||
|
||||
@@ -623,13 +623,13 @@ func OpenaiHandlerWithUsage(c *gin.Context, resp *http.Response, info *relaycomm
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
||||
common.SysError("error copying response body: " + err.Error())
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Once we've written to the client, we should not return errors anymore
|
||||
// because the upstream has already consumed resources and returned content
|
||||
// We should still perform billing even if parsing fails
|
||||
var usageResp dto.SimpleResponse
|
||||
err = json.Unmarshal(responseBody, &usageResp)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package jimeng
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"one-api/model"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/service"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// Request / Response structures
|
||||
// ============================
|
||||
|
||||
type requestPayload struct {
|
||||
ReqKey string `json:"req_key"`
|
||||
BinaryDataBase64 []string `json:"binary_data_base64,omitempty"`
|
||||
ImageUrls []string `json:"image_urls,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Seed int64 `json:"seed"`
|
||||
AspectRatio string `json:"aspect_ratio"`
|
||||
}
|
||||
|
||||
type responsePayload struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestId string `json:"request_id"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type responseTask struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
BinaryDataBase64 []interface{} `json:"binary_data_base64"`
|
||||
ImageUrls interface{} `json:"image_urls"`
|
||||
RespData string `json:"resp_data"`
|
||||
Status string `json:"status"`
|
||||
VideoUrl string `json:"video_url"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
RequestId string `json:"request_id"`
|
||||
Status int `json:"status"`
|
||||
TimeElapsed string `json:"time_elapsed"`
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Adaptor implementation
|
||||
// ============================
|
||||
|
||||
type TaskAdaptor struct {
|
||||
ChannelType int
|
||||
accessKey string
|
||||
secretKey string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.BaseUrl
|
||||
|
||||
// apiKey format: "access_key,secret_key"
|
||||
keyParts := strings.Split(info.ApiKey, ",")
|
||||
if len(keyParts) == 2 {
|
||||
a.accessKey = strings.TrimSpace(keyParts[0])
|
||||
a.secretKey = strings.TrimSpace(keyParts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
|
||||
// Accept only POST /v1/video/generations as "generate" action.
|
||||
action := "generate"
|
||||
info.Action = action
|
||||
|
||||
req := relaycommon.TaskSubmitReq{}
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Prompt) == "" {
|
||||
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Store into context for later usage
|
||||
c.Set("task_request", req)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRequestURL constructs the upstream URL.
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return a.signRequest(req, a.accessKey, a.secretKey)
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Jimeng specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
}
|
||||
req := v.(relaycommon.TaskSubmitReq)
|
||||
|
||||
body, err := a.convertToRequestPayload(&req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "convert request payload failed")
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
// DoRequest delegates to common helper.
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
// DoResponse handles upstream response, returns taskID etc.
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Parse Jimeng response
|
||||
var jResp responsePayload
|
||||
if err := json.Unmarshal(responseBody, &jResp); err != nil {
|
||||
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if jResp.Code != 10000 {
|
||||
taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"task_id": jResp.Data.TaskID})
|
||||
return jResp.Data.TaskID, responseBody, nil
|
||||
}
|
||||
|
||||
// FetchTask fetch task status
|
||||
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
|
||||
taskID, ok := body["task_id"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid task_id")
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
|
||||
payload := map[string]string{
|
||||
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
|
||||
"task_id": taskID,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "marshal fetch task payload failed")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
keyParts := strings.Split(key, ",")
|
||||
if len(keyParts) != 2 {
|
||||
return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak,sk'")
|
||||
}
|
||||
accessKey := strings.TrimSpace(keyParts[0])
|
||||
secretKey := strings.TrimSpace(keyParts[1])
|
||||
|
||||
if err := a.signRequest(req, accessKey, secretKey); err != nil {
|
||||
return nil, errors.Wrap(err, "sign request failed")
|
||||
}
|
||||
|
||||
return service.GetHttpClient().Do(req)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetModelList() []string {
|
||||
return []string{"jimeng_vgfm_t2v_l20"}
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetChannelName() string {
|
||||
return "jimeng"
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) signRequest(req *http.Request, accessKey, secretKey string) error {
|
||||
var bodyBytes []byte
|
||||
var err error
|
||||
|
||||
if req.Body != nil {
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read request body failed")
|
||||
}
|
||||
_ = req.Body.Close()
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind
|
||||
} else {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
payloadHash := sha256.Sum256(bodyBytes)
|
||||
hexPayloadHash := hex.EncodeToString(payloadHash[:])
|
||||
|
||||
t := time.Now().UTC()
|
||||
xDate := t.Format("20060102T150405Z")
|
||||
shortDate := t.Format("20060102")
|
||||
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
req.Header.Set("X-Date", xDate)
|
||||
req.Header.Set("X-Content-Sha256", hexPayloadHash)
|
||||
|
||||
// Sort and encode query parameters to create canonical query string
|
||||
queryParams := req.URL.Query()
|
||||
sortedKeys := make([]string, 0, len(queryParams))
|
||||
for k := range queryParams {
|
||||
sortedKeys = append(sortedKeys, k)
|
||||
}
|
||||
sort.Strings(sortedKeys)
|
||||
var queryParts []string
|
||||
for _, k := range sortedKeys {
|
||||
values := queryParams[k]
|
||||
sort.Strings(values)
|
||||
for _, v := range values {
|
||||
queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)))
|
||||
}
|
||||
}
|
||||
canonicalQueryString := strings.Join(queryParts, "&")
|
||||
|
||||
headersToSign := map[string]string{
|
||||
"host": req.URL.Host,
|
||||
"x-date": xDate,
|
||||
"x-content-sha256": hexPayloadHash,
|
||||
}
|
||||
if req.Header.Get("Content-Type") != "" {
|
||||
headersToSign["content-type"] = req.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
var signedHeaderKeys []string
|
||||
for k := range headersToSign {
|
||||
signedHeaderKeys = append(signedHeaderKeys, k)
|
||||
}
|
||||
sort.Strings(signedHeaderKeys)
|
||||
|
||||
var canonicalHeaders strings.Builder
|
||||
for _, k := range signedHeaderKeys {
|
||||
canonicalHeaders.WriteString(k)
|
||||
canonicalHeaders.WriteString(":")
|
||||
canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k]))
|
||||
canonicalHeaders.WriteString("\n")
|
||||
}
|
||||
signedHeaders := strings.Join(signedHeaderKeys, ";")
|
||||
|
||||
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
|
||||
req.Method,
|
||||
req.URL.Path,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders.String(),
|
||||
signedHeaders,
|
||||
hexPayloadHash,
|
||||
)
|
||||
|
||||
hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest))
|
||||
hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:])
|
||||
|
||||
region := "cn-north-1"
|
||||
serviceName := "cv"
|
||||
credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName)
|
||||
stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s",
|
||||
xDate,
|
||||
credentialScope,
|
||||
hexHashedCanonicalRequest,
|
||||
)
|
||||
|
||||
kDate := hmacSHA256([]byte(secretKey), []byte(shortDate))
|
||||
kRegion := hmacSHA256(kDate, []byte(region))
|
||||
kService := hmacSHA256(kRegion, []byte(serviceName))
|
||||
kSigning := hmacSHA256(kService, []byte("request"))
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
|
||||
|
||||
authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
accessKey,
|
||||
credentialScope,
|
||||
signedHeaders,
|
||||
signature,
|
||||
)
|
||||
req.Header.Set("Authorization", authorization)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, data []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
|
||||
r := requestPayload{
|
||||
ReqKey: "jimeng_vgfm_i2v_l20",
|
||||
Prompt: req.Prompt,
|
||||
AspectRatio: "16:9", // Default aspect ratio
|
||||
Seed: -1, // Default to random
|
||||
}
|
||||
|
||||
// Handle one-of image_urls or binary_data_base64
|
||||
if req.Image != "" {
|
||||
if strings.HasPrefix(req.Image, "http") {
|
||||
r.ImageUrls = []string{req.Image}
|
||||
} else {
|
||||
r.BinaryDataBase64 = []string{req.Image}
|
||||
}
|
||||
}
|
||||
metadata := req.Metadata
|
||||
medaBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "metadata marshal metadata failed")
|
||||
}
|
||||
err = json.Unmarshal(medaBytes, &r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
||||
resTask := responseTask{}
|
||||
if err := json.Unmarshal(respBody, &resTask); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal task result failed")
|
||||
}
|
||||
taskResult := relaycommon.TaskInfo{}
|
||||
if resTask.Code == 10000 {
|
||||
taskResult.Code = 0
|
||||
} else {
|
||||
taskResult.Code = resTask.Code // todo uni code
|
||||
taskResult.Reason = resTask.Message
|
||||
taskResult.Status = model.TaskStatusFailure
|
||||
taskResult.Progress = "100%"
|
||||
}
|
||||
switch resTask.Data.Status {
|
||||
case "in_queue":
|
||||
taskResult.Status = model.TaskStatusQueued
|
||||
taskResult.Progress = "10%"
|
||||
case "done":
|
||||
taskResult.Status = model.TaskStatusSuccess
|
||||
taskResult.Progress = "100%"
|
||||
}
|
||||
taskResult.Url = resTask.Data.VideoUrl
|
||||
return &taskResult, nil
|
||||
}
|
||||
@@ -2,11 +2,12 @@ package kling
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/samber/lo"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -41,16 +42,27 @@ type requestPayload struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
CfgScale float64 `json:"cfg_scale,omitempty"`
|
||||
}
|
||||
|
||||
type responsePayload struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestId string `json:"request_id"`
|
||||
Data struct {
|
||||
TaskId string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
TaskResult struct {
|
||||
Videos []struct {
|
||||
Id string `json:"id"`
|
||||
Url string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos"`
|
||||
} `json:"task_result"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -69,8 +81,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.BaseUrl
|
||||
|
||||
// apiKey format: "access_key,secret_key"
|
||||
keyParts := strings.Split(info.ApiKey, ",")
|
||||
// apiKey format: "access_key|secret_key"
|
||||
keyParts := strings.Split(info.ApiKey, "|")
|
||||
if len(keyParts) == 2 {
|
||||
a.accessKey = strings.TrimSpace(keyParts[0])
|
||||
a.secretKey = strings.TrimSpace(keyParts[1])
|
||||
@@ -94,13 +106,14 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
||||
}
|
||||
|
||||
// Store into context for later usage
|
||||
c.Set("kling_request", req)
|
||||
c.Set("task_request", req)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRequestURL constructs the upstream URL.
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/v1/videos/image2video", a.baseURL), nil
|
||||
path := lo.Ternary(info.Action == "generate", "/v1/videos/image2video", "/v1/videos/text2video")
|
||||
return fmt.Sprintf("%s%s", a.baseURL, path), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
@@ -119,13 +132,16 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
|
||||
|
||||
// BuildRequestBody converts request into Kling specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("kling_request")
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
}
|
||||
req := v.(SubmitReq)
|
||||
|
||||
body := a.convertToRequestPayload(&req)
|
||||
body, err := a.convertToRequestPayload(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -135,6 +151,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
|
||||
|
||||
// DoRequest delegates to common helper.
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
if action := c.GetString("action"); action != "" {
|
||||
info.Action = action
|
||||
}
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
@@ -149,8 +168,8 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
|
||||
// Attempt Kling response parse first.
|
||||
var kResp responsePayload
|
||||
if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskID})
|
||||
return kResp.Data.TaskID, responseBody, nil
|
||||
c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskId})
|
||||
return kResp.Data.TaskId, responseBody, nil
|
||||
}
|
||||
|
||||
// Fallback generic task response.
|
||||
@@ -175,7 +194,12 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid task_id")
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/videos/image2video/%s", baseUrl, taskID)
|
||||
action, ok := body["action"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid action")
|
||||
}
|
||||
path := lo.Ternary(action == "generate", "/v1/videos/image2video", "/v1/videos/text2video")
|
||||
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -187,10 +211,6 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
|
||||
token = key
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("User-Agent", "kling-sdk/1.0")
|
||||
@@ -210,22 +230,29 @@ func (a *TaskAdaptor) GetChannelName() string {
|
||||
// helpers
|
||||
// ============================
|
||||
|
||||
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) *requestPayload {
|
||||
r := &requestPayload{
|
||||
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
|
||||
r := requestPayload{
|
||||
Prompt: req.Prompt,
|
||||
Image: req.Image,
|
||||
Mode: defaultString(req.Mode, "std"),
|
||||
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
|
||||
AspectRatio: a.getAspectRatio(req.Size),
|
||||
Model: req.Model,
|
||||
ModelName: req.Model,
|
||||
CfgScale: 0.5,
|
||||
}
|
||||
if r.Model == "" {
|
||||
r.Model = "kling-v1"
|
||||
if r.ModelName == "" {
|
||||
r.ModelName = "kling-v1"
|
||||
}
|
||||
return r
|
||||
metadata := req.Metadata
|
||||
medaBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "metadata marshal metadata failed")
|
||||
}
|
||||
err = json.Unmarshal(medaBytes, &r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) getAspectRatio(size string) string {
|
||||
@@ -264,7 +291,7 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
|
||||
parts := strings.Split(apiKey, ",")
|
||||
parts := strings.Split(apiKey, "|")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'")
|
||||
}
|
||||
@@ -286,27 +313,33 @@ func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (strin
|
||||
return token.SignedString([]byte(secretKey))
|
||||
}
|
||||
|
||||
// ParseResultUrl 提取视频任务结果的 url
|
||||
func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) {
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("data field not found or invalid")
|
||||
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
||||
resPayload := responsePayload{}
|
||||
err := json.Unmarshal(respBody, &resPayload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal response body")
|
||||
}
|
||||
taskResult, ok := data["task_result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("task_result field not found or invalid")
|
||||
taskInfo := &relaycommon.TaskInfo{}
|
||||
taskInfo.Code = resPayload.Code
|
||||
taskInfo.TaskID = resPayload.Data.TaskId
|
||||
taskInfo.Reason = resPayload.Message
|
||||
//任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败)
|
||||
status := resPayload.Data.TaskStatus
|
||||
switch status {
|
||||
case "submitted":
|
||||
taskInfo.Status = model.TaskStatusSubmitted
|
||||
case "processing":
|
||||
taskInfo.Status = model.TaskStatusInProgress
|
||||
case "succeed":
|
||||
taskInfo.Status = model.TaskStatusSuccess
|
||||
case "failed":
|
||||
taskInfo.Status = model.TaskStatusFailure
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown task status: %s", status)
|
||||
}
|
||||
videos, ok := taskResult["videos"].([]interface{})
|
||||
if !ok || len(videos) == 0 {
|
||||
return "", fmt.Errorf("videos field not found or empty")
|
||||
if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 {
|
||||
video := videos[0]
|
||||
taskInfo.Url = video.Url
|
||||
}
|
||||
video, ok := videos[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("video item invalid")
|
||||
}
|
||||
url, ok := video["url"].(string)
|
||||
if !ok || url == "" {
|
||||
return "", fmt.Errorf("url field not found or invalid")
|
||||
}
|
||||
return url, nil
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ type TaskAdaptor struct {
|
||||
ChannelType int
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) {
|
||||
return "", nil // todo implement this method if needed
|
||||
func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
|
||||
return nil, fmt.Errorf("not implement") // todo implement this method if needed
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package volcengine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel"
|
||||
"one-api/relay/channel/openai"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/constant"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,8 +34,146 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesEdits:
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
writer.WriteField("model", request.Model)
|
||||
// 获取所有表单字段
|
||||
formData := c.Request.PostForm
|
||||
// 遍历表单字段并打印输出
|
||||
for key, values := range formData {
|
||||
if key == "model" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
writer.WriteField(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the multipart form to handle both single image and multiple images
|
||||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory
|
||||
return nil, errors.New("failed to parse multipart form")
|
||||
}
|
||||
|
||||
if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil {
|
||||
// Check if "image" field exists in any form, including array notation
|
||||
var imageFiles []*multipart.FileHeader
|
||||
var exists bool
|
||||
|
||||
// First check for standard "image" field
|
||||
if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 {
|
||||
// If not found, check for "image[]" field
|
||||
if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 {
|
||||
// If still not found, iterate through all fields to find any that start with "image["
|
||||
foundArrayImages := false
|
||||
for fieldName, files := range c.Request.MultipartForm.File {
|
||||
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
|
||||
foundArrayImages = true
|
||||
for _, file := range files {
|
||||
imageFiles = append(imageFiles, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no image fields found at all
|
||||
if !foundArrayImages && (len(imageFiles) == 0) {
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process all image files
|
||||
for i, fileHeader := range imageFiles {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open image file %d: %w", i, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// If multiple images, use image[] as the field name
|
||||
fieldName := "image"
|
||||
if len(imageFiles) > 1 {
|
||||
fieldName = "image[]"
|
||||
}
|
||||
|
||||
// Determine MIME type based on file extension
|
||||
mimeType := detectImageMimeType(fileHeader.Filename)
|
||||
|
||||
// Create a form file with the appropriate content type
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename))
|
||||
h.Set("Content-Type", mimeType)
|
||||
|
||||
part, err := writer.CreatePart(h)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create form part failed for image %d: %w", i, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mask file if present
|
||||
if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 {
|
||||
maskFile, err := maskFiles[0].Open()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to open mask file")
|
||||
}
|
||||
defer maskFile.Close()
|
||||
|
||||
// Determine MIME type for mask file
|
||||
mimeType := detectImageMimeType(maskFiles[0].Filename)
|
||||
|
||||
// Create a form file with the appropriate content type
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename))
|
||||
h.Set("Content-Type", mimeType)
|
||||
|
||||
maskPart, err := writer.CreatePart(h)
|
||||
if err != nil {
|
||||
return nil, errors.New("create form file failed for mask")
|
||||
}
|
||||
|
||||
if _, err := io.Copy(maskPart, maskFile); err != nil {
|
||||
return nil, errors.New("copy mask file failed")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("no multipart form data found")
|
||||
}
|
||||
|
||||
// 关闭 multipart 编写器以设置分界线
|
||||
writer.Close()
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return bytes.NewReader(requestBody.Bytes()), nil
|
||||
|
||||
default:
|
||||
return request, nil
|
||||
}
|
||||
}
|
||||
|
||||
// detectImageMimeType determines the MIME type based on the file extension
|
||||
func detectImageMimeType(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
// Try to detect from extension if possible
|
||||
if strings.HasPrefix(ext, ".jp") {
|
||||
return "image/jpeg"
|
||||
}
|
||||
// Default to png as a fallback
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
@@ -46,6 +188,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/api/v3/chat/completions", info.BaseUrl), nil
|
||||
case constant.RelayModeEmbeddings:
|
||||
return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil
|
||||
case constant.RelayModeImagesGenerations:
|
||||
return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil
|
||||
default:
|
||||
}
|
||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||
@@ -91,6 +235,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
}
|
||||
case constant.RelayModeEmbeddings:
|
||||
err, usage = openai.OpenaiHandler(c, resp, info)
|
||||
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
|
||||
err, usage = openai.OpenaiHandlerWithUsage(c, resp, info)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package xinference
|
||||
|
||||
type XinRerankResponseDocument struct {
|
||||
Document string `json:"document,omitempty"`
|
||||
Document any `json:"document,omitempty"`
|
||||
Index int `json:"index"`
|
||||
RelevanceScore float64 `json:"relevance_score"`
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ type RelayInfo struct {
|
||||
TokenId int
|
||||
TokenKey string
|
||||
UserId int
|
||||
Group string
|
||||
UserGroup string
|
||||
UsingGroup string // 使用的分组
|
||||
UserGroup string // 用户所在分组
|
||||
TokenUnlimited bool
|
||||
StartTime time.Time
|
||||
FirstResponseTime time.Time
|
||||
@@ -219,7 +219,6 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
|
||||
tokenId := c.GetInt("token_id")
|
||||
tokenKey := c.GetString("token_key")
|
||||
userId := c.GetInt("id")
|
||||
group := c.GetString("group")
|
||||
tokenUnlimited := c.GetBool("token_unlimited_quota")
|
||||
startTime := c.GetTime(constant.ContextKeyRequestStartTime)
|
||||
// firstResponseTime = time.Now() - 1 second
|
||||
@@ -239,7 +238,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
|
||||
TokenId: tokenId,
|
||||
TokenKey: tokenKey,
|
||||
UserId: userId,
|
||||
Group: group,
|
||||
UsingGroup: c.GetString(constant.ContextKeyUsingGroup),
|
||||
UserGroup: c.GetString(constant.ContextKeyUserGroup),
|
||||
TokenUnlimited: tokenUnlimited,
|
||||
StartTime: startTime,
|
||||
@@ -314,3 +313,22 @@ func GenTaskRelayInfo(c *gin.Context) *TaskRelayInfo {
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
type TaskSubmitReq struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type TaskInfo struct {
|
||||
Code int `json:"code"`
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Progress string `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
@@ -38,10 +38,16 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
}
|
||||
if info.ReturnDocuments {
|
||||
var document any
|
||||
if result.Document == "" {
|
||||
document = info.Documents[result.Index]
|
||||
} else {
|
||||
document = result.Document
|
||||
if result.Document != nil {
|
||||
if doc, ok := result.Document.(string); ok {
|
||||
if doc == "" {
|
||||
document = info.Documents[result.Index]
|
||||
} else {
|
||||
document = doc
|
||||
}
|
||||
} else {
|
||||
document = result.Document
|
||||
}
|
||||
}
|
||||
respResult.Document = document
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ const (
|
||||
RelayModeKlingFetchByID
|
||||
RelayModeKlingSubmit
|
||||
|
||||
RelayModeJimengFetchByID
|
||||
RelayModeJimengSubmit
|
||||
|
||||
RelayModeRerank
|
||||
|
||||
RelayModeResponses
|
||||
@@ -80,7 +83,7 @@ func Path2RelayMode(path string) int {
|
||||
relayMode = RelayModeRerank
|
||||
} else if strings.HasPrefix(path, "/v1/realtime") {
|
||||
relayMode = RelayModeRealtime
|
||||
} else if strings.HasPrefix(path, "/v1beta/models") {
|
||||
} else if strings.HasPrefix(path, "/v1beta/models") || strings.HasPrefix(path, "/v1/models") {
|
||||
relayMode = RelayModeGemini
|
||||
}
|
||||
return relayMode
|
||||
@@ -146,3 +149,13 @@ func Path2RelayKling(method, path string) int {
|
||||
}
|
||||
return relayMode
|
||||
}
|
||||
|
||||
func Path2RelayJimeng(method, path string) int {
|
||||
relayMode := RelayModeUnknown
|
||||
if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") {
|
||||
relayMode = RelayModeJimengSubmit
|
||||
} else if method == http.MethodGet && strings.Contains(path, "/video/generations/") {
|
||||
relayMode = RelayModeJimengFetchByID
|
||||
}
|
||||
return relayMode
|
||||
}
|
||||
|
||||
+46
-3
@@ -13,6 +13,7 @@ import (
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/setting"
|
||||
"one-api/setting/model_setting"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -76,6 +77,33 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay
|
||||
return inputTokens
|
||||
}
|
||||
|
||||
func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool {
|
||||
if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
|
||||
return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimModelThinking(modelName string) string {
|
||||
// 去除模型名称中的 -nothinking 后缀
|
||||
if strings.HasSuffix(modelName, "-nothinking") {
|
||||
return strings.TrimSuffix(modelName, "-nothinking")
|
||||
}
|
||||
// 去除模型名称中的 -thinking 后缀
|
||||
if strings.HasSuffix(modelName, "-thinking") {
|
||||
return strings.TrimSuffix(modelName, "-thinking")
|
||||
}
|
||||
|
||||
// 去除模型名称中的 -thinking-number
|
||||
if strings.Contains(modelName, "-thinking-") {
|
||||
parts := strings.Split(modelName, "-thinking-")
|
||||
if len(parts) > 1 {
|
||||
return parts[0] + "-thinking"
|
||||
}
|
||||
}
|
||||
return modelName
|
||||
}
|
||||
|
||||
func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
|
||||
req, err := getAndValidateGeminiRequest(c)
|
||||
if err != nil {
|
||||
@@ -107,12 +135,27 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
|
||||
relayInfo.SetPromptTokens(promptTokens)
|
||||
} else {
|
||||
promptTokens := getGeminiInputTokens(req, relayInfo)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest)
|
||||
}
|
||||
c.Set("prompt_tokens", promptTokens)
|
||||
}
|
||||
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
if isNoThinkingRequest(req) {
|
||||
// check is thinking
|
||||
if !strings.Contains(relayInfo.OriginModelName, "-nothinking") {
|
||||
// try to get no thinking model price
|
||||
noThinkingModelName := relayInfo.OriginModelName + "-nothinking"
|
||||
containPrice := helper.ContainPriceOrRatio(noThinkingModelName)
|
||||
if containPrice {
|
||||
relayInfo.OriginModelName = noThinkingModelName
|
||||
relayInfo.UpstreamModelName = noThinkingModelName
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.GenerationConfig.ThinkingConfig == nil {
|
||||
gemini.ThinkingAdaptor(req, relayInfo)
|
||||
}
|
||||
}
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens))
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
|
||||
|
||||
+35
-4
@@ -13,6 +13,7 @@ import (
|
||||
type GroupRatioInfo struct {
|
||||
GroupRatio float64
|
||||
GroupSpecialRatio float64
|
||||
HasSpecialRatio bool
|
||||
}
|
||||
|
||||
type PriceData struct {
|
||||
@@ -31,7 +32,7 @@ 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.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
|
||||
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present
|
||||
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo {
|
||||
groupRatioInfo := GroupRatioInfo{
|
||||
GroupRatio: 1.0, // default ratio
|
||||
@@ -44,18 +45,19 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupR
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("final group: %s", autoGroup))
|
||||
}
|
||||
relayInfo.Group = autoGroup.(string)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
// check user group special ratio
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if ok {
|
||||
// user group special ratio
|
||||
groupRatioInfo.GroupSpecialRatio = userGroupRatio
|
||||
groupRatioInfo.GroupRatio = userGroupRatio
|
||||
groupRatioInfo.HasSpecialRatio = true
|
||||
} else {
|
||||
// normal group ratio
|
||||
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.Group)
|
||||
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
}
|
||||
|
||||
return groupRatioInfo
|
||||
@@ -120,6 +122,35 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
return priceData, nil
|
||||
}
|
||||
|
||||
type PerCallPriceData struct {
|
||||
ModelPrice float64
|
||||
Quota int
|
||||
GroupRatioInfo GroupRatioInfo
|
||||
}
|
||||
|
||||
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
|
||||
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) PerCallPriceData {
|
||||
groupRatioInfo := HandleGroupRatio(c, info)
|
||||
|
||||
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
|
||||
// 如果没有配置价格,则使用默认价格
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[info.OriginModelName]
|
||||
if !ok {
|
||||
modelPrice = 0.1
|
||||
} else {
|
||||
modelPrice = defaultPrice
|
||||
}
|
||||
}
|
||||
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
priceData := PerCallPriceData{
|
||||
ModelPrice: modelPrice,
|
||||
Quota: quota,
|
||||
GroupRatioInfo: groupRatioInfo,
|
||||
}
|
||||
return priceData
|
||||
}
|
||||
|
||||
func ContainPriceOrRatio(modelName string) bool {
|
||||
_, ok := ratio_setting.GetModelPrice(modelName, false)
|
||||
if ok {
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"one-api/setting"
|
||||
"strings"
|
||||
|
||||
"one-api/relay/constant"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -44,6 +46,11 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
|
||||
if info.ApiType == constant.APITypeVolcEngine {
|
||||
watermark := formData.Has("watermark")
|
||||
imageRequest.Watermark = &watermark
|
||||
}
|
||||
default:
|
||||
err := common.UnmarshalBodyReusable(c, imageRequest)
|
||||
if err != nil {
|
||||
|
||||
+28
-61
@@ -13,9 +13,9 @@ import (
|
||||
"one-api/model"
|
||||
relaycommon "one-api/relay/common"
|
||||
relayconstant "one-api/relay/constant"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/setting"
|
||||
"one-api/setting/ratio_setting"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -174,18 +174,9 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
|
||||
return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required")
|
||||
}
|
||||
modelName := service.CoverActionToModelName(constant.MjActionSwapFace)
|
||||
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
|
||||
// 如果没有配置价格,则使用默认价格
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
|
||||
if !ok {
|
||||
modelPrice = 0.1
|
||||
} else {
|
||||
modelPrice = defaultPrice
|
||||
}
|
||||
}
|
||||
groupRatio := ratio_setting.GetGroupRatio(group)
|
||||
ratio := modelPrice * groupRatio
|
||||
|
||||
priceData := helper.ModelPriceHelperPerCall(c, relayInfo)
|
||||
|
||||
userQuota, err := model.GetUserQuota(userId, false)
|
||||
if err != nil {
|
||||
return &dto.MidjourneyResponse{
|
||||
@@ -193,9 +184,8 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
|
||||
Description: err.Error(),
|
||||
}
|
||||
}
|
||||
quota := int(ratio * common.QuotaPerUnit)
|
||||
|
||||
if userQuota-quota < 0 {
|
||||
if userQuota-priceData.Quota < 0 {
|
||||
return &dto.MidjourneyResponse{
|
||||
Code: 4,
|
||||
Description: "quota_not_enough",
|
||||
@@ -210,26 +200,18 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
|
||||
}
|
||||
defer func() {
|
||||
if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 {
|
||||
err := service.PostConsumeQuota(relayInfo, quota, 0, true)
|
||||
err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true)
|
||||
if err != nil {
|
||||
common.SysError("error consuming token remain quota: " + err.Error())
|
||||
}
|
||||
//err = model.CacheUpdateUserQuota(userId)
|
||||
if err != nil {
|
||||
common.SysError("error update user quota cache: " + err.Error())
|
||||
}
|
||||
if quota != 0 {
|
||||
tokenName := c.GetString("token_name")
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, constant.MjActionSwapFace)
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = modelPrice
|
||||
other["group_ratio"] = groupRatio
|
||||
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
|
||||
quota, logContent, tokenId, userQuota, 0, false, group, other)
|
||||
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
|
||||
channelId := c.GetInt("channel_id")
|
||||
model.UpdateChannelUsedQuota(channelId, quota)
|
||||
}
|
||||
|
||||
tokenName := c.GetString("token_name")
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace)
|
||||
other := service.GenerateMjOtherInfo(priceData)
|
||||
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
|
||||
priceData.Quota, logContent, tokenId, userQuota, 0, false, group, other)
|
||||
model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota)
|
||||
model.UpdateChannelUsedQuota(channelId, priceData.Quota)
|
||||
}
|
||||
}()
|
||||
midjResponse := &mjResp.Response
|
||||
@@ -250,7 +232,7 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
|
||||
Progress: "0%",
|
||||
FailReason: "",
|
||||
ChannelId: c.GetInt("channel_id"),
|
||||
Quota: quota,
|
||||
Quota: priceData.Quota,
|
||||
}
|
||||
err = midjourneyTask.Insert()
|
||||
if err != nil {
|
||||
@@ -480,18 +462,9 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
|
||||
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
|
||||
|
||||
modelName := service.CoverActionToModelName(midjRequest.Action)
|
||||
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
|
||||
// 如果没有配置价格,则使用默认价格
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
|
||||
if !ok {
|
||||
modelPrice = 0.1
|
||||
} else {
|
||||
modelPrice = defaultPrice
|
||||
}
|
||||
}
|
||||
groupRatio := ratio_setting.GetGroupRatio(group)
|
||||
ratio := modelPrice * groupRatio
|
||||
|
||||
priceData := helper.ModelPriceHelperPerCall(c, relayInfo)
|
||||
|
||||
userQuota, err := model.GetUserQuota(userId, false)
|
||||
if err != nil {
|
||||
return &dto.MidjourneyResponse{
|
||||
@@ -499,9 +472,8 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
|
||||
Description: err.Error(),
|
||||
}
|
||||
}
|
||||
quota := int(ratio * common.QuotaPerUnit)
|
||||
|
||||
if consumeQuota && userQuota-quota < 0 {
|
||||
if consumeQuota && userQuota-priceData.Quota < 0 {
|
||||
return &dto.MidjourneyResponse{
|
||||
Code: 4,
|
||||
Description: "quota_not_enough",
|
||||
@@ -516,22 +488,17 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
|
||||
|
||||
defer func() {
|
||||
if consumeQuota && midjResponseWithStatus.StatusCode == 200 {
|
||||
err := service.PostConsumeQuota(relayInfo, quota, 0, true)
|
||||
err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true)
|
||||
if err != nil {
|
||||
common.SysError("error consuming token remain quota: " + err.Error())
|
||||
}
|
||||
if quota != 0 {
|
||||
tokenName := c.GetString("token_name")
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s,ID %s", modelPrice, groupRatio, midjRequest.Action, midjResponse.Result)
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = modelPrice
|
||||
other["group_ratio"] = groupRatio
|
||||
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
|
||||
quota, logContent, tokenId, userQuota, 0, false, group, other)
|
||||
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
|
||||
channelId := c.GetInt("channel_id")
|
||||
model.UpdateChannelUsedQuota(channelId, quota)
|
||||
}
|
||||
tokenName := c.GetString("token_name")
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s,ID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result)
|
||||
other := service.GenerateMjOtherInfo(priceData)
|
||||
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
|
||||
priceData.Quota, logContent, tokenId, userQuota, 0, false, group, other)
|
||||
model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota)
|
||||
model.UpdateChannelUsedQuota(channelId, priceData.Quota)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -559,7 +526,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
|
||||
Progress: "0%",
|
||||
FailReason: "",
|
||||
ChannelId: c.GetInt("channel_id"),
|
||||
Quota: quota,
|
||||
Quota: priceData.Quota,
|
||||
}
|
||||
if midjResponse.Code == 3 {
|
||||
//无实例账号自动禁用渠道(No available account instance)
|
||||
|
||||
+1
-1
@@ -541,5 +541,5 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
other["audio_input_price"] = audioInputPrice
|
||||
}
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"one-api/relay/channel/palm"
|
||||
"one-api/relay/channel/perplexity"
|
||||
"one-api/relay/channel/siliconflow"
|
||||
"one-api/relay/channel/task/jimeng"
|
||||
"one-api/relay/channel/task/kling"
|
||||
"one-api/relay/channel/task/suno"
|
||||
"one-api/relay/channel/tencent"
|
||||
@@ -104,6 +105,8 @@ func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor {
|
||||
return &suno.TaskAdaptor{}
|
||||
case commonconstant.TaskPlatformKling:
|
||||
return &kling.TaskAdaptor{}
|
||||
case commonconstant.TaskPlatformJimeng:
|
||||
return &jimeng.TaskAdaptor{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+20
-6
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -16,6 +15,8 @@ import (
|
||||
relayconstant "one-api/relay/constant"
|
||||
"one-api/service"
|
||||
"one-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -51,8 +52,14 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
}
|
||||
|
||||
// 预扣
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group)
|
||||
ratio := modelPrice * groupRatio
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
var ratio float64
|
||||
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if hasUserGroupRatio {
|
||||
ratio = modelPrice * userGroupRatio
|
||||
} else {
|
||||
ratio = modelPrice * groupRatio
|
||||
}
|
||||
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
|
||||
@@ -121,12 +128,19 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
}
|
||||
if quota != 0 {
|
||||
tokenName := c.GetString("token_name")
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, relayInfo.Action)
|
||||
gRatio := groupRatio
|
||||
if hasUserGroupRatio {
|
||||
gRatio = userGroupRatio
|
||||
}
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, relayInfo.Action)
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = modelPrice
|
||||
other["group_ratio"] = groupRatio
|
||||
if hasUserGroupRatio {
|
||||
other["user_group_ratio"] = userGroupRatio
|
||||
}
|
||||
model.RecordConsumeLog(c, relayInfo.UserId, relayInfo.ChannelId, 0, 0,
|
||||
modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, 0, false, relayInfo.Group, other)
|
||||
modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, 0, false, relayInfo.UsingGroup, other)
|
||||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
|
||||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
|
||||
}
|
||||
@@ -231,7 +245,7 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt
|
||||
}
|
||||
|
||||
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
|
||||
taskId := c.Param("id")
|
||||
taskId := c.Param("task_id")
|
||||
userId := c.GetInt("id")
|
||||
|
||||
originTask, exist, err := model.GetByTaskId(userId, taskId)
|
||||
|
||||
@@ -125,6 +125,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
tokenRoute.POST("/", controller.AddToken)
|
||||
tokenRoute.PUT("/", controller.UpdateToken)
|
||||
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
||||
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
|
||||
}
|
||||
redemptionRoute := apiRouter.Group("/redemption")
|
||||
redemptionRoute.Use(middleware.AdminAuth())
|
||||
|
||||
@@ -63,6 +63,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
httpRouter.DELETE("/models/:model", controller.RelayNotImplemented)
|
||||
httpRouter.POST("/moderations", controller.Relay)
|
||||
httpRouter.POST("/rerank", controller.Relay)
|
||||
httpRouter.POST("/models/*path", controller.Relay)
|
||||
}
|
||||
|
||||
relayMjRouter := router.Group("/mj")
|
||||
|
||||
@@ -14,4 +14,11 @@ func SetVideoRouter(router *gin.Engine) {
|
||||
videoV1Router.POST("/video/generations", controller.RelayTask)
|
||||
videoV1Router.GET("/video/generations/:task_id", controller.RelayTask)
|
||||
}
|
||||
|
||||
klingV1Router := router.Group("/kling/v1")
|
||||
klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
klingV1Router.POST("/videos/text2video", controller.RelayTask)
|
||||
klingV1Router.POST("/videos/image2video", controller.RelayTask)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -276,12 +276,15 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
}
|
||||
if info.Done {
|
||||
claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
|
||||
if info.ClaudeConvertInfo.Usage != nil {
|
||||
oaiUsage := info.ClaudeConvertInfo.Usage
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: &dto.ClaudeUsage{
|
||||
InputTokens: info.ClaudeConvertInfo.Usage.PromptTokens,
|
||||
OutputTokens: info.ClaudeConvertInfo.Usage.CompletionTokens,
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
},
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"one-api/dto"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -63,3 +64,13 @@ func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
info["cache_creation_ratio"] = cacheCreationRatio
|
||||
return info
|
||||
}
|
||||
|
||||
func GenerateMjOtherInfo(priceData helper.PerCallPriceData) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = priceData.ModelPrice
|
||||
other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio
|
||||
if priceData.GroupRatioInfo.HasSpecialRatio {
|
||||
other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio
|
||||
}
|
||||
return other
|
||||
}
|
||||
|
||||
+39
-6
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"one-api/common"
|
||||
constant2 "one-api/constant"
|
||||
"one-api/dto"
|
||||
@@ -94,18 +95,18 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
|
||||
textOutTokens := usage.OutputTokenDetails.TextTokens
|
||||
audioInputTokens := usage.InputTokenDetails.AudioTokens
|
||||
audioOutTokens := usage.OutputTokenDetails.AudioTokens
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group)
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
modelRatio, _ := ratio_setting.GetModelRatio(modelName)
|
||||
|
||||
autoGroup, exists := ctx.Get("auto_group")
|
||||
if exists {
|
||||
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
|
||||
log.Printf("final group ratio: %f", groupRatio)
|
||||
relayInfo.Group = autoGroup.(string)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
actualGroupRatio := groupRatio
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
|
||||
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
if ok {
|
||||
actualGroupRatio = userGroupRatio
|
||||
}
|
||||
@@ -209,7 +210,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
||||
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||
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)
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
|
||||
}
|
||||
|
||||
func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
@@ -231,6 +232,17 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
cacheCreationRatio := priceData.CacheCreationRatio
|
||||
cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
|
||||
|
||||
if relayInfo.ChannelType == common.ChannelTypeOpenRouter {
|
||||
promptTokens -= cacheTokens
|
||||
if cacheCreationTokens == 0 && priceData.CacheCreationRatio != 1 && usage.Cost != 0 {
|
||||
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, priceData)
|
||||
if promptTokens >= maybeCacheCreationTokens {
|
||||
cacheCreationTokens = maybeCacheCreationTokens
|
||||
}
|
||||
}
|
||||
promptTokens -= cacheCreationTokens
|
||||
}
|
||||
|
||||
calculateQuota := 0.0
|
||||
if !priceData.UsePrice {
|
||||
calculateQuota = float64(promptTokens)
|
||||
@@ -275,7 +287,28 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
|
||||
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)
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
|
||||
}
|
||||
|
||||
func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData helper.PriceData) int {
|
||||
if priceData.CacheCreationRatio == 1 {
|
||||
return 0
|
||||
}
|
||||
quotaPrice := priceData.ModelRatio / common.QuotaPerUnit
|
||||
promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio
|
||||
promptCacheReadPrice := quotaPrice * priceData.CacheRatio
|
||||
completionPrice := quotaPrice * priceData.CompletionRatio
|
||||
|
||||
cost := usage.Cost
|
||||
totalPromptTokens := float64(usage.PromptTokens)
|
||||
completionTokens := float64(usage.CompletionTokens)
|
||||
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)
|
||||
|
||||
return int(math.Round((cost -
|
||||
totalPromptTokens*quotaPrice +
|
||||
promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) -
|
||||
completionTokens*completionPrice) /
|
||||
(promptCacheCreatePrice - quotaPrice)))
|
||||
}
|
||||
|
||||
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
@@ -352,7 +385,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||
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)
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
|
||||
}
|
||||
|
||||
func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
|
||||
|
||||
@@ -17,6 +17,8 @@ const (
|
||||
const (
|
||||
// Gemini Audio Input Price
|
||||
Gemini25FlashPreviewInputAudioPrice = 1.00
|
||||
Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash`
|
||||
Gemini25FlashLitePreviewInputAudioPrice = 0.50
|
||||
Gemini25FlashNativeAudioInputAudioPrice = 3.00
|
||||
Gemini20FlashInputAudioPrice = 0.70
|
||||
)
|
||||
@@ -64,10 +66,14 @@ func GetFileSearchPricePerThousand() float64 {
|
||||
}
|
||||
|
||||
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
|
||||
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
|
||||
return Gemini25FlashPreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
|
||||
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
|
||||
return Gemini25FlashNativeAudioInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
|
||||
return Gemini25FlashLitePreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
|
||||
return Gemini25FlashPreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash") {
|
||||
return Gemini25FlashProductionInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
|
||||
return Gemini20FlashInputAudioPrice
|
||||
}
|
||||
|
||||
@@ -73,15 +73,15 @@ func GetGroupRatio(name string) float64 {
|
||||
return ratio
|
||||
}
|
||||
|
||||
func GetGroupGroupRatio(group, name string) (float64, bool) {
|
||||
func GetGroupGroupRatio(userGroup, usingGroup string) (float64, bool) {
|
||||
groupGroupRatioMutex.RLock()
|
||||
defer groupGroupRatioMutex.RUnlock()
|
||||
|
||||
gp, ok := GroupGroupRatio[group]
|
||||
gp, ok := GroupGroupRatio[userGroup]
|
||||
if !ok {
|
||||
return -1, false
|
||||
}
|
||||
ratio, ok := gp[name]
|
||||
ratio, ok := gp[usingGroup]
|
||||
if !ok {
|
||||
return -1, false
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ var defaultModelRatio = map[string]float64{
|
||||
"gemini-2.0-flash": 0.05,
|
||||
"gemini-2.5-pro-exp-03-25": 0.625,
|
||||
"gemini-2.5-pro-preview-03-25": 0.625,
|
||||
"gemini-2.5-pro": 0.625,
|
||||
"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,
|
||||
@@ -148,6 +149,8 @@ var defaultModelRatio = map[string]float64{
|
||||
"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 模型设置默认倍率
|
||||
"gemini-2.5-flash-lite-preview-06-17": 0.05,
|
||||
"gemini-2.5-flash": 0.15,
|
||||
"text-embedding-004": 0.001,
|
||||
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
|
||||
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
|
||||
@@ -423,7 +426,12 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error {
|
||||
func GetCompletionRatio(name string) float64 {
|
||||
CompletionRatioMutex.RLock()
|
||||
defer CompletionRatioMutex.RUnlock()
|
||||
|
||||
if strings.HasPrefix(name, "gpt-4-gizmo") {
|
||||
name = "gpt-4-gizmo-*"
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-4o-gizmo") {
|
||||
name = "gpt-4o-gizmo-*"
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
if ratio, ok := CompletionRatio[name]; ok {
|
||||
return ratio
|
||||
@@ -441,12 +449,6 @@ func GetCompletionRatio(name string) float64 {
|
||||
|
||||
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
lowercaseName := strings.ToLower(name)
|
||||
if strings.HasPrefix(name, "gpt-4-gizmo") {
|
||||
name = "gpt-4-gizmo-*"
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-4o-gizmo") {
|
||||
name = "gpt-4o-gizmo-*"
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") {
|
||||
if strings.HasPrefix(name, "gpt-4o") {
|
||||
if name == "gpt-4o-2024-05-13" {
|
||||
@@ -499,13 +501,18 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
} else if strings.HasPrefix(name, "gemini-2.0") {
|
||||
return 4, true
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
|
||||
return 8, true
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 同上
|
||||
if strings.HasSuffix(name, "-nothinking") {
|
||||
return 4, false
|
||||
} else {
|
||||
return 3.5 / 0.6, false
|
||||
return 8, false
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
|
||||
if strings.HasSuffix(name, "-nothinking") {
|
||||
return 4, false
|
||||
}
|
||||
return 3.5 / 0.15, false
|
||||
}
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
|
||||
return 4, true
|
||||
}
|
||||
return 2.5 / 0.3, true
|
||||
}
|
||||
return 4, false
|
||||
}
|
||||
|
||||
@@ -34,20 +34,20 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoginForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password } = inputs;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const { username, password } = inputs;
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
let navigate = useNavigate();
|
||||
const [status, setStatus] = useState({});
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailLogin, setShowEmailLogin] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
@@ -59,7 +59,6 @@ const LoginForm = () => {
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -69,19 +68,22 @@ const LoginForm = () => {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
const [status] = useState(() => {
|
||||
const savedStatus = localStorage.getItem('status');
|
||||
return savedStatus ? JSON.parse(savedStatus) : {};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录'));
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
@@ -356,9 +358,19 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
|
||||
</div>
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -451,9 +463,19 @@ const LoginForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
|
||||
</div>
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -499,8 +521,11 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
|
||||
@@ -78,8 +78,11 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
|
||||
@@ -78,8 +78,11 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
|
||||
@@ -35,6 +35,7 @@ import { UserContext } from '../../context/User/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const RegisterForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
@@ -45,15 +46,12 @@ const RegisterForm = () => {
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password, password2 } = inputs;
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailRegister, setShowEmailRegister] = useState(false);
|
||||
const [status, setStatus] = useState({});
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
@@ -63,7 +61,6 @@ const RegisterForm = () => {
|
||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
let navigate = useNavigate();
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -73,18 +70,22 @@ const RegisterForm = () => {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
const [status] = useState(() => {
|
||||
const savedStatus = localStorage.getItem('status');
|
||||
return savedStatus ? JSON.parse(savedStatus) : {};
|
||||
});
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(() => {
|
||||
return status.email_verification ?? false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
setShowEmailVerification(status.email_verification);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
setShowEmailVerification(status.email_verification);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}, []);
|
||||
}, [status]);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setWechatLoading(true);
|
||||
@@ -541,8 +542,11 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
@@ -43,6 +44,7 @@ const HeaderBar = () => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
@@ -53,9 +55,44 @@ const HeaderBar = () => {
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
|
||||
|
||||
const calculateUnreadCount = () => {
|
||||
if (!announcements.length) return 0;
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
|
||||
};
|
||||
|
||||
const getUnreadKeys = () => {
|
||||
if (!announcements.length) return [];
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadCount(calculateUnreadCount());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [announcements]);
|
||||
|
||||
const mainNavLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
@@ -106,6 +143,25 @@ const HeaderBar = () => {
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleNoticeOpen = () => {
|
||||
setNoticeVisible(true);
|
||||
};
|
||||
|
||||
const handleNoticeClose = () => {
|
||||
setNoticeVisible(false);
|
||||
if (announcements.length) {
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
|
||||
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
|
||||
}
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.body.setAttribute('theme-mode', 'dark');
|
||||
@@ -353,15 +409,14 @@ const HeaderBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前路由是否以/console开头
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
onClose={handleNoticeClose}
|
||||
isMobile={styleState.isMobile}
|
||||
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
|
||||
unreadKeys={getUnreadKeys()}
|
||||
/>
|
||||
<div className="w-full px-2">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
@@ -462,14 +517,27 @@ const HeaderBar = () => {
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={() => setNoticeVisible(true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
{unreadCount > 0 ? (
|
||||
<Badge count={unreadCount} type="danger" overflowCount={99}>
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={handleNoticeOpen}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={handleNoticeOpen}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { API, showError, getRelativeTime } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { Bell, Megaphone } from 'lucide-react';
|
||||
|
||||
const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
||||
|
||||
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||
|
||||
const processedAnnouncements = useMemo(() => {
|
||||
return (announcements || []).slice(0, 20).map(item => ({
|
||||
key: getKeyForItem(item),
|
||||
type: item.type || 'default',
|
||||
time: getRelativeTime(item.publishDate),
|
||||
content: item.content,
|
||||
extra: item.extra,
|
||||
isUnread: unreadSet.has(getKeyForItem(item))
|
||||
}));
|
||||
}, [announcements, unreadSet]);
|
||||
|
||||
const handleCloseTodayNotice = () => {
|
||||
const today = new Date().toDateString();
|
||||
@@ -44,7 +66,13 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const renderContent = () => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setActiveTab(defaultTab);
|
||||
}
|
||||
}, [defaultTab, visible]);
|
||||
|
||||
const renderMarkdownNotice = () => {
|
||||
if (loading) {
|
||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||
}
|
||||
@@ -64,14 +92,80 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className="notice-content-scroll max-h-[60vh] overflow-y-auto pr-2"
|
||||
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAnnouncementTimeline = () => {
|
||||
if (processedAnnouncements.length === 0) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无系统公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
|
||||
<Timeline mode="alternate">
|
||||
{processedAnnouncements.map((item, idx) => {
|
||||
const htmlContent = marked.parse(item.content || '');
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type}
|
||||
time={item.time}
|
||||
className={item.isUnread ? '' : ''}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={item.isUnread ? 'shine-text' : ''}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
{item.extra && (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (activeTab === 'inApp') {
|
||||
return renderMarkdownNotice();
|
||||
}
|
||||
return renderAnnouncementTimeline();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('系统公告')}
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>{t('系统公告')}</span>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
type='card'
|
||||
size='small'
|
||||
>
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={(
|
||||
@@ -82,7 +176,7 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
)}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderContent()}
|
||||
{renderBody()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { API, getLogo, getSystemName, showError, setStatusData } from '../../hel
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
const { Sider, Content, Header, Footer } = Layout;
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
const PageLayout = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
@@ -94,8 +94,6 @@ const PageLayout = () => {
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
marginTop: '64px',
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: styleState.isMobile ? 'visible' : 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -1,115 +1,183 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { isMobile } from '../../helpers';
|
||||
import {
|
||||
Modal,
|
||||
Transfer,
|
||||
Table,
|
||||
Input,
|
||||
Space,
|
||||
Checkbox,
|
||||
Avatar,
|
||||
Highlight,
|
||||
Select,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconClose } from '@douyinfe/semi-icons';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
const CHANNEL_STATUS_CONFIG = {
|
||||
1: { color: 'green', text: '启用' },
|
||||
2: { color: 'red', text: '禁用' },
|
||||
3: { color: 'amber', text: '自禁' },
|
||||
default: { color: 'grey', text: '未知' }
|
||||
};
|
||||
|
||||
const getChannelStatusConfig = (status) => {
|
||||
return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default;
|
||||
};
|
||||
|
||||
export default function ChannelSelectorModal({
|
||||
t,
|
||||
const ChannelSelectorModal = forwardRef(({
|
||||
visible,
|
||||
onCancel,
|
||||
onOk,
|
||||
allChannels = [],
|
||||
selectedChannelIds = [],
|
||||
allChannels,
|
||||
selectedChannelIds,
|
||||
setSelectedChannelIds,
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
}) {
|
||||
t,
|
||||
}, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => {
|
||||
const channelId = item.key || item.value;
|
||||
const currentEndpoint = channelEndpoints[channelId];
|
||||
const baseUrl = item._originalData?.base_url || '';
|
||||
const status = item._originalData?.status || 0;
|
||||
const statusConfig = getChannelStatusConfig(status);
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar color={statusConfig.color} size="small">
|
||||
{statusConfig.text}
|
||||
</Avatar>
|
||||
<div className="info">
|
||||
<div className="name">
|
||||
{isSelected ? (
|
||||
item.label
|
||||
) : (
|
||||
<Highlight sourceString={item.label} searchWords={[searchText]} />
|
||||
)}
|
||||
</div>
|
||||
<div className="email" style={showEndpoint ? { display: 'flex', alignItems: 'center', gap: '4px' } : {}}>
|
||||
<span className="text-xs text-gray-500 truncate max-w-[200px]" title={baseUrl}>
|
||||
{isSelected ? (
|
||||
baseUrl
|
||||
) : (
|
||||
<Highlight sourceString={baseUrl} searchWords={[searchText]} />
|
||||
)}
|
||||
</span>
|
||||
{showEndpoint && (
|
||||
<Input
|
||||
size="small"
|
||||
value={currentEndpoint}
|
||||
onChange={(value) => updateChannelEndpoint(channelId, value)}
|
||||
placeholder="/api/ratio_config"
|
||||
className="flex-1 text-xs"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
{isSelected && !showEndpoint && (
|
||||
<span className="text-xs text-gray-700 font-mono bg-gray-100 px-2 py-1 rounded ml-2">
|
||||
{currentEndpoint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetPagination: () => {
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
|
||||
const searchLower = searchText.trim().toLowerCase();
|
||||
const matched = searchLower
|
||||
? allChannels.filter((item) => {
|
||||
const name = (item.label || '').toLowerCase();
|
||||
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||
})
|
||||
: allChannels;
|
||||
|
||||
setFilteredData(matched);
|
||||
}, [allChannels, searchText]);
|
||||
|
||||
const total = filteredData.length;
|
||||
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
|
||||
const updateEndpoint = (channelId, endpoint) => {
|
||||
if (typeof updateChannelEndpoint === 'function') {
|
||||
updateChannelEndpoint(channelId, endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSourceItem = (item) => {
|
||||
const renderEndpointCell = (text, record) => {
|
||||
const channelId = record.key || record.value;
|
||||
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||
|
||||
const getEndpointType = (ep) => {
|
||||
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||
if (ep === '/api/pricing') return 'pricing';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
const currentType = getEndpointType(currentEndpoint);
|
||||
|
||||
const handleTypeChange = (val) => {
|
||||
if (val === 'ratio_config') {
|
||||
updateEndpoint(channelId, '/api/ratio_config');
|
||||
} else if (val === 'pricing') {
|
||||
updateEndpoint(channelId, '/api/pricing');
|
||||
} else {
|
||||
if (currentType !== 'custom') {
|
||||
updateEndpoint(channelId, '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="components-transfer-source-item" key={item.key}>
|
||||
<Checkbox
|
||||
onChange={item.onChange}
|
||||
checked={item.checked}
|
||||
style={{ height: 52, alignItems: 'center' }}
|
||||
>
|
||||
<ChannelInfo item={item} showEndpoint={true} />
|
||||
</Checkbox>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
optionList={[
|
||||
{ label: 'ratio_config', value: 'ratio_config' },
|
||||
{ label: 'pricing', value: 'pricing' },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
]}
|
||||
/>
|
||||
{currentType === 'custom' && (
|
||||
<Input
|
||||
size="small"
|
||||
value={currentEndpoint}
|
||||
onChange={(val) => updateEndpoint(channelId, val)}
|
||||
placeholder="/your/endpoint"
|
||||
style={{ width: 160, fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedItem = (item) => {
|
||||
return (
|
||||
<div className="components-transfer-selected-item" key={item.key}>
|
||||
<ChannelInfo item={item} isSelected={true} />
|
||||
<IconClose style={{ cursor: 'pointer' }} onClick={item.onRemove} />
|
||||
</div>
|
||||
);
|
||||
const renderStatusCell = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const channelFilter = (input, item) => {
|
||||
const searchLower = input.toLowerCase();
|
||||
return item.label.toLowerCase().includes(searchLower) ||
|
||||
(item._originalData?.base_url || '').toLowerCase().includes(searchLower);
|
||||
const renderNameCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const renderBaseUrlCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'label',
|
||||
render: renderNameCell,
|
||||
},
|
||||
{
|
||||
title: t('源地址'),
|
||||
dataIndex: '_originalData.base_url',
|
||||
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
dataIndex: 'endpoint',
|
||||
fixed: 'right',
|
||||
render: renderEndpointCell,
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedChannelIds,
|
||||
onChange: (keys) => setSelectedChannelIds(keys),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -118,26 +186,51 @@ export default function ChannelSelectorModal({
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
|
||||
width={1000}
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
keepDOM
|
||||
lazyRender={false}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Transfer
|
||||
style={{ width: '100%' }}
|
||||
dataSource={allChannels}
|
||||
value={selectedChannelIds}
|
||||
onChange={setSelectedChannelIds}
|
||||
renderSourceItem={renderSourceItem}
|
||||
renderSelectedItem={renderSelectedItem}
|
||||
filter={channelFilter}
|
||||
inputProps={{ placeholder: t('搜索渠道名称或地址') }}
|
||||
onSearch={setSearchText}
|
||||
emptyContent={{
|
||||
left: t('暂无渠道'),
|
||||
right: t('暂无选择'),
|
||||
search: t('无搜索结果'),
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索渠道名称或地址')}
|
||||
value={searchText}
|
||||
onChange={setSearchText}
|
||||
showClear
|
||||
className="!rounded-full"
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paginatedData}
|
||||
rowKey="key"
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: total,
|
||||
}),
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (curr, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ChannelSelectorModal;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsChats from '../../pages/Setting/Chat/SettingsChats.js';
|
||||
import { API, showError } from '../../helpers';
|
||||
|
||||
const ChatsSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
/* 聊天设置 */
|
||||
Chats: '[]',
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.key.endsWith('Enabled') ||
|
||||
['DefaultCollapseSidebar'].includes(item.key)
|
||||
) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError('刷新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 聊天设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsChats options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatsSetting;
|
||||
@@ -5,6 +5,7 @@ import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
|
||||
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
|
||||
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
|
||||
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Dashboard/SettingsDataDashboard.js';
|
||||
|
||||
const DashboardSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
@@ -23,6 +24,11 @@ const DashboardSetting = () => {
|
||||
FAQ: '',
|
||||
UptimeKumaUrl: '',
|
||||
UptimeKumaSlug: '',
|
||||
|
||||
/* 数据看板 */
|
||||
DataExportEnabled: false,
|
||||
DataExportDefaultTime: 'hour',
|
||||
DataExportInterval: 5,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
@@ -37,6 +43,10 @@ const DashboardSetting = () => {
|
||||
if (item.key in inputs) {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
if (item.key.endsWith('Enabled') &&
|
||||
(item.key === 'DataExportEnabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
}
|
||||
});
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
@@ -106,9 +116,9 @@ const DashboardSetting = () => {
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{/* API信息管理 */}
|
||||
{/* 数据看板设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
|
||||
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* 系统公告管理 */}
|
||||
@@ -116,6 +126,11 @@ const DashboardSetting = () => {
|
||||
<SettingsAnnouncements options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* API信息管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* 常见问答管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsFAQ options={inputs} refresh={onRefresh} />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing.js';
|
||||
import { API, showError } from '../../helpers';
|
||||
|
||||
const DrawingSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
/* 绘图设置 */
|
||||
DrawingEnabled: false,
|
||||
MjNotifyEnabled: false,
|
||||
MjAccountFilterEnabled: false,
|
||||
MjForwardUrlEnabled: false,
|
||||
MjModeClearEnabled: false,
|
||||
MjActionCheckSuccessEnabled: false,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError('刷新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 绘图设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsDrawing options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingSetting;
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
|
||||
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
|
||||
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
|
||||
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
|
||||
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
|
||||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
|
||||
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||
import { API, showError } from '../../helpers';
|
||||
|
||||
const OperationSetting = () => {
|
||||
@@ -29,14 +26,6 @@ const OperationSetting = () => {
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
|
||||
/* 绘图设置 */
|
||||
DrawingEnabled: false,
|
||||
MjNotifyEnabled: false,
|
||||
MjAccountFilterEnabled: false,
|
||||
MjForwardUrlEnabled: false,
|
||||
MjModeClearEnabled: false,
|
||||
MjActionCheckSuccessEnabled: false,
|
||||
|
||||
/* 敏感词设置 */
|
||||
CheckSensitiveEnabled: false,
|
||||
CheckSensitiveOnPromptEnabled: false,
|
||||
@@ -45,20 +34,12 @@ const OperationSetting = () => {
|
||||
/* 日志设置 */
|
||||
LogConsumeEnabled: false,
|
||||
|
||||
/* 数据看板 */
|
||||
DataExportEnabled: false,
|
||||
DataExportDefaultTime: 'hour',
|
||||
DataExportInterval: 5,
|
||||
|
||||
/* 监控设置 */
|
||||
ChannelDisableThreshold: 0,
|
||||
QuotaRemindThreshold: 0,
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
|
||||
/* 聊天设置 */
|
||||
Chats: '[]',
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
@@ -107,10 +88,6 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsGeneral options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 绘图设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsDrawing options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 屏蔽词过滤设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsSensitiveWords options={inputs} refresh={onRefresh} />
|
||||
@@ -119,10 +96,6 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsLog options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 数据看板 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 监控设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsMonitoring options={inputs} refresh={onRefresh} />
|
||||
@@ -131,10 +104,6 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsCreditLimit options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 聊天设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsChats options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
|
||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PaymentSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
ServerAddress: '',
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
TopupGroupRatio: '',
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
switch (item.key) {
|
||||
case 'TopupGroupRatio':
|
||||
try {
|
||||
newInputs[item.key] = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
} catch (error) {
|
||||
console.error('解析TopupGroupRatio出错:', error);
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
newInputs[item.key] = parseFloat(item.value);
|
||||
break;
|
||||
default:
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError(t('刷新失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsGeneralPayment options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSetting;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
import { API, showError, showSuccess } from '../../helpers/index.js';
|
||||
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||
import { API, showError } from '../../helpers/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
||||
|
||||
@@ -24,14 +23,14 @@ const RateLimitSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (item.key === 'ModelRequestRateLimitGroup') {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
}
|
||||
if (item.key === 'ModelRequestRateLimitGroup') {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
}
|
||||
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -82,9 +82,18 @@ const RatioSetting = () => {
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 模型倍率设置以及可视化编辑器 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='line'>
|
||||
<Tabs type='card'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
<ModelRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
|
||||
<GroupRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor
|
||||
@@ -106,10 +115,6 @@ const RatioSetting = () => {
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
{/* 分组倍率设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
verifyJSON,
|
||||
} from '../../helpers';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -42,17 +41,9 @@ const SystemSetting = () => {
|
||||
SMTPAccount: '',
|
||||
SMTPFrom: '',
|
||||
SMTPToken: '',
|
||||
ServerAddress: '',
|
||||
WorkerUrl: '',
|
||||
WorkerValidKey: '',
|
||||
WorkerAllowHttpImageRequestEnabled: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
TopupGroupRatio: '',
|
||||
PayAddress: '',
|
||||
CustomCallbackAddress: '',
|
||||
Footer: '',
|
||||
WeChatAuthEnabled: '',
|
||||
WeChatServerAddress: '',
|
||||
@@ -73,7 +64,6 @@ const SystemSetting = () => {
|
||||
LinuxDOOAuthEnabled: '',
|
||||
LinuxDOClientId: '',
|
||||
LinuxDOClientSecret: '',
|
||||
PayMethods: '',
|
||||
});
|
||||
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
@@ -200,11 +190,6 @@ const SystemSetting = () => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitServerAddress = async () => {
|
||||
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
|
||||
await updateOptions([{ key: 'ServerAddress', value: ServerAddress }]);
|
||||
};
|
||||
|
||||
const submitWorker = async () => {
|
||||
let WorkerUrl = removeTrailingSlash(inputs.WorkerUrl);
|
||||
const options = [
|
||||
@@ -220,56 +205,6 @@ const SystemSetting = () => {
|
||||
await updateOptions(options);
|
||||
};
|
||||
|
||||
const submitPayAddress = async () => {
|
||||
if (inputs.ServerAddress === '') {
|
||||
showError('请先填写服务器地址');
|
||||
return;
|
||||
}
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
if (!verifyJSON(inputs.TopupGroupRatio)) {
|
||||
showError('充值分组倍率不是合法的 JSON 字符串');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
if (!verifyJSON(inputs.PayMethods)) {
|
||||
showError('充值方式设置不是合法的 JSON 字符串');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
|
||||
];
|
||||
|
||||
if (inputs.EpayId !== '') {
|
||||
options.push({ key: 'EpayId', value: inputs.EpayId });
|
||||
}
|
||||
if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
|
||||
options.push({ key: 'EpayKey', value: inputs.EpayKey });
|
||||
}
|
||||
if (inputs.Price !== '') {
|
||||
options.push({ key: 'Price', value: inputs.Price.toString() });
|
||||
}
|
||||
if (inputs.MinTopUp !== '') {
|
||||
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
|
||||
}
|
||||
if (inputs.CustomCallbackAddress !== '') {
|
||||
options.push({
|
||||
key: 'CustomCallbackAddress',
|
||||
value: inputs.CustomCallbackAddress,
|
||||
});
|
||||
}
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
|
||||
}
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
|
||||
await updateOptions(options);
|
||||
};
|
||||
|
||||
const submitSMTP = async () => {
|
||||
const options = [];
|
||||
|
||||
@@ -551,17 +486,6 @@ const SystemSetting = () => {
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<Form.Section text='通用设置'>
|
||||
<Form.Input
|
||||
field='ServerAddress'
|
||||
label='服务器地址'
|
||||
placeholder='例如:https://yourdomain.com'
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={submitServerAddress}>更新服务器地址</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form.Section text='代理设置'>
|
||||
<Text>
|
||||
@@ -604,80 +528,6 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text='支付设置'>
|
||||
<Text>
|
||||
(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='PayAddress'
|
||||
label='支付地址'
|
||||
placeholder='例如:https://yourdomain.com'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayId'
|
||||
label='易支付商户ID'
|
||||
placeholder='例如:0001'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayKey'
|
||||
label='易支付商户密钥'
|
||||
placeholder='敏感信息不会发送到前端显示'
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='CustomCallbackAddress'
|
||||
label='回调地址'
|
||||
placeholder='例如:https://yourdomain.com'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='Price'
|
||||
precision={2}
|
||||
label='充值价格(x元/美金)'
|
||||
placeholder='例如:7,就是7元/美金'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='MinTopUp'
|
||||
label='最低充值美元数量'
|
||||
placeholder='例如:2,就是最低充值2$'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.TextArea
|
||||
field='TopupGroupRatio'
|
||||
label='充值分组倍率'
|
||||
placeholder='为一个 JSON 文本,键为组名称,值为倍率'
|
||||
autosize
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='PayMethods'
|
||||
label='充值方式设置'
|
||||
placeholder='为一个 JSON 文本'
|
||||
autosize
|
||||
/>
|
||||
<Button onClick={submitPayAddress}>更新支付设置</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text='配置登录注册'>
|
||||
<Row
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
Coins,
|
||||
Tags
|
||||
Tags,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
@@ -40,7 +40,8 @@ import {
|
||||
Card,
|
||||
Form,
|
||||
Tabs,
|
||||
TabPane
|
||||
TabPane,
|
||||
Select
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -50,20 +51,16 @@ import EditChannel from '../../pages/Channel/EditChannel.js';
|
||||
import {
|
||||
IconTreeTriangleDown,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSetting,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
IconCopy,
|
||||
IconSmallTriangleRight
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { loadChannelModels } from '../../helpers/index.js';
|
||||
import { loadChannelModels, isMobile, copy } from '../../helpers';
|
||||
import EditTagModal from '../../pages/Channel/EditTagModal.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const ChannelsTable = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -114,7 +111,7 @@ const ChannelsTable = () => {
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -187,6 +184,11 @@ const ChannelsTable = () => {
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
|
||||
// 状态筛选 all / enabled / disabled
|
||||
const [statusFilter, setStatusFilter] = useState(
|
||||
localStorage.getItem('channel-status-filter') || 'all'
|
||||
);
|
||||
|
||||
// Load saved column preferences from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem('channels-table-columns');
|
||||
@@ -549,7 +551,6 @@ const ChannelsTable = () => {
|
||||
type='warning'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconStop />}
|
||||
onClick={() => manageChannel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
@@ -560,7 +561,6 @@ const ChannelsTable = () => {
|
||||
type='secondary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconPlay />}
|
||||
onClick={() => manageChannel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
@@ -572,7 +572,6 @@ const ChannelsTable = () => {
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => {
|
||||
setEditingChannel(record);
|
||||
setShowEdit(true);
|
||||
@@ -597,19 +596,7 @@ const ChannelsTable = () => {
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
// 标签操作的下拉菜单项
|
||||
const tagMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('编辑'),
|
||||
icon: <IconEdit />,
|
||||
onClick: () => {
|
||||
setShowEditTag(true);
|
||||
setEditingTag(record.key);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 标签操作按钮
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button
|
||||
@@ -617,7 +604,6 @@ const ChannelsTable = () => {
|
||||
type='secondary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconPlay />}
|
||||
onClick={() => manageTag(record.key, 'enable')}
|
||||
>
|
||||
{t('启用全部')}
|
||||
@@ -627,24 +613,22 @@ const ChannelsTable = () => {
|
||||
type='warning'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconStop />}
|
||||
onClick={() => manageTag(record.key, 'disable')}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={tagMenuItems}
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setShowEditTag(true);
|
||||
setEditingTag(record.key);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -676,18 +660,22 @@ const ChannelsTable = () => {
|
||||
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModels, setTestingModels] = useState(new Set());
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [testQueue, setTestQueue] = useState([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
const [activeTypeKey, setActiveTypeKey] = useState('all');
|
||||
const [typeCounts, setTypeCounts] = useState({});
|
||||
const requestCounter = useRef(0);
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('channels');
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchGroup: '',
|
||||
searchModel: '',
|
||||
};
|
||||
const allSelectingRef = useRef(false);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
@@ -864,12 +852,30 @@ const ChannelsTable = () => {
|
||||
setChannels(channelDates);
|
||||
};
|
||||
|
||||
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
|
||||
const loadChannels = async (
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
enableTagMode,
|
||||
typeKey = activeTypeKey,
|
||||
statusF,
|
||||
) => {
|
||||
if (statusF === undefined) statusF = statusFilter;
|
||||
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||
setLoading(true);
|
||||
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = ++requestCounter.current; // 记录当前请求序号
|
||||
setLoading(true);
|
||||
const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||
);
|
||||
if (res === undefined || reqId !== requestCounter.current) {
|
||||
return;
|
||||
@@ -920,7 +926,7 @@ const ChannelsTable = () => {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage, pageSize, idSort, enableTagMode);
|
||||
} else {
|
||||
await searchChannels(enableTagMode);
|
||||
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1026,7 +1032,7 @@ const ChannelsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取表单值的辅助函数,确保所有值都是字符串
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
@@ -1036,27 +1042,35 @@ const ChannelsTable = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const searchChannels = async (enableTagMode) => {
|
||||
const searchChannels = async (
|
||||
enableTagMode,
|
||||
typeKey = activeTypeKey,
|
||||
statusF = statusFilter,
|
||||
page = 1,
|
||||
pageSz = pageSize,
|
||||
sortFlag = idSort,
|
||||
) => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||||
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : '';
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const { items = [], type_counts = {} } = data;
|
||||
const { items = [], total = 0, type_counts = {} } = data;
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
setChannelFormat(items, enableTagMode);
|
||||
setActivePage(1);
|
||||
setChannelCount(total);
|
||||
setActivePage(page);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -1096,7 +1110,22 @@ const ChannelsTable = () => {
|
||||
const processTestQueue = async () => {
|
||||
if (!isProcessingQueue || testQueue.length === 0) return;
|
||||
|
||||
const { channel, model } = testQueue[0];
|
||||
const { channel, model, indexInFiltered } = testQueue[0];
|
||||
|
||||
// 自动翻页到正在测试的模型所在页
|
||||
if (currentTestChannel && currentTestChannel.id === channel.id) {
|
||||
let pageNo;
|
||||
if (indexInFiltered !== undefined) {
|
||||
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
|
||||
} else {
|
||||
const filteredModelsList = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
|
||||
const modelIdx = filteredModelsList.indexOf(model);
|
||||
pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
|
||||
}
|
||||
setModelTablePage(pageNo);
|
||||
}
|
||||
|
||||
try {
|
||||
setTestingModels(prev => new Set([...prev, model]));
|
||||
@@ -1159,16 +1188,22 @@ const ChannelsTable = () => {
|
||||
|
||||
setIsBatchTesting(true);
|
||||
|
||||
const models = currentTestChannel.models
|
||||
// 重置分页到第一页
|
||||
setModelTablePage(1);
|
||||
|
||||
const filteredModels = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
setTestQueue(models.map(model => ({
|
||||
channel: currentTestChannel,
|
||||
model
|
||||
})));
|
||||
setTestQueue(
|
||||
filteredModels.map((model, idx) => ({
|
||||
channel: currentTestChannel,
|
||||
model,
|
||||
indexInFiltered: idx, // 记录在过滤列表中的顺序
|
||||
})),
|
||||
);
|
||||
setIsProcessingQueue(true);
|
||||
};
|
||||
|
||||
@@ -1182,6 +1217,8 @@ const ChannelsTable = () => {
|
||||
} else {
|
||||
setShowModelTestModal(false);
|
||||
setModelSearchKeyword('');
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1262,32 +1299,31 @@ const ChannelsTable = () => {
|
||||
};
|
||||
|
||||
let pageData = channels;
|
||||
if (activeTypeKey !== 'all') {
|
||||
const typeVal = parseInt(activeTypeKey);
|
||||
if (!isNaN(typeVal)) {
|
||||
pageData = pageData.filter((ch) => {
|
||||
if (ch.children !== undefined) {
|
||||
return ch.children.some((c) => c.type === typeVal);
|
||||
}
|
||||
return ch.type === typeVal;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
setActivePage(page);
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
loadChannels(1, size, idSort, enableTagMode)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(1, size, idSort, enableTagMode)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
@@ -1468,6 +1504,7 @@ const ChannelsTable = () => {
|
||||
<div className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
theme='light'
|
||||
type='danger'
|
||||
@@ -1484,6 +1521,7 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
disabled={!enableBatchDelete}
|
||||
theme='light'
|
||||
type='primary'
|
||||
@@ -1494,11 +1532,13 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
size='small'
|
||||
trigger='click'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='warning'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1517,6 +1557,7 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1535,6 +1576,7 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='danger'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1553,6 +1595,7 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
className="!rounded-full w-full"
|
||||
@@ -1572,10 +1615,20 @@ const ChannelsTable = () => {
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
|
||||
<Button size='small' theme='light' type='tertiary' className="!rounded-full w-full md:w-auto">
|
||||
{t('批量操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
@@ -1584,11 +1637,17 @@ const ChannelsTable = () => {
|
||||
{t('使用ID排序')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={idSort}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('id-sort', v + '');
|
||||
setIdSort(v);
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1598,6 +1657,7 @@ const ChannelsTable = () => {
|
||||
{t('开启批量操作')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableBatchDelete}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-batch-delete', v + '');
|
||||
@@ -1611,6 +1671,7 @@ const ChannelsTable = () => {
|
||||
{t('标签聚合模式')}
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enableTagMode}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-tag-mode', v + '');
|
||||
@@ -1620,6 +1681,27 @@ const ChannelsTable = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选器 */}
|
||||
<div className="flex items-center justify-between w-full md:w-auto">
|
||||
<Typography.Text strong className="mr-2">
|
||||
{t('状态筛选')}
|
||||
</Typography.Text>
|
||||
<Select
|
||||
size='small'
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('channel-status-filter', v);
|
||||
setStatusFilter(v);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
|
||||
}}
|
||||
>
|
||||
<Select.Option value="all">{t('全部')}</Select.Option>
|
||||
<Select.Option value="enabled">{t('已启用')}</Select.Option>
|
||||
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1628,6 +1710,7 @@ const ChannelsTable = () => {
|
||||
<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
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
@@ -1643,9 +1726,9 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconRefresh />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={refresh}
|
||||
>
|
||||
@@ -1653,9 +1736,9 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
@@ -1677,9 +1760,10 @@ const ChannelsTable = () => {
|
||||
>
|
||||
<div className="relative w-full md:w-64">
|
||||
<Form.Input
|
||||
size='small'
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
|
||||
placeholder={t('渠道ID,名称,密钥,API地址')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
@@ -1687,6 +1771,7 @@ const ChannelsTable = () => {
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Form.Input
|
||||
size='small'
|
||||
field="searchModel"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型关键字')}
|
||||
@@ -1695,8 +1780,9 @@ const ChannelsTable = () => {
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<div className="w-full md:w-32">
|
||||
<Form.Select
|
||||
size='small'
|
||||
field="searchGroup"
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
@@ -1715,6 +1801,7 @@ const ChannelsTable = () => {
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
@@ -1723,6 +1810,7 @@ const ChannelsTable = () => {
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
@@ -1766,9 +1854,9 @@ const ChannelsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
dataSource={pageData}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
@@ -1806,7 +1894,7 @@ const ChannelsTable = () => {
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
loading={loading}
|
||||
loading={loading || searching}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1842,13 +1930,73 @@ const ChannelsTable = () => {
|
||||
<Modal
|
||||
title={
|
||||
currentTestChannel && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" className="!text-xs flex items-center">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" className="!text-xs flex items-center">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-2 w-full">
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => {
|
||||
setModelSearchKeyword(v);
|
||||
setModelTablePage(1);
|
||||
}}
|
||||
className="!w-full !rounded-full"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Button
|
||||
theme='light'
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
if (selectedModelKeys.length === 0) {
|
||||
showError(t('请先选择模型!'));
|
||||
return;
|
||||
}
|
||||
copy(selectedModelKeys.join(',')).then((ok) => {
|
||||
if (ok) {
|
||||
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
|
||||
} else {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制已选')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
if (!currentTestChannel) return;
|
||||
const successKeys = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
|
||||
.filter((m) => {
|
||||
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
|
||||
return result && result.success;
|
||||
});
|
||||
if (successKeys.length === 0) {
|
||||
showInfo(t('暂无成功模型'));
|
||||
}
|
||||
setSelectedModelKeys(successKeys);
|
||||
}}
|
||||
>
|
||||
{t('选择成功')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1898,22 +2046,11 @@ const ChannelsTable = () => {
|
||||
}
|
||||
maskClosable={!isBatchTesting}
|
||||
className="!rounded-lg"
|
||||
size="large"
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<div className="model-test-scroll">
|
||||
{currentTestChannel && (
|
||||
<div>
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={modelSearchKeyword}
|
||||
onChange={(v) => setModelSearchKeyword(v)}
|
||||
className="w-64 !rounded-full"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
@@ -1987,16 +2124,47 @@ const ChannelsTable = () => {
|
||||
}
|
||||
}
|
||||
]}
|
||||
dataSource={currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
)
|
||||
.map((model) => ({
|
||||
dataSource={(() => {
|
||||
const filtered = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filtered.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model
|
||||
}))}
|
||||
pagination={false}
|
||||
key: model,
|
||||
}));
|
||||
})()}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedModelKeys,
|
||||
onChange: (keys) => {
|
||||
if (allSelectingRef.current) {
|
||||
allSelectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedModelKeys(keys);
|
||||
},
|
||||
onSelectAll: (checked) => {
|
||||
const filtered = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
|
||||
allSelectingRef.current = true;
|
||||
setSelectedModelKeys(checked ? filtered : []);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: modelTablePage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
).length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setModelTablePage(page),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -47,8 +47,9 @@ import {
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
||||
import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { IconSetting, IconSearch, IconHelpCircle, IconDescend } from '@douyinfe/semi-icons';
|
||||
import { Route } from 'lucide-react';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -192,7 +193,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 +210,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 +221,7 @@ const LogsTable = () => {
|
||||
{renderModelTag(other.upstream_model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, other.upstream_model_name).then(
|
||||
(r) => {},
|
||||
(r) => { },
|
||||
);
|
||||
},
|
||||
})}
|
||||
@@ -231,7 +232,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
|
||||
@@ -636,23 +637,23 @@ const LogsTable = () => {
|
||||
}
|
||||
let content = other?.claude
|
||||
? renderClaudeModelPriceSimple(
|
||||
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,
|
||||
)
|
||||
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?.user_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={{
|
||||
@@ -985,27 +986,27 @@ const LogsTable = () => {
|
||||
key: t('日志详情'),
|
||||
value: other?.claude
|
||||
? renderClaudeLogContent(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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) {
|
||||
@@ -1145,7 +1146,7 @@ const LogsTable = () => {
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
|
||||
loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
@@ -1203,6 +1204,8 @@ const LogsTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderColumnSelector()}
|
||||
@@ -1211,45 +1214,57 @@ const LogsTable = () => {
|
||||
title={
|
||||
<div className='flex flex-col w-full'>
|
||||
<Spin spinning={loadingStat}>
|
||||
<Space>
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<Space>
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='pink'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
RPM: {stat.rpm}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='white'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
TPM: {stat.tpm}
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='pink'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
RPM: {stat.rpm}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='white'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
TPM: {stat.tpm}
|
||||
</Tag>
|
||||
</Space>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Divider margin='12px' />
|
||||
@@ -1382,7 +1397,6 @@ const LogsTable = () => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
setLogType(0);
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
@@ -1411,7 +1425,7 @@ const LogsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
{...(hasExpandableRows() && {
|
||||
expandedRowRender: expandRowRender,
|
||||
expandRowByClick: true,
|
||||
@@ -1421,7 +1435,7 @@ const LogsTable = () => {
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
empty={
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
XCircle,
|
||||
Loader,
|
||||
AlertCircle,
|
||||
Hash
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
@@ -59,8 +59,10 @@ import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting
|
||||
IconSetting,
|
||||
IconDescend
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -107,6 +109,7 @@ const LogsTable = () => {
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const isAdminUser = isAdmin();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
|
||||
|
||||
// 加载保存的列偏好设置
|
||||
useEffect(() => {
|
||||
@@ -802,7 +805,7 @@ const LogsTable = () => {
|
||||
className="!rounded-2xl mb-4"
|
||||
title={
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
|
||||
<IconEyeOpened className="mr-2" />
|
||||
{loading ? (
|
||||
@@ -821,6 +824,15 @@ const LogsTable = () => {
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
@@ -919,11 +931,11 @@ const LogsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
empty={
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Card,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Dropdown,
|
||||
Empty
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -257,7 +256,7 @@ const ModelPricing = () => {
|
||||
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
const [groupRatio, setGroupRatio] = useState({});
|
||||
const [usableGroup, setUsableGroup] = useState({});
|
||||
|
||||
@@ -334,57 +333,6 @@ const ModelPricing = () => {
|
||||
return counts;
|
||||
}, [models, modelCategories]);
|
||||
|
||||
const renderArrow = (items, pos, handleArrowClick) => {
|
||||
const style = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: '0 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '100%',
|
||||
background: 'rgba(var(--semi-grey-1), 1)',
|
||||
color: 'var(--semi-color-text)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
return (
|
||||
<Dropdown
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{items.map(item => {
|
||||
const key = item.itemKey;
|
||||
const modelCount = categoryCounts[key] || 0;
|
||||
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={item.itemKey}
|
||||
onClick={() => setActiveKey(item.itemKey)}
|
||||
icon={modelCategories[item.itemKey]?.icon}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelCategories[item.itemKey]?.label || item.itemKey}
|
||||
<Tag
|
||||
color={activeKey === item.itemKey ? 'red' : 'grey'}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<div style={style} onClick={handleArrowClick}>
|
||||
{pos === 'start' ? '←' : '→'}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// 检查分类是否有对应的模型
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!models.length) return ['all'];
|
||||
|
||||
@@ -394,11 +342,9 @@ const ModelPricing = () => {
|
||||
}).map(([key]) => key);
|
||||
}, [models]);
|
||||
|
||||
// 渲染标签页
|
||||
const renderTabs = () => {
|
||||
return (
|
||||
<Tabs
|
||||
renderArrow={renderArrow}
|
||||
activeKey={activeKey}
|
||||
type="card"
|
||||
collapsible
|
||||
@@ -434,16 +380,13 @@ const ModelPricing = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 优化过滤逻辑
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
|
||||
// 先按分类过滤
|
||||
if (activeKey !== 'all') {
|
||||
result = result.filter(model => modelCategories[activeKey].filter(model));
|
||||
}
|
||||
|
||||
// 再按搜索词过滤
|
||||
if (filteredValue.length > 0) {
|
||||
const searchTerm = filteredValue[0].toLowerCase();
|
||||
result = result.filter(model =>
|
||||
@@ -454,7 +397,6 @@ const ModelPricing = () => {
|
||||
return result;
|
||||
}, [activeKey, models, filteredValue]);
|
||||
|
||||
// 搜索和操作区组件
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<Card className="!rounded-xl mb-6" bordered={false}>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
@@ -485,7 +427,6 @@ const ModelPricing = () => {
|
||||
</Card>
|
||||
), [selectedRowKeys, t]);
|
||||
|
||||
// 表格组件
|
||||
const ModelTable = useMemo(() => (
|
||||
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
||||
<Table
|
||||
@@ -523,10 +464,10 @@ const ModelPricing = () => {
|
||||
<div className="bg-gray-50">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center p-4 sm:p-6 md:p-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full">
|
||||
{/* 主卡片容器 */}
|
||||
<Card className="!rounded-2xl shadow-lg border-0">
|
||||
<Card bordered={false} className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部状态卡片 */}
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
|
||||
|
||||
@@ -45,10 +45,12 @@ import {
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore
|
||||
IconMore,
|
||||
IconDescend
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditRedemption from '../../pages/Redemption/EditRedemption';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -266,16 +268,14 @@ const RedemptionsTable = () => {
|
||||
id: undefined,
|
||||
});
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
};
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
@@ -296,14 +296,15 @@ const RedemptionsTable = () => {
|
||||
setRedemptions(redeptions);
|
||||
};
|
||||
|
||||
const loadRedemptions = async (startIdx, pageSize) => {
|
||||
const loadRedemptions = async (page = 1, pageSize) => {
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/redemption/?p=${startIdx}&page_size=${pageSize}`,
|
||||
`/api/redemption/?p=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setActivePage(data.page <= 0 ? 1 : data.page);
|
||||
setTokenCount(data.total);
|
||||
setRedemptionFormat(newPageData);
|
||||
} else {
|
||||
@@ -336,17 +337,8 @@ const RedemptionsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
|
||||
await loadRedemptions(activePage - 1, pageSize);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRedemptions(0, pageSize)
|
||||
loadRedemptions(1, pageSize)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
@@ -417,20 +409,6 @@ const RedemptionsTable = () => {
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const sortRedemption = (key) => {
|
||||
if (redemptions.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedRedemptions = [...redemptions];
|
||||
sortedRedemptions.sort((a, b) => {
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
});
|
||||
if (sortedRedemptions[0].id === redemptions[0].id) {
|
||||
sortedRedemptions.reverse();
|
||||
}
|
||||
setRedemptions(sortedRedemptions);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
const { searchKeyword } = getFormValues();
|
||||
@@ -465,9 +443,20 @@ const RedemptionsTable = () => {
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-orange-500">
|
||||
<Ticket size={16} className="mr-2" />
|
||||
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-orange-500">
|
||||
<Ticket size={16} className="mr-2" />
|
||||
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -610,9 +599,9 @@ const RedemptionsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
|
||||
dataSource={pageData}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
|
||||
@@ -47,8 +47,10 @@ import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting
|
||||
IconSetting,
|
||||
IconDescend
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -210,7 +212,13 @@ const LogsTable = () => {
|
||||
case 'generate':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('生成视频')}
|
||||
{t('图生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case 'textGenerate':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('文生视频')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
@@ -222,8 +230,8 @@ const LogsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderPlatform = (type) => {
|
||||
switch (type) {
|
||||
const renderPlatform = (platform) => {
|
||||
switch (platform) {
|
||||
case 'suno':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
|
||||
@@ -232,10 +240,16 @@ const LogsTable = () => {
|
||||
);
|
||||
case 'kling':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
Kling
|
||||
</Tag>
|
||||
);
|
||||
case 'jimeng':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
Jimeng
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
@@ -432,7 +446,7 @@ const LogsTable = () => {
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
|
||||
const isVideoTask = record.action === 'generate';
|
||||
const isVideoTask = record.action === 'generate' || record.action === 'textGenerate';
|
||||
const isSuccess = record.status === 'SUCCESS';
|
||||
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
||||
if (isSuccess && isVideoTask && isUrl) {
|
||||
@@ -471,6 +485,8 @@ const LogsTable = () => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
|
||||
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
@@ -650,7 +666,7 @@ const LogsTable = () => {
|
||||
className="!rounded-2xl mb-4"
|
||||
title={
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
|
||||
<IconEyeOpened className="mr-2" />
|
||||
{loading ? (
|
||||
@@ -665,6 +681,15 @@ const LogsTable = () => {
|
||||
<Text>{t('任务记录')}</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
@@ -763,11 +788,11 @@ const LogsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
empty={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
@@ -52,10 +52,12 @@ import {
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore
|
||||
IconMore,
|
||||
IconDescend
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditToken from '../../pages/Token/EditToken';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -385,6 +387,7 @@ const TokensTable = () => {
|
||||
const [editingToken, setEditingToken] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
@@ -435,6 +438,7 @@ const TokensTable = () => {
|
||||
|
||||
const refresh = async () => {
|
||||
await loadTokens(1);
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
@@ -583,24 +587,58 @@ const TokensTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const batchDeleteTokens = async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请先选择要删除的令牌!'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const ids = selectedKeys.map((token) => token.id);
|
||||
const res = await API.post('/api/token/batch', { ids });
|
||||
if (res?.data?.success) {
|
||||
const count = res.data.data || 0;
|
||||
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(res?.data?.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<Key size={16} className="mr-2" />
|
||||
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
theme="light"
|
||||
type="secondary"
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</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">
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="!rounded-full flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
setEditingToken({
|
||||
id: undefined,
|
||||
@@ -614,21 +652,77 @@ const TokensTable = () => {
|
||||
theme="light"
|
||||
type="warning"
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={async () => {
|
||||
className="!rounded-full flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
Modal.info({
|
||||
title: t('复制令牌'),
|
||||
icon: null,
|
||||
content: t('请选择你的复制方式'),
|
||||
footer: (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
icon={<IconCopy />}
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('名称+密钥')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
icon={<IconCopy />}
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('仅密钥')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制所选令牌到剪贴板')}
|
||||
{t('复制所选令牌')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t('批量删除令牌'),
|
||||
content: (
|
||||
<div>
|
||||
{t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
|
||||
</div>
|
||||
),
|
||||
onOk: () => batchDeleteTokens(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除所选令牌')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -711,9 +805,15 @@ const TokensTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={compactMode ? columns.map(col => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
}) : columns}
|
||||
dataSource={tokens}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Activity,
|
||||
Users,
|
||||
DollarSign,
|
||||
UserPlus
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -43,17 +43,20 @@ import {
|
||||
IconMore,
|
||||
IconUserAdd,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
IconArrowDown,
|
||||
IconDescend
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import AddUser from '../../pages/User/AddUser';
|
||||
import EditUser from '../../pages/User/EditUser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const UsersTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('users');
|
||||
|
||||
function renderRole(role) {
|
||||
switch (role) {
|
||||
@@ -527,9 +530,20 @@ const UsersTable = () => {
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<IconUserAdd className="mr-2" />
|
||||
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<IconUserAdd className="mr-2" />
|
||||
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
icon={<IconDescend />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -645,9 +659,9 @@ const UsersTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
|
||||
dataSource={users}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
|
||||
@@ -130,4 +130,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'green',
|
||||
label: '可灵',
|
||||
},
|
||||
{
|
||||
value: 51,
|
||||
color: 'blue',
|
||||
label: '即梦',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
|
||||
|
||||
export const DEFAULT_ENDPOINT = '/api/ratio_config';
|
||||
export const DEFAULT_ENDPOINT = '/api/ratio_config';
|
||||
|
||||
export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';
|
||||
|
||||
export const API_ENDPOINTS = [
|
||||
'/v1/chat/completions',
|
||||
'/v1/responses',
|
||||
'/v1/messages',
|
||||
'/v1beta/models',
|
||||
'/v1/embeddings',
|
||||
'/v1/rerank',
|
||||
'/v1/images/generations',
|
||||
'/v1/images/edits',
|
||||
'/v1/images/variations',
|
||||
'/v1/audio/speech',
|
||||
'/v1/audio/transcriptions',
|
||||
'/v1/audio/translations'
|
||||
];
|
||||
+70
-70
@@ -883,7 +883,7 @@ function getEffectiveRatio(groupRatio, user_group_ratio) {
|
||||
? i18next.t('专属倍率')
|
||||
: i18next.t('分组倍率');
|
||||
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
||||
|
||||
|
||||
return {
|
||||
ratio: effectiveRatio,
|
||||
label: ratioLabel,
|
||||
@@ -1074,25 +1074,25 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
].join('');
|
||||
|
||||
@@ -1281,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 (
|
||||
<>
|
||||
@@ -1340,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(
|
||||
@@ -1397,7 +1397,7 @@ export function renderQuotaWithPrompt(quota, digits) {
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
return (
|
||||
' | ' + i18next.t('等价金额') + ': ' + renderQuota(quota, digits) + ''
|
||||
i18next.t('等价金额:') + renderQuota(quota, digits)
|
||||
);
|
||||
}
|
||||
return '';
|
||||
@@ -1499,35 +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}} * {{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),
|
||||
},
|
||||
)
|
||||
'提示 {{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}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toastConstants } from '../constants';
|
||||
import React from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
|
||||
import { TABLE_COMPACT_MODES_KEY } from '../constants';
|
||||
|
||||
const HTMLToastContent = ({ htmlContent }) => {
|
||||
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
|
||||
@@ -509,3 +510,31 @@ export const formatDateTimeString = (date) => {
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
function readTableCompactModes() {
|
||||
try {
|
||||
const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY);
|
||||
return json ? JSON.parse(json) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeTableCompactModes(modes) {
|
||||
try {
|
||||
localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function getTableCompactMode(tableKey = 'global') {
|
||||
const modes = readTableCompactModes();
|
||||
return !!modes[tableKey];
|
||||
}
|
||||
|
||||
export function setTableCompactMode(compact, tableKey = 'global') {
|
||||
const modes = readTableCompactModes();
|
||||
modes[tableKey] = compact;
|
||||
writeTableCompactModes(modes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getTableCompactMode, setTableCompactMode } from '../helpers';
|
||||
import { TABLE_COMPACT_MODES_KEY } from '../constants';
|
||||
|
||||
/**
|
||||
* 自定义 Hook:管理表格紧凑/自适应模式
|
||||
* 返回 [compactMode, setCompactMode]。
|
||||
* 内部使用 localStorage 保存状态,并监听 storage 事件保持多标签页同步。
|
||||
*/
|
||||
export function useTableCompactMode(tableKey = 'global') {
|
||||
const [compactMode, setCompactModeState] = useState(() => getTableCompactMode(tableKey));
|
||||
|
||||
const setCompactMode = useCallback((value) => {
|
||||
setCompactModeState(value);
|
||||
setTableCompactMode(value, tableKey);
|
||||
}, [tableKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (e) => {
|
||||
if (e.key === TABLE_COMPACT_MODES_KEY) {
|
||||
try {
|
||||
const modes = JSON.parse(e.newValue || '{}');
|
||||
setCompactModeState(!!modes[tableKey]);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, [tableKey]);
|
||||
|
||||
return [compactMode, setCompactMode];
|
||||
}
|
||||
@@ -139,7 +139,7 @@
|
||||
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.",
|
||||
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
|
||||
"已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!",
|
||||
"搜索渠道的 ID,名称,密钥和API地址 ...": "Search channel ID, name, key and Base URL...",
|
||||
"渠道ID,名称,密钥,API地址": "Channel ID, name, key, Base URL",
|
||||
"名称": "Name",
|
||||
"分组": "Group",
|
||||
"类型": "Type",
|
||||
@@ -397,7 +397,7 @@
|
||||
"删除用户": "Delete User",
|
||||
"添加新的用户": "Add New User",
|
||||
"自定义": "Custom",
|
||||
"等价金额": "Equivalent Amount",
|
||||
"等价金额:": "Equivalent Amount: ",
|
||||
"未登录或登录已过期,请重新登录": "Not logged in or login has expired, please log in again",
|
||||
"请求次数过多,请稍后再试": "Too many requests, please try again later",
|
||||
"服务器内部错误,请联系管理员": "Server internal error, please contact the administrator",
|
||||
@@ -428,6 +428,7 @@
|
||||
"填入基础模型": "Fill in the basic model",
|
||||
"填入所有模型": "Fill in all models",
|
||||
"清除所有模型": "Clear all models",
|
||||
"复制所有模型": "Copy all models",
|
||||
"密钥": "Key",
|
||||
"请输入密钥": "Please enter the key",
|
||||
"批量创建": "Batch Create",
|
||||
@@ -456,7 +457,7 @@
|
||||
"令牌分组,默认为用户的分组": "Token group, default is the your's group",
|
||||
"IP白名单": "IP whitelist",
|
||||
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.",
|
||||
"设为无限额度": "Set to unlimited quota",
|
||||
"无限额度": "Unlimited quota",
|
||||
"更新令牌信息": "Update Token Information",
|
||||
"请输入充值码!": "Please enter the recharge code!",
|
||||
"请输入名称": "Please enter a name",
|
||||
@@ -470,10 +471,11 @@
|
||||
"请输入新的密码": "Please enter a new password",
|
||||
"显示名称": "Display Name",
|
||||
"请输入新的显示名称": "Please enter a new display name",
|
||||
"已绑定的 GitHub 账户": "GitHub Account Bound",
|
||||
"此项只读,要用户通过个人设置页面的相关绑��按钮进��绑���,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
|
||||
"已绑定的微信账户": "WeChat Account Bound",
|
||||
"已绑定的邮箱账户": "Email Account Bound",
|
||||
"已绑定的 GITHUB 账户": "Bound GitHub Account",
|
||||
"已绑定的 WECHAT 账户": "Bound WeChat Account",
|
||||
"已绑定的 EMAIL 账户": "Bound Email Account",
|
||||
"已绑定的 TELEGRAM 账户": "Bound Telegram Account",
|
||||
"此项只读,要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
|
||||
"用户信息更新成功!": "User information updated successfully!",
|
||||
"使用明细(总消耗额度:{renderQuota(stat.quota)})": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})",
|
||||
"用户名称": "User Name",
|
||||
@@ -515,7 +517,6 @@
|
||||
"注意,系统请求的时模型名称中的点会被剔除,例如:gpt-4.1会请求为gpt-41,所以在Azure部署的时候,部署模型名称需要手动改为gpt-41": "Note that the dot in the model name requested by the system will be removed, for example: gpt-4.1 will be requested as gpt-41, so when deploying on Azure, the deployment model name needs to be manually changed to gpt-41",
|
||||
"2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
|
||||
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
|
||||
"取消无限额度": "Cancel unlimited quota",
|
||||
"取消": "Cancel",
|
||||
"重置": "Reset",
|
||||
"请输入新的剩余额度": "Please enter the new remaining quota",
|
||||
@@ -800,6 +801,7 @@
|
||||
"获取无水印": "Get no watermark",
|
||||
"生成图片": "Generate pictures",
|
||||
"可灵": "Kling",
|
||||
"即梦": "Jimeng",
|
||||
"正在提交": "Submitting",
|
||||
"执行中": "processing",
|
||||
"平台": "platform",
|
||||
@@ -813,7 +815,16 @@
|
||||
"复制所选令牌": "Copy selected token",
|
||||
"请至少选择一个令牌!": "Please select at least one token!",
|
||||
"管理员未设置查询页链接": "The administrator has not set the query page link",
|
||||
"复制所选令牌到剪贴板": "Copy selected token to clipboard",
|
||||
"批量删除令牌": "Batch delete token",
|
||||
"确定要删除所选的 {{count}} 个令牌吗?": "Are you sure you want to delete the selected {{count}} tokens?",
|
||||
"删除所选令牌": "Delete selected token",
|
||||
"请先选择要删除的令牌!": "Please select the token to be deleted!",
|
||||
"已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
|
||||
"删除失败": "Delete failed",
|
||||
"复制令牌": "Copy token",
|
||||
"请选择你的复制方式": "Please select your copy method",
|
||||
"名称+密钥": "Name + key",
|
||||
"仅密钥": "Only key",
|
||||
"查看API地址": "View API address",
|
||||
"打开查询页": "Open query page",
|
||||
"时间(仅显示近3天)": "Time (only displays the last 3 days)",
|
||||
@@ -1206,7 +1217,7 @@
|
||||
"默认折叠侧边栏": "Default collapse sidebar",
|
||||
"聊天链接功能已经弃用,请使用下方聊天设置功能": "Chat link function has been deprecated, please use the chat settings below",
|
||||
"你似乎并没有修改什么": "You seem to have not modified anything",
|
||||
"令牌聊天设置": "Chat settings",
|
||||
"聊天设置": "Chat settings",
|
||||
"必须将上方聊天链接全部设置为空,才能使用下方聊天设置功能": "Must set all chat links above to empty to use the chat settings below",
|
||||
"链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "The {key} in the link will be automatically replaced with sk-xxxx, the {address} will be automatically replaced with the server address in system settings, and the end will not have / and /v1",
|
||||
"聊天配置": "Chat configuration",
|
||||
@@ -1263,7 +1274,7 @@
|
||||
" 吗?": "?",
|
||||
"修改子渠道优先级": "Modify sub-channel priority",
|
||||
"确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ",
|
||||
"分组设置": "Group settings",
|
||||
"分组倍率设置": "Group ratio settings",
|
||||
"用户可选分组": "User selectable groups",
|
||||
"保存分组倍率设置": "Save group ratio settings",
|
||||
"模型倍率设置": "Model ratio settings",
|
||||
@@ -1412,8 +1423,8 @@
|
||||
"初始化系统": "Initialize system",
|
||||
"支持众多的大模型供应商": "Supporting various LLM providers",
|
||||
"统一的大模型接口网关": "The Unified LLMs API Gateway",
|
||||
"更好的价格,更好的稳定性,无需订阅": "Better price, better stability, no subscription required",
|
||||
"开始使用": "Get Started",
|
||||
"更好的价格,更好的稳定性,只需要将模型基址替换为:": "Better price, better stability, no subscription required, just replace the model BASE URL with: ",
|
||||
"获取密钥": "Get Key",
|
||||
"关于我们": "About Us",
|
||||
"关于项目": "About Project",
|
||||
"联系我们": "Contact Us",
|
||||
@@ -1449,7 +1460,8 @@
|
||||
"访问限制": "Access Restrictions",
|
||||
"设置令牌的访问限制": "Set token access restrictions",
|
||||
"请勿过度信任此功能,IP可能被伪造": "Do not over-trust this feature, IP can be spoofed",
|
||||
"勾选启用模型限制后可选择": "Select after checking to enable model restrictions",
|
||||
"模型限制列表": "Model restrictions list",
|
||||
"请选择该令牌支持的模型,留空支持所有模型": "Select models supported by the token, leave blank to support all models",
|
||||
"非必要,不建议启用模型限制": "Not necessary, model restrictions are not recommended",
|
||||
"分组信息": "Group Information",
|
||||
"设置令牌的分组": "Set token grouping",
|
||||
@@ -1615,6 +1627,7 @@
|
||||
"编辑公告": "Edit Notice",
|
||||
"公告内容": "Notice Content",
|
||||
"请输入公告内容": "Please enter the notice content",
|
||||
"请输入公告内容(支持 Markdown/HTML)": "Please enter the notice content (supports Markdown/HTML)",
|
||||
"发布日期": "Publish Date",
|
||||
"请选择发布日期": "Please select the publish date",
|
||||
"发布时间": "Publish Time",
|
||||
@@ -1630,6 +1643,7 @@
|
||||
"请输入问题标题": "Please enter the question title",
|
||||
"回答内容": "Answer Content",
|
||||
"请输入回答内容": "Please enter the answer content",
|
||||
"请输入回答内容(支持 Markdown/HTML)": "Please enter the answer content (supports Markdown/HTML)",
|
||||
"确定要删除此问答吗?": "Are you sure you want to delete this FAQ?",
|
||||
"系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)",
|
||||
"常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
|
||||
@@ -1672,7 +1686,7 @@
|
||||
"获取倍率失败:": "Failed to get ratios: ",
|
||||
"后端请求失败": "Backend request failed",
|
||||
"部分渠道测试失败:": "Some channels failed to test: ",
|
||||
"已与上游倍率完全一致,无需同步": "The upstream ratio is completely consistent, no synchronization is required",
|
||||
"未找到差异化倍率,无需同步": "No differential ratio found, no synchronization is required",
|
||||
"请求后端接口失败:": "Failed to request the backend interface: ",
|
||||
"同步成功": "Synchronization successful",
|
||||
"部分保存失败": "Some settings failed to save",
|
||||
@@ -1688,5 +1702,53 @@
|
||||
"暂无差异化倍率显示": "No differential ratio display",
|
||||
"请先选择同步渠道": "Please select the synchronization channel first",
|
||||
"与本地相同": "Same as local",
|
||||
"未找到匹配的模型": "No matching model found"
|
||||
"未找到匹配的模型": "No matching model found",
|
||||
"暴露倍率接口": "Expose ratio API",
|
||||
"支付设置": "Payment Settings",
|
||||
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)",
|
||||
"支付地址": "Payment address",
|
||||
"易支付商户ID": "Epay merchant ID",
|
||||
"易支付商户密钥": "Epay merchant key",
|
||||
"回调地址": "Callback address",
|
||||
"充值价格(x元/美金)": "Recharge price (x yuan/dollar)",
|
||||
"最低充值美元数量": "Minimum recharge dollar amount",
|
||||
"充值分组倍率": "Recharge group ratio",
|
||||
"充值方式设置": "Recharge method settings",
|
||||
"更新支付设置": "Update payment settings",
|
||||
"通知": "Notice",
|
||||
"源地址": "Source address",
|
||||
"同步接口": "Synchronization interface",
|
||||
"置信度": "Confidence",
|
||||
"谨慎": "Cautious",
|
||||
"该数据可能不可信,请谨慎使用": "This data may not be reliable, please use with caution",
|
||||
"可信": "Reliable",
|
||||
"所有上游数据均可信": "All upstream data is reliable",
|
||||
"以下上游数据可能不可信:": "The following upstream data may not be reliable: ",
|
||||
"按倍率类型筛选": "Filter by ratio type",
|
||||
"内容": "Content",
|
||||
"放大编辑": "Expand editor",
|
||||
"编辑公告内容": "Edit announcement content",
|
||||
"自适应列表": "Adaptive list",
|
||||
"紧凑列表": "Compact list",
|
||||
"仅显示矛盾倍率": "Only show conflicting ratios",
|
||||
"矛盾": "Conflict",
|
||||
"确认冲突项修改": "Confirm conflict item modification",
|
||||
"该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection",
|
||||
"当前计费": "Current billing",
|
||||
"修改为": "Modify to",
|
||||
"状态筛选": "Status filter",
|
||||
"没有模型可以复制": "No models to copy",
|
||||
"模型列表已复制到剪贴板": "Model list copied to clipboard",
|
||||
"复制失败": "Copy failed",
|
||||
"复制已选": "Copy selected",
|
||||
"选择成功": "Selection successful",
|
||||
"暂无成功模型": "No successful models",
|
||||
"请先选择模型!": "Please select a model first!",
|
||||
"已复制 ${count} 个模型": "Copied ${count} models",
|
||||
"复制失败,请手动复制": "Copy failed, please copy manually",
|
||||
"快捷设置": "Quick settings",
|
||||
"批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name",
|
||||
"额度必须大于0": "Quota must be greater than 0",
|
||||
"生成数量必须大于0": "Generation quantity must be greater than 0",
|
||||
"创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel"
|
||||
}
|
||||
@@ -375,6 +375,7 @@ code {
|
||||
}
|
||||
|
||||
/* 隐藏卡片内容区域的滚动条 */
|
||||
.model-test-scroll,
|
||||
.card-content-scroll,
|
||||
.model-settings-scroll,
|
||||
.thinking-content-scroll,
|
||||
@@ -385,6 +386,7 @@ code {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.model-test-scroll::-webkit-scrollbar,
|
||||
.card-content-scroll::-webkit-scrollbar,
|
||||
.model-settings-scroll::-webkit-scrollbar,
|
||||
.thinking-content-scroll::-webkit-scrollbar,
|
||||
@@ -500,4 +502,94 @@ code {
|
||||
|
||||
.components-transfer-selected-item .semi-icon-close:hover {
|
||||
color: var(--semi-color-text-0);
|
||||
}
|
||||
|
||||
/* ==================== 未读通知闪光效果 ==================== */
|
||||
@keyframes sweep-shine {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shine-text {
|
||||
background: linear-gradient(90deg, currentColor 0%, currentColor 40%, rgba(255, 255, 255, 0.9) 50%, currentColor 60%, currentColor 100%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: sweep-shine 4s linear infinite;
|
||||
}
|
||||
|
||||
.dark .shine-text {
|
||||
background: linear-gradient(90deg, currentColor 0%, currentColor 40%, #facc15 50%, currentColor 60%, currentColor 100%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* ==================== ScrollList 定制样式 ==================== */
|
||||
.semi-scrolllist,
|
||||
.semi-scrolllist * {
|
||||
-ms-overflow-style: none;
|
||||
/* IE, Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.semi-scrolllist::-webkit-scrollbar,
|
||||
.semi-scrolllist *::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.semi-scrolllist-body {
|
||||
padding: 1px !important;
|
||||
}
|
||||
|
||||
.semi-scrolllist-list-outer {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* ==================== Banner 背景模糊球 ==================== */
|
||||
.blur-ball {
|
||||
position: absolute;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.blur-ball-indigo {
|
||||
background: #6366f1;
|
||||
/* indigo-500 */
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.blur-ball-teal {
|
||||
background: #14b8a6;
|
||||
/* teal-400 */
|
||||
top: 200px;
|
||||
left: 30%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 浅色主题下让模糊球更柔和 */
|
||||
html:not(.dark) .blur-ball-indigo {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
html:not(.dark) .blur-ball-teal {
|
||||
opacity: 0.2;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import '@douyinfe/semi-ui/dist/css/semi.css';
|
||||
import { UserProvider } from './context/User';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { StatusProvider } from './context/Status';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import { ThemeProvider } from './context/Theme';
|
||||
import { StyleProvider } from './context/Style/index.js';
|
||||
import PageLayout from './components/layout/PageLayout.js';
|
||||
@@ -15,7 +14,6 @@ import './index.css';
|
||||
// initialization
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
const { Sider, Content, Header, Footer } = Layout;
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StatusProvider>
|
||||
|
||||
@@ -105,7 +105,7 @@ const About = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className="flex justify-center items-center h-screen p-8">
|
||||
<Empty
|
||||
@@ -132,7 +132,7 @@ const About = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
ImagePreview,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels } from '../../helpers';
|
||||
import { getChannelModels, copy } from '../../helpers';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -64,6 +65,8 @@ function type2secretPrompt(type) {
|
||||
return '按照如下格式输入:AppId|SecretId|SecretKey';
|
||||
case 33:
|
||||
return '按照如下格式输入:Ak|Sk|Region';
|
||||
case 50:
|
||||
return '按照如下格式输入: AccessKey|SecretKey';
|
||||
default:
|
||||
return '请输入渠道对应的鉴权密钥';
|
||||
}
|
||||
@@ -109,6 +112,10 @@ const EditChannel = (props) => {
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const handleInputChange = (name, value) => {
|
||||
if (name === 'models' && Array.isArray(value)) {
|
||||
value = Array.from(new Set(value.map((m) => (m || '').trim())));
|
||||
}
|
||||
|
||||
if (name === 'base_url' && value.endsWith('/v1')) {
|
||||
Modal.confirm({
|
||||
title: '警告',
|
||||
@@ -263,10 +270,14 @@ const EditChannel = (props) => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/channel/models`);
|
||||
let localModelOptions = res.data.data.map((model) => ({
|
||||
label: model.id,
|
||||
value: model.id,
|
||||
}));
|
||||
const localModelOptions = res.data.data.map((model) => {
|
||||
const id = (model.id || '').trim();
|
||||
return {
|
||||
key: id,
|
||||
label: id,
|
||||
value: id,
|
||||
};
|
||||
});
|
||||
setOriginModelOptions(localModelOptions);
|
||||
setFullModels(res.data.data.map((model) => model.id));
|
||||
setBasicModels(
|
||||
@@ -298,27 +309,29 @@ const EditChannel = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 使用 Map 来避免重复,以 value 为键
|
||||
const modelMap = new Map();
|
||||
|
||||
// 先添加原始模型选项
|
||||
originModelOptions.forEach(option => {
|
||||
modelMap.set(option.value, option);
|
||||
});
|
||||
|
||||
// 再添加当前选中的模型(如果不存在)
|
||||
inputs.models.forEach(model => {
|
||||
if (!modelMap.has(model)) {
|
||||
modelMap.set(model, {
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(Array.from(modelMap.values()));
|
||||
}, [originModelOptions, inputs.models]);
|
||||
useEffect(() => {
|
||||
const modelMap = new Map();
|
||||
|
||||
originModelOptions.forEach(option => {
|
||||
const v = (option.value || '').trim();
|
||||
if (!modelMap.has(v)) {
|
||||
modelMap.set(v, option);
|
||||
}
|
||||
});
|
||||
|
||||
inputs.models.forEach(model => {
|
||||
const v = (model || '').trim();
|
||||
if (!modelMap.has(v)) {
|
||||
modelMap.set(v, {
|
||||
key: v,
|
||||
label: v,
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setModelOptions(Array.from(modelMap.values()));
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels().then();
|
||||
@@ -401,7 +414,7 @@ useEffect(() => {
|
||||
localModels.push(model);
|
||||
localModelOptions.push({
|
||||
key: model,
|
||||
text: model,
|
||||
label: model,
|
||||
value: model,
|
||||
});
|
||||
addedModels.push(model);
|
||||
@@ -440,10 +453,7 @@ useEffect(() => {
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
@@ -451,7 +461,6 @@ useEffect(() => {
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
icon={<IconSave />}
|
||||
@@ -460,7 +469,6 @@ useEffect(() => {
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
@@ -477,20 +485,14 @@ useEffect(() => {
|
||||
<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>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconServer 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>
|
||||
{/* Header: Basic Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconServer size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('渠道的基本配置信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -507,7 +509,6 @@ useEffect(() => {
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
placeholder={t('请选择渠道类型')}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -523,7 +524,6 @@ useEffect(() => {
|
||||
}}
|
||||
value={inputs.name}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -582,7 +582,6 @@ useEffect(() => {
|
||||
}}
|
||||
value={inputs.key}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
)}
|
||||
@@ -604,20 +603,14 @@ useEffect(() => {
|
||||
|
||||
{/* API Configuration 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>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconGlobe size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('API 配置')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('API 地址和相关配置')}</div>
|
||||
{/* Header: API Config */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconGlobe size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('API 配置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('API 地址和相关配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -657,7 +650,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('base_url', value)}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -669,7 +661,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -691,7 +682,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('base_url', value)}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -715,7 +705,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('base_url', value)}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
<Text type="tertiary" className="mt-1 text-xs">
|
||||
@@ -733,7 +722,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('base_url', value)}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -750,7 +738,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('base_url', value)}
|
||||
value={inputs.base_url}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -760,20 +747,14 @@ useEffect(() => {
|
||||
|
||||
{/* Model Configuration 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>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconCode 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>
|
||||
{/* Header: Model Config */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('模型配置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -792,7 +773,6 @@ useEffect(() => {
|
||||
value={inputs.models}
|
||||
autoComplete='new-password'
|
||||
optionList={modelOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -801,7 +781,6 @@ useEffect(() => {
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => handleInputChange('models', basicModels)}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('填入相关模型')}
|
||||
@@ -809,29 +788,54 @@ useEffect(() => {
|
||||
<Button
|
||||
type='secondary'
|
||||
onClick={() => handleInputChange('models', fullModels)}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('填入所有模型')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => fetchUpstreamModelList('models')}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
{isEdit ? (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => fetchUpstreamModelList('models')}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='warning'
|
||||
onClick={() => handleInputChange('models', [])}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('清除所有模型')}
|
||||
</Button>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (inputs.models.length === 0) {
|
||||
showInfo(t('没有模型可以复制'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
copy(inputs.models.join(','));
|
||||
showSuccess(t('模型列表已复制到剪贴板'));
|
||||
} catch (error) {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
}}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{t('复制所有模型')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('创建后可在编辑渠道时获取上游模型列表')}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Input
|
||||
addonAfter={
|
||||
@@ -842,7 +846,6 @@ useEffect(() => {
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
value={customModel}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -876,7 +879,6 @@ useEffect(() => {
|
||||
placeholder={t('不填则为模型列表第一个')}
|
||||
onChange={(value) => handleInputChange('test_model', value)}
|
||||
value={inputs.test_model}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -885,20 +887,14 @@ useEffect(() => {
|
||||
|
||||
{/* Advanced Settings 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, #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">
|
||||
<IconSetting 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>
|
||||
{/* Header: Advanced Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="orange" className="mr-2 shadow-md">
|
||||
<IconSetting size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('高级设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('渠道的高级配置选项')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -917,7 +913,6 @@ useEffect(() => {
|
||||
value={inputs.groups}
|
||||
autoComplete='new-password'
|
||||
optionList={groupOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -931,7 +926,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -973,7 +967,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -988,7 +981,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -1003,7 +995,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('other', value)}
|
||||
value={inputs.other}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -1017,7 +1008,6 @@ useEffect(() => {
|
||||
onChange={(value) => handleInputChange('tag', value)}
|
||||
value={inputs.tag}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -1037,7 +1027,6 @@ useEffect(() => {
|
||||
}}
|
||||
value={inputs.priority}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -1057,7 +1046,6 @@ useEffect(() => {
|
||||
}}
|
||||
value={inputs.weight}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -1125,7 +1113,6 @@ useEffect(() => {
|
||||
placeholder={t('请输入组织org-xxx')}
|
||||
onChange={(value) => handleInputChange('openai_organization', value)}
|
||||
value={inputs.openai_organization}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
<Text type="tertiary" className="mt-1 text-xs">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TextArea,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
@@ -277,10 +278,7 @@ const EditTagModal = (props) => {
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
@@ -289,7 +287,6 @@ const EditTagModal = (props) => {
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
@@ -299,7 +296,6 @@ const EditTagModal = (props) => {
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleClose}
|
||||
@@ -315,20 +311,14 @@ const EditTagModal = (props) => {
|
||||
<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>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconBookmark 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>
|
||||
{/* Header: Tag Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconBookmark size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('标签信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,7 +335,6 @@ const EditTagModal = (props) => {
|
||||
value={inputs.new_tag}
|
||||
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -353,20 +342,14 @@ const EditTagModal = (props) => {
|
||||
</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>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconCode 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>
|
||||
{/* Header: Model Config */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('模型配置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,7 +370,6 @@ const EditTagModal = (props) => {
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
value={inputs.models}
|
||||
optionList={modelOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -402,7 +384,6 @@ const EditTagModal = (props) => {
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
value={customModel}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -442,20 +423,14 @@ const EditTagModal = (props) => {
|
||||
</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, #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">
|
||||
<IconUser 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>
|
||||
{/* Header: Group Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('分组设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -471,7 +446,6 @@ const EditTagModal = (props) => {
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
value={inputs.groups}
|
||||
optionList={groupOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,9 @@ import ChannelsTable from '../../components/table/ChannelsTable';
|
||||
|
||||
const File = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<ChannelsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ const ChatPage = () => {
|
||||
return !isLoading && iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
style={{ width: '100%', height: 'calc(100vh - 64px)', border: 'none', marginTop: '64px' }}
|
||||
title='Token Frame'
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000] mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spin
|
||||
size="large"
|
||||
|
||||
@@ -17,7 +17,7 @@ const chat2page = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-[64px]">
|
||||
<h3>正在加载,请稍候...</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef, useState, useMemo, useCallback }
|
||||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -983,7 +984,7 @@ const Detail = (props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 h-full">
|
||||
<div className="bg-gray-50 h-full mt-[64px]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
|
||||
<div className="flex gap-3">
|
||||
@@ -1267,10 +1268,27 @@ const Detail = (props) => {
|
||||
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
|
||||
>
|
||||
{announcementData.length > 0 ? (
|
||||
<Timeline
|
||||
mode="alternate"
|
||||
dataSource={announcementData}
|
||||
/>
|
||||
<Timeline mode="alternate">
|
||||
{announcementData.map((item, idx) => (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type || 'default'}
|
||||
time={item.time}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
|
||||
/>
|
||||
{item.extra && (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
@@ -1321,7 +1339,9 @@ const Detail = (props) => {
|
||||
header={item.question}
|
||||
itemKey={index.toString()}
|
||||
>
|
||||
<p>{item.answer}</p>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
|
||||
+82
-31
@@ -1,10 +1,11 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Typography, Tag } from '@douyinfe/semi-ui';
|
||||
import { API, showError, isMobile } from '../../helpers';
|
||||
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
|
||||
import { API, showError, isMobile, copy, showSuccess } from '../../helpers';
|
||||
import { API_ENDPOINTS } from '../../constants/common.constant';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons';
|
||||
import { IconGithubLogo, IconPlay, IconFile, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NoticeModal from '../../components/layout/NoticeModal';
|
||||
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
|
||||
@@ -17,29 +18,12 @@ const Home = () => {
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
const lastCloseDate = localStorage.getItem('notice_close_date');
|
||||
const today = new Date().toDateString();
|
||||
if (lastCloseDate !== today) {
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, data } = res.data;
|
||||
if (success && data && data.trim() !== '') {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkNoticeAndShow();
|
||||
}, []);
|
||||
const serverAddress = statusState?.status?.server_address || window.location.origin;
|
||||
const endpointItems = API_ENDPOINTS.map((e) => ({ value: e }));
|
||||
const [endpointIndex, setEndpointIndex] = useState(0);
|
||||
const isChinese = i18n.language.startsWith('zh');
|
||||
|
||||
const displayHomePageContent = async () => {
|
||||
setHomePageContent(localStorage.getItem('home_page_content') || '');
|
||||
@@ -71,10 +55,44 @@ const Home = () => {
|
||||
setHomePageContentLoaded(true);
|
||||
};
|
||||
|
||||
const handleCopyBaseURL = async () => {
|
||||
const ok = await copy(serverAddress);
|
||||
if (ok) {
|
||||
showSuccess(t('已复制到剪切板'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
const lastCloseDate = localStorage.getItem('notice_close_date');
|
||||
const today = new Date().toDateString();
|
||||
if (lastCloseDate !== today) {
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, data } = res.data;
|
||||
if (success && data && data.trim() !== '') {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkNoticeAndShow();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
displayHomePageContent().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setEndpointIndex((prev) => (prev + 1) % endpointItems.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<NoticeModal
|
||||
@@ -86,30 +104,63 @@ const Home = () => {
|
||||
<div className="w-full overflow-x-hidden">
|
||||
{/* Banner 部分 */}
|
||||
<div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
|
||||
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" />
|
||||
<div className="blur-ball blur-ball-teal" />
|
||||
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10">
|
||||
{/* 居中内容区 */}
|
||||
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
|
||||
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-semibold text-semi-color-text-0 leading-tight">
|
||||
<h1 className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`}>
|
||||
{i18n.language === 'en' ? (
|
||||
<>
|
||||
The Unified<br />
|
||||
LLMs API Gateway
|
||||
<span className="shine-text">LLMs API Gateway</span>
|
||||
</>
|
||||
) : (
|
||||
t('统一的大模型接口网关')
|
||||
<>
|
||||
统一的<br />
|
||||
<span className="shine-text">大模型接口网关</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl lg:text-2xl text-semi-color-text-1 mt-4 md:mt-6">
|
||||
{t('更好的价格,更好的稳定性,无需订阅')}
|
||||
<p className="text-base md:text-lg lg:text-xl text-semi-color-text-1 mt-4 md:mt-6 max-w-xl">
|
||||
{t('更好的价格,更好的稳定性,只需要将模型基址替换为:')}
|
||||
</p>
|
||||
{/* BASE URL 与端点选择 */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md">
|
||||
<Input
|
||||
readonly
|
||||
value={serverAddress}
|
||||
className="flex-1 !rounded-full"
|
||||
size={isMobile() ? 'default' : 'large'}
|
||||
suffix={
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
|
||||
<ScrollItem
|
||||
mode="wheel"
|
||||
cycled={true}
|
||||
list={endpointItems}
|
||||
selectedIndex={endpointIndex}
|
||||
onSelect={({ index }) => setEndpointIndex(index)}
|
||||
/>
|
||||
</ScrollList>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleCopyBaseURL}
|
||||
icon={<IconCopy />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-row gap-4 justify-center items-center">
|
||||
<Link to="/console">
|
||||
<Button theme="solid" type="primary" size={isMobile() ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
|
||||
{t('开始使用')}
|
||||
{t('获取密钥')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isDemoSiteMode && statusState?.status?.version ? (
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import LogsTable from '../../components/table/LogsTable';
|
||||
|
||||
const Token = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<LogsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Token;
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import MjLogsTable from '../../components/table/MjLogsTable';
|
||||
|
||||
const Midjourney = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<MjLogsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Midjourney;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen p-8">
|
||||
<div className="flex justify-center items-center h-screen p-8 mt-[64px]">
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />}
|
||||
|
||||
@@ -363,7 +363,7 @@ const Playground = () => {
|
||||
}, [setMessage, saveMessagesImmediately]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gray-50">
|
||||
<div className="h-full bg-gray-50 mt-[64px]">
|
||||
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
|
||||
{(showSettings || !styleState.isMobile) && (
|
||||
<Layout.Sider
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import ModelPricing from '../../components/table/ModelPricing.js';
|
||||
|
||||
const Pricing = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<ModelPricing />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
SideSheet,
|
||||
Space,
|
||||
@@ -21,13 +19,14 @@ import {
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
DatePicker,
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconPlusCircle,
|
||||
IconGift,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
@@ -37,30 +36,30 @@ const EditRedemption = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = props.editingRedemption.id !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const originInputs = {
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
quota: 100000,
|
||||
count: 1,
|
||||
expired_time: 0,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const { name, quota, count, expired_time } = inputs;
|
||||
expired_time: null,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const loadRedemption = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setInputs(data);
|
||||
if (data.expired_time === 0) {
|
||||
data.expired_time = null;
|
||||
} else {
|
||||
data.expired_time = new Date(data.expired_time * 1000);
|
||||
}
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -68,28 +67,30 @@ const EditRedemption = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadRedemption().then(() => {
|
||||
// console.log(inputs);
|
||||
});
|
||||
} else {
|
||||
setInputs(originInputs);
|
||||
if (formApiRef.current) {
|
||||
if (isEdit) {
|
||||
loadRedemption();
|
||||
} else {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
}, [props.editingRedemption.id]);
|
||||
|
||||
const submit = async () => {
|
||||
let name = inputs.name;
|
||||
if (!isEdit && inputs.name === '') {
|
||||
// set default name
|
||||
name = renderQuota(quota);
|
||||
const submit = async (values) => {
|
||||
let name = values.name;
|
||||
if (!isEdit && values.name === '') {
|
||||
|
||||
name = renderQuota(values.quota);
|
||||
}
|
||||
setLoading(true);
|
||||
let localInputs = inputs;
|
||||
localInputs.count = parseInt(localInputs.count);
|
||||
localInputs.quota = parseInt(localInputs.quota);
|
||||
let localInputs = { ...values };
|
||||
localInputs.count = parseInt(localInputs.count) || 0;
|
||||
localInputs.quota = parseInt(localInputs.quota) || 0;
|
||||
localInputs.name = name;
|
||||
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
|
||||
if (!localInputs.expired_time) {
|
||||
localInputs.expired_time = 0;
|
||||
} else {
|
||||
localInputs.expired_time = Math.floor(localInputs.expired_time.getTime() / 1000);
|
||||
}
|
||||
let res;
|
||||
if (isEdit) {
|
||||
@@ -110,8 +111,8 @@ const EditRedemption = (props) => {
|
||||
props.handleClose();
|
||||
} else {
|
||||
showSuccess(t('兑换码创建成功!'));
|
||||
setInputs(originInputs);
|
||||
props.refresh();
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
props.handleClose();
|
||||
}
|
||||
} else {
|
||||
@@ -131,7 +132,7 @@ const EditRedemption = (props) => {
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
downloadTextAsFile(text, `${inputs.name}.txt`);
|
||||
downloadTextAsFile(text, `${localInputs.name}.txt`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -157,10 +158,7 @@ const EditRedemption = (props) => {
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
@@ -168,9 +166,8 @@ const EditRedemption = (props) => {
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
@@ -178,7 +175,6 @@ const EditRedemption = (props) => {
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
@@ -193,123 +189,119 @@ const EditRedemption = (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>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconGift 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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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"
|
||||
showClear
|
||||
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>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<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>
|
||||
<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>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Text strong>{t('额度')}</Text>
|
||||
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => formApiRef.current = api}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Basic Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconGift size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('设置兑换码的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutoComplete
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('生成数量')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入生成数量')}
|
||||
onChange={(value) => handleInputChange('count', value)}
|
||||
value={count}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconPlusCircle />}
|
||||
/>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
placeholder={t('请输入名称')}
|
||||
style={{ width: '100%' }}
|
||||
rules={isEdit ? [] : [{ required: true, message: t('请输入名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.DatePicker
|
||||
field='expired_time'
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('选择过期时间(可选,留空为永久)')}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
{/* Header: Quota Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('额度设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('设置兑换码的额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
style={{ width: '100%' }}
|
||||
type='number'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
extraText={renderQuotaWithPrompt(Number(values.quota) || 0)}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='count'
|
||||
label={t('生成数量')}
|
||||
min={1}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入生成数量') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('生成数量必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
|
||||
@@ -3,9 +3,9 @@ import RedemptionsTable from '../../components/table/RedemptionsTable';
|
||||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<RedemptionsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user