mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-10 17:07:27 +00:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f844772126 | ||
|
|
a8a2141626 | ||
|
|
0401f1e9ec | ||
|
|
358af20ad1 | ||
|
|
e455f06851 | ||
|
|
f191f981c4 | ||
|
|
9b659ed4f1 | ||
|
|
d39b52272e | ||
|
|
a0ae6644ee | ||
|
|
1a7da8397b | ||
|
|
dcefd7dfb4 | ||
|
|
a28ab3628a | ||
|
|
3123d4bb9b | ||
|
|
dd21183261 | ||
|
|
ef4b0bc371 | ||
|
|
3d6859b865 | ||
|
|
0389e76af5 | ||
|
|
a1163dd735 | ||
|
|
a9a284a595 | ||
|
|
95bac28232 | ||
|
|
5bf5419633 | ||
|
|
48817648c3 | ||
|
|
4baaf456a7 | ||
|
|
52356a1b92 | ||
|
|
bdb7c9cbd7 | ||
|
|
a7b17eb1ba | ||
|
|
8ed68e4b12 | ||
|
|
f124404f07 | ||
|
|
3f89ee66e1 | ||
|
|
7c0302b5f8 | ||
|
|
26b70d6a25 | ||
|
|
2509f644bc | ||
|
|
896e1d978f | ||
|
|
6c4f64c397 | ||
|
|
d1f493bf17 | ||
|
|
56188c33b5 | ||
|
|
d9461a477d | ||
|
|
07b47fbf3a | ||
|
|
66d3206d7d | ||
|
|
136a46218b | ||
|
|
3f67db1028 | ||
|
|
936e593a4f | ||
|
|
9ff33405ec | ||
|
|
f25b084d40 | ||
|
|
fe00434454 | ||
|
|
f2957ee558 | ||
|
|
b605ff9b02 | ||
|
|
b035b4d8af | ||
|
|
5d3a6caae5 | ||
|
|
7daf1f63e6 | ||
|
|
bed19d5ca4 | ||
|
|
96183e6664 | ||
|
|
d99cafbb09 | ||
|
|
4759cda8f7 | ||
|
|
ce8858716a | ||
|
|
ecb0553c6d | ||
|
|
e4217f64d3 | ||
|
|
cbb6bcc4ac | ||
|
|
845b748ffe | ||
|
|
b3209030b0 | ||
|
|
410b8afe6d | ||
|
|
cf967d39ea | ||
|
|
f2f3bad9ef | ||
|
|
5f95b4a0b7 | ||
|
|
340f86f3cc | ||
|
|
768ab854d6 | ||
|
|
452f648d75 | ||
|
|
dc0f303bb7 | ||
|
|
27bbd951f0 | ||
|
|
7d8a47123d | ||
|
|
c95fb55c51 | ||
|
|
a80bc02b96 | ||
|
|
17e1ea5f4b | ||
|
|
587f420344 | ||
|
|
9dbfd1b0af | ||
|
|
74be7b20f6 | ||
|
|
ef5832777d | ||
|
|
8184357b49 | ||
|
|
7a83060012 | ||
|
|
d05adbbb9b | ||
|
|
5f79709b4e | ||
|
|
86354e305e | ||
|
|
4eef3feef3 | ||
|
|
865377449e | ||
|
|
a4fabbe299 | ||
|
|
f67843b963 | ||
|
|
bf296d92a5 | ||
|
|
253b8cc899 | ||
|
|
1a6f332223 | ||
|
|
1b78a33aac | ||
|
|
3bd98f62f7 | ||
|
|
a6d315e14c | ||
|
|
f343d9ca2b | ||
|
|
b5708ec51c | ||
|
|
b47274bfad | ||
|
|
97a8219845 | ||
|
|
c26599ef46 | ||
|
|
a92952f070 | ||
|
|
77d5dff0c6 | ||
|
|
02e43ee12e | ||
|
|
7bced6b236 | ||
|
|
a0844d5481 | ||
|
|
d79b9e266e | ||
|
|
6acfe31ee9 | ||
|
|
2c95a7c277 | ||
|
|
7010450f77 | ||
|
|
c9849ecc46 | ||
|
|
5b641a4ead | ||
|
|
b73af9e88f | ||
|
|
ed84f937e3 | ||
|
|
6bf8a72011 | ||
|
|
d3b93196cf | ||
|
|
4989892830 | ||
|
|
b7c742166a | ||
|
|
fcc4d0074f | ||
|
|
cb83a06103 | ||
|
|
5018945c71 | ||
|
|
ce2fba7f8b | ||
|
|
2b898bc577 | ||
|
|
017fa70e1a | ||
|
|
5f52148e4e | ||
|
|
7e9bd35ac7 | ||
|
|
d124ec5b1a | ||
|
|
b778cd2b23 | ||
|
|
6e7249cf06 | ||
|
|
33014e9399 | ||
|
|
387721e907 | ||
|
|
e0cc13094f | ||
|
|
5dc3543e41 | ||
|
|
f1f07cb31b | ||
|
|
49e77fb3df | ||
|
|
eff9ce117f | ||
|
|
191f521926 | ||
|
|
50d40f04ec |
54
.github/workflows/docker-image-amd64.yml
vendored
54
.github/workflows/docker-image-amd64.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Publish Docker image (amd64)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
jobs:
|
||||
push_to_registries:
|
||||
name: Push Docker image to multiple registries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Save version info
|
||||
run: |
|
||||
git describe --tags > VERSION
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
calciumion/new-api
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
7
.github/workflows/docker-image-arm64.yml
vendored
7
.github/workflows/docker-image-arm64.yml
vendored
@@ -1,14 +1,9 @@
|
||||
name: Publish Docker image (arm64)
|
||||
name: Publish Docker image (Multi Registries)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
jobs:
|
||||
push_to_registries:
|
||||
name: Push Docker image to multiple registries
|
||||
|
||||
13
.github/workflows/linux-release.yml
vendored
13
.github/workflows/linux-release.yml
vendored
@@ -3,6 +3,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -15,16 +20,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 18
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
REACT_APP_VERSION=$(git describe --tags) npm run build
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
|
||||
13
.github/workflows/macos-release.yml
vendored
13
.github/workflows/macos-release.yml
vendored
@@ -3,6 +3,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -15,16 +20,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 18
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
REACT_APP_VERSION=$(git describe --tags) npm run build
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
|
||||
13
.github/workflows/windows-release.yml
vendored
13
.github/workflows/windows-release.yml
vendored
@@ -3,6 +3,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -18,16 +23,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 18
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
REACT_APP_VERSION=$(git describe --tags) npm run build
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
|
||||
@@ -92,12 +92,12 @@ func RedisDel(key string) error {
|
||||
return RDB.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func RedisHDelObj(key string) error {
|
||||
func RedisDelKey(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HDEL: key=%s", key))
|
||||
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.HDel(ctx, key).Err()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
|
||||
|
||||
@@ -7,6 +7,7 @@ var (
|
||||
UserSettingWebhookSecret = "webhook_secret" // WebhookSecret webhook密钥
|
||||
UserSettingNotificationEmail = "notification_email" // NotificationEmail 通知邮箱地址
|
||||
UserAcceptUnsetRatioModel = "accept_unset_model_ratio_model" // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
||||
UserSettingRecordIpLog = "record_ip_log" // 是否记录请求和错误日志IP
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -166,7 +166,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatio, priceData.CompletionRatio,
|
||||
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice)
|
||||
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.UserGroupRatio)
|
||||
model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试",
|
||||
quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other)
|
||||
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||
@@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
|
||||
} else {
|
||||
testRequest.MaxTokens = 10
|
||||
}
|
||||
content, _ := json.Marshal("hi")
|
||||
|
||||
testMessage := dto.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
Content: "hi",
|
||||
}
|
||||
testRequest.Model = model
|
||||
testRequest.Messages = append(testRequest.Messages, testMessage)
|
||||
@@ -271,6 +271,13 @@ func testAllChannels(notify bool) error {
|
||||
disableThreshold = 10000000 // a impossible value
|
||||
}
|
||||
gopool.Go(func() {
|
||||
// 使用 defer 确保无论如何都会重置运行状态,防止死锁
|
||||
defer func() {
|
||||
testAllChannelsLock.Lock()
|
||||
testAllChannelsRunning = false
|
||||
testAllChannelsLock.Unlock()
|
||||
}()
|
||||
|
||||
for _, channel := range channels {
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
@@ -305,9 +312,7 @@ func testAllChannels(notify bool) error {
|
||||
channel.UpdateResponseTime(milliseconds)
|
||||
time.Sleep(common.RequestInterval)
|
||||
}
|
||||
testAllChannelsLock.Lock()
|
||||
testAllChannelsRunning = false
|
||||
testAllChannelsLock.Unlock()
|
||||
|
||||
if notify {
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
|
||||
}
|
||||
|
||||
@@ -43,22 +43,23 @@ type OpenAIModelsResponse struct {
|
||||
func GetAllChannels(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if pageSize < 0 {
|
||||
if pageSize < 1 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
|
||||
var total int64
|
||||
|
||||
if enableTagMode {
|
||||
tags, err := model.GetPaginatedTags(p*pageSize, pageSize)
|
||||
// tag 分页:先分页 tag,再取各 tag 下 channels
|
||||
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
@@ -69,21 +70,27 @@ func GetAllChannels(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 计算 tag 总数用于分页
|
||||
total, _ = model.CountAllTags()
|
||||
} else {
|
||||
channels, err := model.GetAllChannels(p*pageSize, pageSize, false, idSort)
|
||||
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
channelData = channels
|
||||
total, _ = model.CountAllChannels()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": channelData,
|
||||
"data": gin.H{
|
||||
"items": channelData,
|
||||
"total": total,
|
||||
"page": p,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -623,3 +630,44 @@ func BatchSetChannelTag(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetTagModels(c *gin.Context) {
|
||||
tag := c.Query("tag")
|
||||
if tag == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "tag不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var longestModels string
|
||||
maxLength := 0
|
||||
|
||||
// Find the longest models string among all channels with the given tag
|
||||
for _, channel := range channels {
|
||||
if channel.Models != "" {
|
||||
currentModels := strings.Split(channel.Models, ",")
|
||||
if len(currentModels) > maxLength {
|
||||
maxLength = len(currentModels)
|
||||
longestModels = channel.Models
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": longestModels,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
@@ -215,8 +214,12 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
|
||||
|
||||
func GetAllMidjourney(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
// 解析其他查询参数
|
||||
@@ -227,31 +230,38 @@ func GetAllMidjourney(c *gin.Context) {
|
||||
EndTimestamp: c.Query("end_timestamp"),
|
||||
}
|
||||
|
||||
logs := model.GetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
|
||||
if logs == nil {
|
||||
logs = make([]*model.Midjourney, 0)
|
||||
}
|
||||
items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams)
|
||||
total := model.CountAllTasks(queryParams)
|
||||
|
||||
if setting.MjForwardUrlEnabled {
|
||||
for i, midjourney := range logs {
|
||||
for i, midjourney := range items {
|
||||
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
|
||||
logs[i] = midjourney
|
||||
items[i] = midjourney
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": logs,
|
||||
"data": gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": p,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserMidjourney(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
log.Printf("userId = %d \n", userId)
|
||||
|
||||
queryParams := model.TaskQueryParams{
|
||||
MjID: c.Query("mj_id"),
|
||||
@@ -259,19 +269,23 @@ func GetUserMidjourney(c *gin.Context) {
|
||||
EndTimestamp: c.Query("end_timestamp"),
|
||||
}
|
||||
|
||||
logs := model.GetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
|
||||
if logs == nil {
|
||||
logs = make([]*model.Midjourney, 0)
|
||||
}
|
||||
items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
|
||||
total := model.CountAllUserTask(userId, queryParams)
|
||||
|
||||
if setting.MjForwardUrlEnabled {
|
||||
for i, midjourney := range logs {
|
||||
for i, midjourney := range items {
|
||||
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
|
||||
logs[i] = midjourney
|
||||
items[i] = midjourney
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": logs,
|
||||
"data": gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": p,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/middleware"
|
||||
"one-api/model"
|
||||
"one-api/setting"
|
||||
"one-api/setting/operation_setting"
|
||||
@@ -24,14 +25,18 @@ func TestStatus(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// 获取HTTP统计信息
|
||||
httpStats := middleware.GetStats()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Server is running",
|
||||
"success": true,
|
||||
"message": "Server is running",
|
||||
"http_stats": httpStats,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetStatus(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -74,6 +79,9 @@ func GetStatus(c *gin.Context) {
|
||||
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
||||
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
||||
"setup": constant.Setup,
|
||||
"api_info": setting.GetApiInfo(),
|
||||
"announcements": setting.GetAnnouncements(),
|
||||
"faq": setting.GetFAQ(),
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
@@ -119,7 +119,33 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "ApiInfo":
|
||||
err = setting.ValidateApiInfo(option.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "Announcements":
|
||||
err = setting.ValidateConsoleSettings(option.Value, "Announcements")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "FAQ":
|
||||
err = setting.ValidateConsoleSettings(option.Value, "FAQ")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
err = model.UpdateOption(option.Key, option.Value)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"one-api/model"
|
||||
"one-api/setting"
|
||||
"one-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetPricing(c *gin.Context) {
|
||||
@@ -20,6 +21,12 @@ func GetPricing(c *gin.Context) {
|
||||
user, err := model.GetUserCache(userId.(int))
|
||||
if err == nil {
|
||||
group = user.Group
|
||||
for g := range groupRatio {
|
||||
ratio, ok := setting.GetGroupGroupRatio(group, g)
|
||||
if ok {
|
||||
groupRatio[g] = ratio
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -224,9 +224,14 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool
|
||||
|
||||
func GetAllTask(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||
// 解析其他查询参数
|
||||
@@ -237,24 +242,32 @@ func GetAllTask(c *gin.Context) {
|
||||
Action: c.Query("action"),
|
||||
StartTimestamp: startTimestamp,
|
||||
EndTimestamp: endTimestamp,
|
||||
ChannelID: c.Query("channel_id"),
|
||||
}
|
||||
|
||||
logs := model.TaskGetAllTasks(p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
|
||||
if logs == nil {
|
||||
logs = make([]*model.Task, 0)
|
||||
}
|
||||
items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams)
|
||||
total := model.TaskCountAllTasks(queryParams)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": logs,
|
||||
"data": gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": p,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserTask(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.Query("page_size"))
|
||||
if pageSize <= 0 {
|
||||
pageSize = common.ItemsPerPage
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
@@ -271,14 +284,17 @@ func GetUserTask(c *gin.Context) {
|
||||
EndTimestamp: endTimestamp,
|
||||
}
|
||||
|
||||
logs := model.TaskGetAllUserTask(userId, p*common.ItemsPerPage, common.ItemsPerPage, queryParams)
|
||||
if logs == nil {
|
||||
logs = make([]*model.Task, 0)
|
||||
}
|
||||
items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
|
||||
total := model.TaskCountAllUserTask(userId, queryParams)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": logs,
|
||||
"data": gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": p,
|
||||
"page_size": pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ func GetAllTokens(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
size, _ := strconv.Atoi(c.Query("size"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = common.ItemsPerPage
|
||||
} else if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
tokens, err := model.GetAllUserTokens(userId, p*size, size)
|
||||
tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -28,10 +28,18 @@ func GetAllTokens(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// Get total count for pagination
|
||||
total, _ := model.CountUserTokens(userId)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": tokens,
|
||||
"data": gin.H{
|
||||
"items": tokens,
|
||||
"total": total,
|
||||
"page": p,
|
||||
"page_size": size,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func RequestEpay(c *gin.Context) {
|
||||
payType = "wxpay"
|
||||
}
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, _ := url.Parse(setting.ServerAddress + "/log")
|
||||
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
|
||||
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
|
||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
|
||||
|
||||
169
controller/uptime_kuma.go
Normal file
169
controller/uptime_kuma.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type UptimeKumaMonitor struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type UptimeKumaGroup struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
MonitorList []UptimeKumaMonitor `json:"monitorList"`
|
||||
}
|
||||
|
||||
type UptimeKumaHeartbeat struct {
|
||||
Status int `json:"status"`
|
||||
Time string `json:"time"`
|
||||
Msg string `json:"msg"`
|
||||
Ping *float64 `json:"ping"`
|
||||
}
|
||||
|
||||
type UptimeKumaStatusResponse struct {
|
||||
PublicGroupList []UptimeKumaGroup `json:"publicGroupList"`
|
||||
}
|
||||
|
||||
type UptimeKumaHeartbeatResponse struct {
|
||||
HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"`
|
||||
UptimeList map[string]float64 `json:"uptimeList"`
|
||||
}
|
||||
|
||||
type MonitorStatus struct {
|
||||
Name string `json:"name"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUpstreamNon200 = errors.New("upstream non-200")
|
||||
ErrTimeout = errors.New("context deadline exceeded")
|
||||
)
|
||||
|
||||
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return ErrTimeout
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ErrUpstreamNon200
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(dest)
|
||||
}
|
||||
|
||||
func GetUptimeKumaStatus(c *gin.Context) {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"]
|
||||
slug := common.OptionMap["UptimeKumaSlug"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if uptimeKumaUrl == "" || slug == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": []MonitorStatus{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
|
||||
heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
|
||||
|
||||
var (
|
||||
statusData UptimeKumaStatusResponse
|
||||
heartbeatData UptimeKumaHeartbeatResponse
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
return getAndDecode(gCtx, client, statusPageUrl, &statusData)
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
switch err {
|
||||
case ErrUpstreamNon200:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "上游接口出现问题",
|
||||
})
|
||||
case ErrTimeout:
|
||||
c.JSON(http.StatusRequestTimeout, gin.H{
|
||||
"success": false,
|
||||
"message": "请求上游接口超时",
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var monitors []MonitorStatus
|
||||
for _, group := range statusData.PublicGroupList {
|
||||
for _, monitor := range group.MonitorList {
|
||||
monitorStatus := MonitorStatus{
|
||||
Name: monitor.Name,
|
||||
Uptime: 0.0,
|
||||
Status: 0,
|
||||
}
|
||||
|
||||
uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
|
||||
if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
|
||||
monitorStatus.Uptime = uptime
|
||||
}
|
||||
|
||||
heartbeatKey := fmt.Sprintf("%d", monitor.ID)
|
||||
if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
|
||||
latestHeartbeat := heartbeats[0]
|
||||
monitorStatus.Status = latestHeartbeat.Status
|
||||
}
|
||||
|
||||
monitors = append(monitors, monitorStatus)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": monitors,
|
||||
})
|
||||
}
|
||||
@@ -943,6 +943,7 @@ type UpdateUserSettingRequest struct {
|
||||
WebhookSecret string `json:"webhook_secret,omitempty"`
|
||||
NotificationEmail string `json:"notification_email,omitempty"`
|
||||
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
|
||||
RecordIpLog bool `json:"record_ip_log"`
|
||||
}
|
||||
|
||||
func UpdateUserSetting(c *gin.Context) {
|
||||
@@ -1019,6 +1020,7 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
constant.UserSettingNotifyType: req.QuotaWarningType,
|
||||
constant.UserSettingQuotaWarningThreshold: req.QuotaWarningThreshold,
|
||||
"accept_unset_model_ratio_model": req.AcceptUnsetModelRatioModel,
|
||||
constant.UserSettingRecordIpLog: req.RecordIpLog,
|
||||
}
|
||||
|
||||
// 如果是webhook类型,添加webhook相关设置
|
||||
|
||||
105
dto/claude.go
105
dto/claude.go
@@ -1,6 +1,9 @@
|
||||
package dto
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
type ClaudeMetadata struct {
|
||||
UserId string `json:"user_id"`
|
||||
@@ -20,11 +23,11 @@ type ClaudeMediaMessage struct {
|
||||
Delta string `json:"delta,omitempty"`
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
// tool_calls
|
||||
Id string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
ToolUseId string `json:"tool_use_id,omitempty"`
|
||||
Id string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Content any `json:"content,omitempty"`
|
||||
ToolUseId string `json:"tool_use_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ClaudeMediaMessage) SetText(s string) {
|
||||
@@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string {
|
||||
}
|
||||
|
||||
func (c *ClaudeMediaMessage) IsStringContent() bool {
|
||||
var content string
|
||||
return json.Unmarshal(c.Content, &content) == nil
|
||||
if c.Content == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := c.Content.(string)
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ClaudeMediaMessage) GetStringContent() string {
|
||||
var content string
|
||||
if err := json.Unmarshal(c.Content, &content); err == nil {
|
||||
return content
|
||||
if c.Content == nil {
|
||||
return ""
|
||||
}
|
||||
switch c.Content.(type) {
|
||||
case string:
|
||||
return c.Content.(string)
|
||||
case []any:
|
||||
var contentStr string
|
||||
for _, contentItem := range c.Content.([]any) {
|
||||
contentMap, ok := contentItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contentMap["type"] == ContentTypeText {
|
||||
if subStr, ok := contentMap["text"].(string); ok {
|
||||
contentStr += subStr
|
||||
}
|
||||
}
|
||||
}
|
||||
return contentStr
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string {
|
||||
}
|
||||
|
||||
func (c *ClaudeMediaMessage) SetContent(content any) {
|
||||
jsonContent, _ := json.Marshal(content)
|
||||
c.Content = jsonContent
|
||||
c.Content = content
|
||||
}
|
||||
|
||||
func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
|
||||
var mediaContent []ClaudeMediaMessage
|
||||
if err := json.Unmarshal(c.Content, &mediaContent); err == nil {
|
||||
return mediaContent
|
||||
}
|
||||
return make([]ClaudeMediaMessage, 0)
|
||||
mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content)
|
||||
return mediaContent
|
||||
}
|
||||
|
||||
type ClaudeMessageSource struct {
|
||||
@@ -82,14 +105,36 @@ type ClaudeMessage struct {
|
||||
}
|
||||
|
||||
func (c *ClaudeMessage) IsStringContent() bool {
|
||||
if c.Content == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := c.Content.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *ClaudeMessage) GetStringContent() string {
|
||||
if c.IsStringContent() {
|
||||
return c.Content.(string)
|
||||
if c.Content == nil {
|
||||
return ""
|
||||
}
|
||||
switch c.Content.(type) {
|
||||
case string:
|
||||
return c.Content.(string)
|
||||
case []any:
|
||||
var contentStr string
|
||||
for _, contentItem := range c.Content.([]any) {
|
||||
contentMap, ok := contentItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contentMap["type"] == ContentTypeText {
|
||||
if subStr, ok := contentMap["text"].(string); ok {
|
||||
contentStr += subStr
|
||||
}
|
||||
}
|
||||
}
|
||||
return contentStr
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) {
|
||||
}
|
||||
|
||||
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
|
||||
// map content to []ClaudeMediaMessage
|
||||
// parse to json
|
||||
jsonContent, _ := json.Marshal(c.Content)
|
||||
var contentList []ClaudeMediaMessage
|
||||
err := json.Unmarshal(jsonContent, &contentList)
|
||||
if err != nil {
|
||||
return make([]ClaudeMediaMessage, 0), err
|
||||
}
|
||||
return contentList, nil
|
||||
return common.Any2Type[[]ClaudeMediaMessage](c.Content)
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
@@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) {
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
|
||||
// map content to []ClaudeMediaMessage
|
||||
// parse to json
|
||||
jsonContent, _ := json.Marshal(c.System)
|
||||
var contentList []ClaudeMediaMessage
|
||||
if err := json.Unmarshal(jsonContent, &contentList); err == nil {
|
||||
return contentList
|
||||
}
|
||||
return make([]ClaudeMediaMessage, 0)
|
||||
mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System)
|
||||
return mediaContent
|
||||
}
|
||||
|
||||
type ClaudeError struct {
|
||||
|
||||
@@ -19,43 +19,43 @@ type FormatJsonSchema struct {
|
||||
}
|
||||
|
||||
type GeneralOpenAIRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
Prompt any `json:"prompt,omitempty"`
|
||||
Prefix any `json:"prefix,omitempty"`
|
||||
Suffix any `json:"suffix,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
||||
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
Stop any `json:"stop,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Functions any `json:"functions,omitempty"`
|
||||
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
|
||||
PresencePenalty float64 `json:"presence_penalty,omitempty"`
|
||||
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||
EncodingFormat any `json:"encoding_format,omitempty"`
|
||||
Seed float64 `json:"seed,omitempty"`
|
||||
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
LogProbs bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs int `json:"top_logprobs,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
Modalities any `json:"modalities,omitempty"`
|
||||
Audio any `json:"audio,omitempty"`
|
||||
EnableThinking any `json:"enable_thinking,omitempty"` // ali
|
||||
ExtraBody any `json:"extra_body,omitempty"`
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// OpenRouter Params
|
||||
Model string `json:"model,omitempty"`
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
Prompt any `json:"prompt,omitempty"`
|
||||
Prefix any `json:"prefix,omitempty"`
|
||||
Suffix any `json:"suffix,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
||||
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
Stop any `json:"stop,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Functions json.RawMessage `json:"functions,omitempty"`
|
||||
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
|
||||
PresencePenalty float64 `json:"presence_penalty,omitempty"`
|
||||
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||
EncodingFormat json.RawMessage `json:"encoding_format,omitempty"`
|
||||
Seed float64 `json:"seed,omitempty"`
|
||||
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
LogProbs bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs int `json:"top_logprobs,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
Modalities json.RawMessage `json:"modalities,omitempty"`
|
||||
Audio json.RawMessage `json:"audio,omitempty"`
|
||||
EnableThinking any `json:"enable_thinking,omitempty"` // ali
|
||||
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// OpenRouter Params
|
||||
Reasoning json.RawMessage `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
@@ -107,16 +107,16 @@ func (r *GeneralOpenAIRequest) ParseInput() []string {
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Prefix *bool `json:"prefix,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||
ToolCallId string `json:"tool_call_id,omitempty"`
|
||||
parsedContent []MediaContent
|
||||
parsedStringContent *string
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Prefix *bool `json:"prefix,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||
ToolCallId string `json:"tool_call_id,omitempty"`
|
||||
parsedContent []MediaContent
|
||||
//parsedStringContent *string
|
||||
}
|
||||
|
||||
type MediaContent struct {
|
||||
@@ -132,21 +132,50 @@ type MediaContent struct {
|
||||
|
||||
func (m *MediaContent) GetImageMedia() *MessageImageUrl {
|
||||
if m.ImageUrl != nil {
|
||||
return m.ImageUrl.(*MessageImageUrl)
|
||||
if _, ok := m.ImageUrl.(*MessageImageUrl); ok {
|
||||
return m.ImageUrl.(*MessageImageUrl)
|
||||
}
|
||||
if itemMap, ok := m.ImageUrl.(map[string]any); ok {
|
||||
out := &MessageImageUrl{
|
||||
Url: common.Interface2String(itemMap["url"]),
|
||||
Detail: common.Interface2String(itemMap["detail"]),
|
||||
MimeType: common.Interface2String(itemMap["mime_type"]),
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaContent) GetInputAudio() *MessageInputAudio {
|
||||
if m.InputAudio != nil {
|
||||
return m.InputAudio.(*MessageInputAudio)
|
||||
if _, ok := m.InputAudio.(*MessageInputAudio); ok {
|
||||
return m.InputAudio.(*MessageInputAudio)
|
||||
}
|
||||
if itemMap, ok := m.InputAudio.(map[string]any); ok {
|
||||
out := &MessageInputAudio{
|
||||
Data: common.Interface2String(itemMap["data"]),
|
||||
Format: common.Interface2String(itemMap["format"]),
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaContent) GetFile() *MessageFile {
|
||||
if m.File != nil {
|
||||
return m.File.(*MessageFile)
|
||||
if _, ok := m.File.(*MessageFile); ok {
|
||||
return m.File.(*MessageFile)
|
||||
}
|
||||
if itemMap, ok := m.File.(map[string]any); ok {
|
||||
out := &MessageFile{
|
||||
FileName: common.Interface2String(itemMap["file_name"]),
|
||||
FileData: common.Interface2String(itemMap["file_data"]),
|
||||
FileId: common.Interface2String(itemMap["file_id"]),
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -212,6 +241,186 @@ func (m *Message) SetToolCalls(toolCalls any) {
|
||||
}
|
||||
|
||||
func (m *Message) StringContent() string {
|
||||
switch m.Content.(type) {
|
||||
case string:
|
||||
return m.Content.(string)
|
||||
case []any:
|
||||
var contentStr string
|
||||
for _, contentItem := range m.Content.([]any) {
|
||||
contentMap, ok := contentItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contentMap["type"] == ContentTypeText {
|
||||
if subStr, ok := contentMap["text"].(string); ok {
|
||||
contentStr += subStr
|
||||
}
|
||||
}
|
||||
}
|
||||
return contentStr
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Message) SetNullContent() {
|
||||
m.Content = nil
|
||||
m.parsedContent = nil
|
||||
}
|
||||
|
||||
func (m *Message) SetStringContent(content string) {
|
||||
m.Content = content
|
||||
m.parsedContent = nil
|
||||
}
|
||||
|
||||
func (m *Message) SetMediaContent(content []MediaContent) {
|
||||
m.Content = content
|
||||
m.parsedContent = content
|
||||
}
|
||||
|
||||
func (m *Message) IsStringContent() bool {
|
||||
_, ok := m.Content.(string)
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Message) ParseContent() []MediaContent {
|
||||
if m.Content == nil {
|
||||
return nil
|
||||
}
|
||||
if len(m.parsedContent) > 0 {
|
||||
return m.parsedContent
|
||||
}
|
||||
|
||||
var contentList []MediaContent
|
||||
// 先尝试解析为字符串
|
||||
content, ok := m.Content.(string)
|
||||
if ok {
|
||||
contentList = []MediaContent{{
|
||||
Type: ContentTypeText,
|
||||
Text: content,
|
||||
}}
|
||||
m.parsedContent = contentList
|
||||
return contentList
|
||||
}
|
||||
|
||||
// 尝试解析为数组
|
||||
//var arrayContent []map[string]interface{}
|
||||
|
||||
arrayContent, ok := m.Content.([]any)
|
||||
if !ok {
|
||||
return contentList
|
||||
}
|
||||
|
||||
for _, contentItemAny := range arrayContent {
|
||||
mediaItem, ok := contentItemAny.(MediaContent)
|
||||
if ok {
|
||||
contentList = append(contentList, mediaItem)
|
||||
continue
|
||||
}
|
||||
|
||||
contentItem, ok := contentItemAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
contentType, ok := contentItem["type"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case ContentTypeText:
|
||||
if text, ok := contentItem["text"].(string); ok {
|
||||
contentList = append(contentList, MediaContent{
|
||||
Type: ContentTypeText,
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
case ContentTypeImageURL:
|
||||
imageUrl := contentItem["image_url"]
|
||||
temp := &MessageImageUrl{
|
||||
Detail: "high",
|
||||
}
|
||||
switch v := imageUrl.(type) {
|
||||
case string:
|
||||
temp.Url = v
|
||||
case map[string]interface{}:
|
||||
url, ok1 := v["url"].(string)
|
||||
detail, ok2 := v["detail"].(string)
|
||||
if ok2 {
|
||||
temp.Detail = detail
|
||||
}
|
||||
if ok1 {
|
||||
temp.Url = url
|
||||
}
|
||||
}
|
||||
contentList = append(contentList, MediaContent{
|
||||
Type: ContentTypeImageURL,
|
||||
ImageUrl: temp,
|
||||
})
|
||||
|
||||
case ContentTypeInputAudio:
|
||||
if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok {
|
||||
data, ok1 := audioData["data"].(string)
|
||||
format, ok2 := audioData["format"].(string)
|
||||
if ok1 && ok2 {
|
||||
temp := &MessageInputAudio{
|
||||
Data: data,
|
||||
Format: format,
|
||||
}
|
||||
contentList = append(contentList, MediaContent{
|
||||
Type: ContentTypeInputAudio,
|
||||
InputAudio: temp,
|
||||
})
|
||||
}
|
||||
}
|
||||
case ContentTypeFile:
|
||||
if fileData, ok := contentItem["file"].(map[string]interface{}); ok {
|
||||
fileId, ok3 := fileData["file_id"].(string)
|
||||
if ok3 {
|
||||
contentList = append(contentList, MediaContent{
|
||||
Type: ContentTypeFile,
|
||||
File: &MessageFile{
|
||||
FileId: fileId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
fileName, ok1 := fileData["filename"].(string)
|
||||
fileDataStr, ok2 := fileData["file_data"].(string)
|
||||
if ok1 && ok2 {
|
||||
contentList = append(contentList, MediaContent{
|
||||
Type: ContentTypeFile,
|
||||
File: &MessageFile{
|
||||
FileName: fileName,
|
||||
FileData: fileDataStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
case ContentTypeVideoUrl:
|
||||
if videoUrl, ok := contentItem["video_url"].(string); ok {
|
||||
contentList = append(contentList, MediaContent{
|
||||
Type: ContentTypeVideoUrl,
|
||||
VideoUrl: &MessageVideoUrl{
|
||||
Url: videoUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(contentList) > 0 {
|
||||
m.parsedContent = contentList
|
||||
}
|
||||
return contentList
|
||||
}
|
||||
|
||||
// old code
|
||||
/*func (m *Message) StringContent() string {
|
||||
if m.parsedStringContent != nil {
|
||||
return *m.parsedStringContent
|
||||
}
|
||||
@@ -382,7 +591,7 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
m.parsedContent = contentList
|
||||
}
|
||||
return contentList
|
||||
}
|
||||
}*/
|
||||
|
||||
type WebSearchOptions struct {
|
||||
SearchContextSize string `json:"search_context_size,omitempty"`
|
||||
|
||||
6
go.mod
6
go.mod
@@ -11,7 +11,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
|
||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
|
||||
github.com/bytedance/sonic v1.11.6
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-contrib/gzip v0.0.6
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
@@ -25,10 +24,10 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pkoukk/tiktoken-go v0.1.7
|
||||
github.com/samber/lo v1.39.0
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tiktoken-go/tokenizer v0.6.2
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.35.0
|
||||
@@ -43,12 +42,13 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
|
||||
github.com/aws/smithy-go v1.20.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
@@ -167,8 +167,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -197,6 +195,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
|
||||
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
|
||||
2
makefile
2
makefile
@@ -7,7 +7,7 @@ all: build-frontend start-backend
|
||||
|
||||
build-frontend:
|
||||
@echo "Building frontend..."
|
||||
@cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) npm run build
|
||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
|
||||
start-backend:
|
||||
@echo "Starting backend dev server..."
|
||||
|
||||
41
middleware/stats.go
Normal file
41
middleware/stats.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTTPStats 存储HTTP统计信息
|
||||
type HTTPStats struct {
|
||||
activeConnections int64
|
||||
}
|
||||
|
||||
var globalStats = &HTTPStats{}
|
||||
|
||||
// StatsMiddleware 统计中间件
|
||||
func StatsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 增加活跃连接数
|
||||
atomic.AddInt64(&globalStats.activeConnections, 1)
|
||||
|
||||
// 确保在请求结束时减少连接数
|
||||
defer func() {
|
||||
atomic.AddInt64(&globalStats.activeConnections, -1)
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// StatsInfo 统计信息结构
|
||||
type StatsInfo struct {
|
||||
ActiveConnections int64 `json:"active_connections"`
|
||||
}
|
||||
|
||||
// GetStats 获取统计信息
|
||||
func GetStats() StatsInfo {
|
||||
return StatsInfo{
|
||||
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
|
||||
}
|
||||
}
|
||||
@@ -583,3 +583,17 @@ func BatchSetChannelTag(ids []int, tag *string) error {
|
||||
// 提交事务
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// CountAllChannels returns total channels in DB
|
||||
func CountAllChannels() (int64, error) {
|
||||
var total int64
|
||||
err := DB.Model(&Channel{}).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// CountAllTags returns number of non-empty distinct tags
|
||||
func CountAllTags() (int64, error) {
|
||||
var total int64
|
||||
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
22
model/log.go
22
model/log.go
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -32,6 +33,7 @@ type Log struct {
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
Other string `json:"other"`
|
||||
}
|
||||
|
||||
@@ -95,6 +97,15 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
common.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
|
||||
username := c.GetString("username")
|
||||
otherStr := common.MapToJsonStr(other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
if settingMap, err := GetUserSetting(userId, false); err == nil {
|
||||
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
|
||||
if vb, ok := v.(bool); ok && vb {
|
||||
needRecordIp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
log := &Log{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
@@ -111,6 +122,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
UseTime: useTimeSeconds,
|
||||
IsStream: isStream,
|
||||
Group: group,
|
||||
Ip: func() string { if needRecordIp { return c.ClientIP() }; return "" }(),
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
@@ -128,6 +140,15 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
|
||||
}
|
||||
username := c.GetString("username")
|
||||
otherStr := common.MapToJsonStr(other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
if settingMap, err := GetUserSetting(userId, false); err == nil {
|
||||
if v, ok := settingMap[constant.UserSettingRecordIpLog]; ok {
|
||||
if vb, ok := v.(bool); ok && vb {
|
||||
needRecordIp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
log := &Log{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
@@ -144,6 +165,7 @@ func RecordConsumeLog(c *gin.Context, userId int, channelId int, promptTokens in
|
||||
UseTime: useTimeSeconds,
|
||||
IsStream: isStream,
|
||||
Group: group,
|
||||
Ip: func() string { if needRecordIp { return c.ClientIP() }; return "" }(),
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
|
||||
@@ -166,3 +166,40 @@ func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error {
|
||||
Where("id in (?)", taskIDs).
|
||||
Updates(params).Error
|
||||
}
|
||||
|
||||
// CountAllTasks returns total midjourney tasks for admin query
|
||||
func CountAllTasks(queryParams TaskQueryParams) int64 {
|
||||
var total int64
|
||||
query := DB.Model(&Midjourney{})
|
||||
if queryParams.ChannelID != "" {
|
||||
query = query.Where("channel_id = ?", queryParams.ChannelID)
|
||||
}
|
||||
if queryParams.MjID != "" {
|
||||
query = query.Where("mj_id = ?", queryParams.MjID)
|
||||
}
|
||||
if queryParams.StartTimestamp != "" {
|
||||
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
|
||||
}
|
||||
if queryParams.EndTimestamp != "" {
|
||||
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
|
||||
}
|
||||
_ = query.Count(&total).Error
|
||||
return total
|
||||
}
|
||||
|
||||
// CountAllUserTask returns total midjourney tasks for user
|
||||
func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 {
|
||||
var total int64
|
||||
query := DB.Model(&Midjourney{}).Where("user_id = ?", userId)
|
||||
if queryParams.MjID != "" {
|
||||
query = query.Where("mj_id = ?", queryParams.MjID)
|
||||
}
|
||||
if queryParams.StartTimestamp != "" {
|
||||
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
|
||||
}
|
||||
if queryParams.EndTimestamp != "" {
|
||||
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
|
||||
}
|
||||
_ = query.Count(&total).Error
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["ModelPrice"] = operation_setting.ModelPrice2JSONString()
|
||||
common.OptionMap["CacheRatio"] = operation_setting.CacheRatio2JSONString()
|
||||
common.OptionMap["GroupRatio"] = setting.GroupRatio2JSONString()
|
||||
common.OptionMap["GroupGroupRatio"] = setting.GroupGroupRatio2JSONString()
|
||||
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
|
||||
common.OptionMap["CompletionRatio"] = operation_setting.CompletionRatio2JSONString()
|
||||
common.OptionMap["TopUpLink"] = common.TopUpLink
|
||||
@@ -122,6 +123,9 @@ func InitOptionMap() {
|
||||
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
|
||||
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
|
||||
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
|
||||
common.OptionMap["ApiInfo"] = ""
|
||||
common.OptionMap["UptimeKumaUrl"] = ""
|
||||
common.OptionMap["UptimeKumaSlug"] = ""
|
||||
|
||||
// 自动添加所有注册的模型配置
|
||||
modelConfigs := config.GlobalConfig.ExportAllConfigs()
|
||||
@@ -354,6 +358,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
err = operation_setting.UpdateModelRatioByJSONString(value)
|
||||
case "GroupRatio":
|
||||
err = setting.UpdateGroupRatioByJSONString(value)
|
||||
case "GroupGroupRatio":
|
||||
err = setting.UpdateGroupGroupRatioByJSONString(value)
|
||||
case "UserUsableGroups":
|
||||
err = setting.UpdateUserUsableGroupsByJSONString(value)
|
||||
case "CompletionRatio":
|
||||
|
||||
@@ -302,3 +302,64 @@ func SumUsedTaskQuota(queryParams SyncTaskQueryParams) (stat []TaskQuotaUsage, e
|
||||
err = query.Select("mode, sum(quota) as count").Group("mode").Find(&stat).Error
|
||||
return stat, err
|
||||
}
|
||||
|
||||
// TaskCountAllTasks returns total tasks that match the given query params (admin usage)
|
||||
func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 {
|
||||
var total int64
|
||||
query := DB.Model(&Task{})
|
||||
if queryParams.ChannelID != "" {
|
||||
query = query.Where("channel_id = ?", queryParams.ChannelID)
|
||||
}
|
||||
if queryParams.Platform != "" {
|
||||
query = query.Where("platform = ?", queryParams.Platform)
|
||||
}
|
||||
if queryParams.UserID != "" {
|
||||
query = query.Where("user_id = ?", queryParams.UserID)
|
||||
}
|
||||
if len(queryParams.UserIDs) != 0 {
|
||||
query = query.Where("user_id in (?)", queryParams.UserIDs)
|
||||
}
|
||||
if queryParams.TaskID != "" {
|
||||
query = query.Where("task_id = ?", queryParams.TaskID)
|
||||
}
|
||||
if queryParams.Action != "" {
|
||||
query = query.Where("action = ?", queryParams.Action)
|
||||
}
|
||||
if queryParams.Status != "" {
|
||||
query = query.Where("status = ?", queryParams.Status)
|
||||
}
|
||||
if queryParams.StartTimestamp != 0 {
|
||||
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
|
||||
}
|
||||
if queryParams.EndTimestamp != 0 {
|
||||
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
|
||||
}
|
||||
_ = query.Count(&total).Error
|
||||
return total
|
||||
}
|
||||
|
||||
// TaskCountAllUserTask returns total tasks for given user
|
||||
func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 {
|
||||
var total int64
|
||||
query := DB.Model(&Task{}).Where("user_id = ?", userId)
|
||||
if queryParams.TaskID != "" {
|
||||
query = query.Where("task_id = ?", queryParams.TaskID)
|
||||
}
|
||||
if queryParams.Action != "" {
|
||||
query = query.Where("action = ?", queryParams.Action)
|
||||
}
|
||||
if queryParams.Status != "" {
|
||||
query = query.Where("status = ?", queryParams.Status)
|
||||
}
|
||||
if queryParams.Platform != "" {
|
||||
query = query.Where("platform = ?", queryParams.Platform)
|
||||
}
|
||||
if queryParams.StartTimestamp != 0 {
|
||||
query = query.Where("submit_time >= ?", queryParams.StartTimestamp)
|
||||
}
|
||||
if queryParams.EndTimestamp != 0 {
|
||||
query = query.Where("submit_time <= ?", queryParams.EndTimestamp)
|
||||
}
|
||||
_ = query.Count(&total).Error
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -320,3 +320,10 @@ func decreaseTokenQuota(id int, quota int) (err error) {
|
||||
).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// CountUserTokens returns total number of tokens for the given user, used for pagination
|
||||
func CountUserTokens(userId int) (int64, error) {
|
||||
var total int64
|
||||
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func cacheSetToken(token Token) error {
|
||||
|
||||
func cacheDeleteToken(key string) error {
|
||||
key = common.GenerateHMAC(key)
|
||||
err := common.RedisHDelObj(fmt.Sprintf("token:%s", key))
|
||||
err := common.RedisDelKey(fmt.Sprintf("token:%s", key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package model
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
@@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error {
|
||||
if !common.RedisEnabled {
|
||||
return nil
|
||||
}
|
||||
return common.RedisHDelObj(getUserCacheKey(userId))
|
||||
return common.RedisDelKey(getUserCacheKey(userId))
|
||||
}
|
||||
|
||||
// updateUserCache updates all user cache fields using hash
|
||||
|
||||
@@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeEmbeddings:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl)
|
||||
case constant.RelayModeRerank:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl)
|
||||
case constant.RelayModeImagesGenerations:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
|
||||
case constant.RelayModeCompletions:
|
||||
@@ -76,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
return ConvertRerankRequest(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
@@ -103,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
err, usage = aliImageHandler(c, resp, info)
|
||||
case constant.RelayModeEmbeddings:
|
||||
err, usage = aliEmbeddingHandler(c, resp)
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
default:
|
||||
if info.IsStream {
|
||||
err, usage = openai.OaiStreamHandler(c, resp, info)
|
||||
|
||||
@@ -8,6 +8,7 @@ var ModelList = []string{
|
||||
"qwq-32b",
|
||||
"qwen3-235b-a22b",
|
||||
"text-embedding-v1",
|
||||
"gte-rerank-v2",
|
||||
}
|
||||
|
||||
var ChannelName = "ali"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ali
|
||||
|
||||
import "one-api/dto"
|
||||
|
||||
type AliMessage struct {
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
@@ -97,3 +99,28 @@ type AliImageRequest struct {
|
||||
} `json:"parameters,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankParameters struct {
|
||||
TopN *int `json:"top_n,omitempty"`
|
||||
ReturnDocuments *bool `json:"return_documents,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankInput struct {
|
||||
Query string `json:"query"`
|
||||
Documents []any `json:"documents"`
|
||||
}
|
||||
|
||||
type AliRerankRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input AliRerankInput `json:"input"`
|
||||
Parameters AliRerankParameters `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankResponse struct {
|
||||
Output struct {
|
||||
Results []dto.RerankResponseResult `json:"results"`
|
||||
} `json:"output"`
|
||||
Usage AliUsage `json:"usage"`
|
||||
RequestId string `json:"request_id"`
|
||||
AliError
|
||||
}
|
||||
|
||||
83
relay/channel/ali/rerank.go
Normal file
83
relay/channel/ali/rerank.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/dto"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest {
|
||||
returnDocuments := request.ReturnDocuments
|
||||
if returnDocuments == nil {
|
||||
t := true
|
||||
returnDocuments = &t
|
||||
}
|
||||
return &AliRerankRequest{
|
||||
Model: request.Model,
|
||||
Input: AliRerankInput{
|
||||
Query: request.Query,
|
||||
Documents: request.Documents,
|
||||
},
|
||||
Parameters: AliRerankParameters{
|
||||
TopN: &request.TopN,
|
||||
ReturnDocuments: returnDocuments,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
var aliResponse AliRerankResponse
|
||||
err = json.Unmarshal(responseBody, &aliResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
if aliResponse.Code != "" {
|
||||
return &dto.OpenAIErrorWithStatusCode{
|
||||
Error: dto.OpenAIError{
|
||||
Message: aliResponse.Message,
|
||||
Type: aliResponse.Code,
|
||||
Param: aliResponse.RequestId,
|
||||
Code: aliResponse.Code,
|
||||
},
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
usage := dto.Usage{
|
||||
PromptTokens: aliResponse.Usage.TotalTokens,
|
||||
CompletionTokens: 0,
|
||||
TotalTokens: aliResponse.Usage.TotalTokens,
|
||||
}
|
||||
rerankResponse := dto.RerankResponse{
|
||||
Results: aliResponse.Output.Results,
|
||||
Usage: usage,
|
||||
}
|
||||
|
||||
jsonResponse, err := json.Marshal(rerankResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = c.Writer.Write(jsonResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
return nil, &usage
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package ali
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r
|
||||
@@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
|
||||
}
|
||||
|
||||
func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
|
||||
if request.Model == "" {
|
||||
request.Model = "text-embedding-v1"
|
||||
}
|
||||
return &AliEmbeddingRequest{
|
||||
Model: request.Model,
|
||||
Input: struct {
|
||||
@@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
|
||||
}, nil
|
||||
}
|
||||
|
||||
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse)
|
||||
model := c.GetString("model")
|
||||
if model == "" {
|
||||
model = "text-embedding-v4"
|
||||
}
|
||||
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model)
|
||||
jsonResponse, err := json.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
|
||||
@@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
|
||||
return nil, &fullTextResponse.Usage
|
||||
}
|
||||
|
||||
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse {
|
||||
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
|
||||
openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
|
||||
Object: "list",
|
||||
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
|
||||
Model: "text-embedding-v1",
|
||||
Model: model,
|
||||
Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens},
|
||||
}
|
||||
|
||||
@@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe
|
||||
}
|
||||
|
||||
func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
|
||||
content, _ := json.Marshal(response.Output.Text)
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: response.Output.Text,
|
||||
},
|
||||
FinishReason: response.Output.FinishReason,
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
// 增加panic恢复处理
|
||||
if r := recover(); r != nil {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r))
|
||||
}
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped.")
|
||||
}
|
||||
@@ -119,19 +125,32 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(pingInterval)
|
||||
// 退出时清理 ticker
|
||||
defer ticker.Stop()
|
||||
// 确保在任何情况下都清理ticker
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping ticker stopped")
|
||||
}
|
||||
}()
|
||||
|
||||
var pingMutex sync.Mutex
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine started")
|
||||
}
|
||||
|
||||
// 增加超时控制,防止goroutine长时间运行
|
||||
maxPingDuration := 120 * time.Minute // 最大ping持续时间
|
||||
pingTimeout := time.NewTimer(maxPingDuration)
|
||||
defer pingTimeout.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
// 发送 ping 数据
|
||||
case <-ticker.C:
|
||||
if err := sendPingData(c, &pingMutex); err != nil {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping error, stopping goroutine:", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
// 收到退出信号
|
||||
@@ -140,6 +159,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
// request 结束
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
// 超时保护,防止goroutine无限运行
|
||||
case <-pingTimeout.C:
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine timeout, stopping")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -148,19 +173,34 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
}
|
||||
|
||||
func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
// 增加超时控制,防止锁死等待
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
err := helper.PingData(c)
|
||||
if err != nil {
|
||||
common2.LogError(c, "SSE ping error: "+err.Error())
|
||||
err := helper.PingData(c)
|
||||
if err != nil {
|
||||
common2.LogError(c, "SSE ping error: "+err.Error())
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping data sent.")
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
// 设置发送ping数据的超时时间
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(10 * time.Second):
|
||||
return errors.New("SSE ping data send timeout")
|
||||
case <-c.Request.Context().Done():
|
||||
return errors.New("request context cancelled during ping")
|
||||
}
|
||||
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping data sent.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
|
||||
@@ -175,15 +215,23 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
client = service.GetHttpClient()
|
||||
}
|
||||
|
||||
var stopPinger context.CancelFunc
|
||||
if info.IsStream {
|
||||
helper.SetEventStreamHeaders(c)
|
||||
|
||||
// 处理流式请求的 ping 保活
|
||||
generalSettings := operation_setting.GetGeneralSetting()
|
||||
if generalSettings.PingIntervalEnabled {
|
||||
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
|
||||
stopPinger := startPingKeepAlive(c, pingInterval)
|
||||
defer stopPinger()
|
||||
stopPinger = startPingKeepAlive(c, pingInterval)
|
||||
// 使用defer确保在任何情况下都能停止ping goroutine
|
||||
defer func() {
|
||||
if stopPinger != nil {
|
||||
stopPinger()
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped by defer")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest {
|
||||
}
|
||||
|
||||
func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse {
|
||||
content, _ := json.Marshal(response.Result)
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: response.Result,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla
|
||||
prompt := ""
|
||||
for _, message := range textRequest.Messages {
|
||||
if message.Role == "user" {
|
||||
prompt += fmt.Sprintf("\n\nHuman: %s", message.Content)
|
||||
prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent())
|
||||
} else if message.Role == "assistant" {
|
||||
prompt += fmt.Sprintf("\n\nAssistant: %s", message.Content)
|
||||
prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent())
|
||||
} else if message.Role == "system" {
|
||||
if prompt == "" {
|
||||
prompt = message.StringContent()
|
||||
@@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
|
||||
}
|
||||
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
|
||||
if lastMessage.IsStringContent() && message.IsStringContent() {
|
||||
content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
|
||||
fmtMessage.Content = content
|
||||
fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
|
||||
// delete last message
|
||||
formatMessages = formatMessages[:len(formatMessages)-1]
|
||||
}
|
||||
}
|
||||
if fmtMessage.Content == nil {
|
||||
content, _ := json.Marshal("...")
|
||||
fmtMessage.Content = content
|
||||
fmtMessage.SetStringContent("...")
|
||||
}
|
||||
formatMessages = append(formatMessages, fmtMessage)
|
||||
lastMessage = fmtMessage
|
||||
@@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
|
||||
thinkingContent := ""
|
||||
|
||||
if reqMode == RequestModeCompletion {
|
||||
content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " "))
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: strings.TrimPrefix(claudeResponse.Completion, " "),
|
||||
Name: nil,
|
||||
},
|
||||
FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason),
|
||||
|
||||
@@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt
|
||||
openaiResp.Model = modelName
|
||||
openaiResp.Usage = usage
|
||||
|
||||
content, _ := json.Marshal(cohereResp.Text)
|
||||
openaiResp.Choices = []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: dto.Message{Content: content, Role: "assistant"},
|
||||
Message: dto.Message{Content: cohereResp.Text, Role: "assistant"},
|
||||
FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ type CozeError struct {
|
||||
type CozeEnterMessage struct {
|
||||
Role string `json:"role"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
Content any `json:"content,omitempty"`
|
||||
MetaData json.RawMessage `json:"meta_data,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
@@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf
|
||||
Created: common.GetTimestamp(),
|
||||
Usage: difyResponse.MetaData.Usage,
|
||||
}
|
||||
content, _ := json.Marshal(difyResponse.Answer)
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: difyResponse.Answer,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package gemini
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type GeminiChatRequest struct {
|
||||
Contents []GeminiChatContent `json:"contents"`
|
||||
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
|
||||
@@ -22,6 +24,30 @@ type GeminiInlineData struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
|
||||
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
|
||||
type Alias GeminiInlineData // Use type alias to avoid recursion
|
||||
var aux struct {
|
||||
Alias
|
||||
MimeTypeSnake string `json:"mime_type"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*g = GeminiInlineData(aux.Alias) // Copy other fields if any in future
|
||||
|
||||
// Prioritize snake_case if present
|
||||
if aux.MimeTypeSnake != "" {
|
||||
g.MimeType = aux.MimeTypeSnake
|
||||
} else if aux.MimeType != "" { // Fallback to camelCase from Alias
|
||||
g.MimeType = aux.MimeType
|
||||
}
|
||||
// g.Data would be populated by aux.Alias.Data
|
||||
return nil
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
FunctionName string `json:"name"`
|
||||
Arguments any `json:"args"`
|
||||
@@ -58,6 +84,33 @@ type GeminiPart struct {
|
||||
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData
|
||||
func (p *GeminiPart) UnmarshalJSON(data []byte) error {
|
||||
// Alias to avoid recursion during unmarshalling
|
||||
type Alias GeminiPart
|
||||
var aux struct {
|
||||
Alias
|
||||
InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Assign fields from alias
|
||||
*p = GeminiPart(aux.Alias)
|
||||
|
||||
// Prioritize snake_case for InlineData if present
|
||||
if aux.InlineDataSnake != nil {
|
||||
p.InlineData = aux.InlineDataSnake
|
||||
} else if aux.InlineData != nil { // Fallback to camelCase from Alias
|
||||
p.InlineData = aux.InlineData
|
||||
}
|
||||
// Other fields like Text, FunctionCall etc. are already populated via aux.Alias
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeminiChatContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []GeminiPart `json:"parts"`
|
||||
|
||||
@@ -175,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
// common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools))
|
||||
// json_data, _ := json.Marshal(geminiRequest.Tools)
|
||||
// common.SysLog("tools_json: " + string(json_data))
|
||||
} else if textRequest.Functions != nil {
|
||||
//geminiRequest.Tools = []GeminiChatTool{
|
||||
// {
|
||||
// FunctionDeclarations: textRequest.Functions,
|
||||
// },
|
||||
//}
|
||||
}
|
||||
|
||||
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
|
||||
@@ -211,7 +205,22 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
|
||||
name = val
|
||||
}
|
||||
contentMap := common.StrToMap(message.StringContent())
|
||||
var contentMap map[string]interface{}
|
||||
contentStr := message.StringContent()
|
||||
|
||||
// 1. 尝试解析为 JSON 对象
|
||||
if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil {
|
||||
// 2. 如果失败,尝试解析为 JSON 数组
|
||||
var contentSlice []interface{}
|
||||
if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
|
||||
// 如果是数组,包装成对象
|
||||
contentMap = map[string]interface{}{"result": contentSlice}
|
||||
} else {
|
||||
// 3. 如果再次失败,作为纯文本处理
|
||||
contentMap = map[string]interface{}{"content": contentStr}
|
||||
}
|
||||
}
|
||||
|
||||
functionResp := &FunctionResponse{
|
||||
Name: name,
|
||||
Response: contentMap,
|
||||
@@ -609,14 +618,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp
|
||||
Created: common.GetTimestamp(),
|
||||
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
|
||||
}
|
||||
content, _ := json.Marshal("")
|
||||
isToolCall := false
|
||||
for _, candidate := range response.Candidates {
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: int(candidate.Index),
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: "",
|
||||
},
|
||||
FinishReason: constant.FinishReasonStop,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
package mistral
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$")
|
||||
|
||||
func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
|
||||
messages := make([]dto.Message, 0, len(request.Messages))
|
||||
idMap := make(map[string]string)
|
||||
for _, message := range request.Messages {
|
||||
// 1. tool_calls.id
|
||||
toolCalls := message.ParseToolCalls()
|
||||
if toolCalls != nil {
|
||||
for i := range toolCalls {
|
||||
if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) {
|
||||
if newId, ok := idMap[toolCalls[i].ID]; ok {
|
||||
toolCalls[i].ID = newId
|
||||
} else {
|
||||
newId, err := common.GenerateRandomCharsKey(9)
|
||||
if err == nil {
|
||||
idMap[toolCalls[i].ID] = newId
|
||||
toolCalls[i].ID = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
// 2. tool_call_id
|
||||
if message.ToolCallId != "" {
|
||||
if newId, ok := idMap[message.ToolCallId]; ok {
|
||||
message.ToolCallId = newId
|
||||
} else {
|
||||
if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) {
|
||||
newId, err := common.GenerateRandomCharsKey(9)
|
||||
if err == nil {
|
||||
idMap[message.ToolCallId] = newId
|
||||
message.ToolCallId = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaMessages := message.ParseContent()
|
||||
if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" {
|
||||
mediaMessages = []dto.MediaContent{}
|
||||
}
|
||||
for j, mediaMessage := range mediaMessages {
|
||||
if mediaMessage.Type == dto.ContentTypeImageURL {
|
||||
imageUrl := mediaMessage.GetImageMedia()
|
||||
|
||||
@@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse {
|
||||
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
|
||||
}
|
||||
for i, candidate := range response.Candidates {
|
||||
content, _ := json.Marshal(candidate.Content)
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: i,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: candidate.Content,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
}
|
||||
|
||||
@@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon
|
||||
},
|
||||
}
|
||||
if len(response.Choices) > 0 {
|
||||
content, _ := json.Marshal(response.Choices[0].Messages.Content)
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: response.Choices[0].Messages.Content,
|
||||
},
|
||||
FinishReason: response.Choices[0].FinishReason,
|
||||
}
|
||||
|
||||
@@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
content, _ := json.Marshal(response.Payload.Choices.Text[0].Content)
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Content: response.Payload.Choices.Text[0].Content,
|
||||
},
|
||||
FinishReason: constant.FinishReasonStop,
|
||||
}
|
||||
|
||||
@@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse {
|
||||
Usage: response.Data.Usage,
|
||||
}
|
||||
for i, choice := range response.Data.Choices {
|
||||
content, _ := json.Marshal(strings.Trim(choice.Content, "\""))
|
||||
openaiChoice := dto.OpenAITextResponseChoice{
|
||||
Index: i,
|
||||
Message: dto.Message{
|
||||
Role: choice.Role,
|
||||
Content: content,
|
||||
Content: strings.Trim(choice.Content, "\""),
|
||||
},
|
||||
FinishReason: "",
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type RelayInfo struct {
|
||||
TokenKey string
|
||||
UserId int
|
||||
Group string
|
||||
UserGroup string
|
||||
TokenUnlimited bool
|
||||
StartTime time.Time
|
||||
FirstResponseTime time.Time
|
||||
@@ -204,6 +205,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
|
||||
TokenKey: tokenKey,
|
||||
UserId: userId,
|
||||
Group: group,
|
||||
UserGroup: c.GetString(constant.ContextKeyUserGroup),
|
||||
TokenUnlimited: tokenUnlimited,
|
||||
StartTime: startTime,
|
||||
FirstResponseTime: startTime.Add(-time.Second),
|
||||
|
||||
@@ -2,12 +2,13 @@ package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"one-api/common"
|
||||
constant2 "one-api/constant"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/setting"
|
||||
"one-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PriceData struct {
|
||||
@@ -18,6 +19,7 @@ type PriceData struct {
|
||||
CacheCreationRatio float64
|
||||
ImageRatio float64
|
||||
GroupRatio float64
|
||||
UserGroupRatio float64
|
||||
UsePrice bool
|
||||
ShouldPreConsumedQuota int
|
||||
}
|
||||
@@ -29,6 +31,10 @@ func (p PriceData) ToSetting() string {
|
||||
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) {
|
||||
modelPrice, usePrice := operation_setting.GetModelPrice(info.OriginModelName, false)
|
||||
groupRatio := setting.GetGroupRatio(info.Group)
|
||||
userGroupRatio, ok := setting.GetGroupGroupRatio(info.UserGroup, info.Group)
|
||||
if ok {
|
||||
groupRatio = userGroupRatio
|
||||
}
|
||||
var preConsumedQuota int
|
||||
var modelRatio float64
|
||||
var completionRatio float64
|
||||
@@ -69,6 +75,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
ModelRatio: modelRatio,
|
||||
CompletionRatio: completionRatio,
|
||||
GroupRatio: groupRatio,
|
||||
UserGroupRatio: userGroupRatio,
|
||||
UsePrice: usePrice,
|
||||
CacheRatio: cacheRatio,
|
||||
ImageRatio: imageRatio,
|
||||
|
||||
@@ -3,6 +3,7 @@ package helper
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -19,8 +20,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
InitialScannerBufferSize = 1 << 20 // 1MB (1*1024*1024)
|
||||
MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024)
|
||||
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
|
||||
MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024)
|
||||
DefaultPingInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
@@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
// 确保响应体总是被关闭
|
||||
defer func() {
|
||||
if resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second
|
||||
if strings.HasPrefix(info.UpstreamModelName, "o") {
|
||||
@@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
}
|
||||
|
||||
var (
|
||||
stopChan = make(chan bool, 2)
|
||||
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
|
||||
scanner = bufio.NewScanner(resp.Body)
|
||||
ticker = time.NewTicker(streamingTimeout)
|
||||
pingTicker *time.Ticker
|
||||
writeMutex sync.Mutex // Mutex to protect concurrent writes
|
||||
wg sync.WaitGroup // 用于等待所有 goroutine 退出
|
||||
)
|
||||
|
||||
generalSettings := operation_setting.GetGeneralSetting()
|
||||
@@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
pingTicker = time.NewTicker(pingInterval)
|
||||
}
|
||||
|
||||
// 改进资源清理,确保所有 goroutine 正确退出
|
||||
defer func() {
|
||||
// 通知所有 goroutine 停止
|
||||
common.SafeSendBool(stopChan, true)
|
||||
|
||||
ticker.Stop()
|
||||
if pingTicker != nil {
|
||||
pingTicker.Stop()
|
||||
}
|
||||
|
||||
// 等待所有 goroutine 退出,最多等待5秒
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
common.LogError(c, "timeout waiting for goroutines to exit")
|
||||
}
|
||||
|
||||
close(stopChan)
|
||||
}()
|
||||
|
||||
scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
SetEventStreamHeaders(c)
|
||||
@@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
|
||||
ctx = context.WithValue(ctx, "stop_chan", stopChan)
|
||||
|
||||
// Handle ping data sending
|
||||
// Handle ping data sending with improved error handling
|
||||
if pingEnabled && pingTicker != nil {
|
||||
wg.Add(1)
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
if r := recover(); r != nil {
|
||||
common.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r))
|
||||
common.SafeSendBool(stopChan, true)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping goroutine exited")
|
||||
}
|
||||
}()
|
||||
|
||||
// 添加超时保护,防止 goroutine 无限运行
|
||||
maxPingDuration := 30 * time.Minute // 最大 ping 持续时间
|
||||
pingTimeout := time.NewTimer(maxPingDuration)
|
||||
defer pingTimeout.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-pingTicker.C:
|
||||
writeMutex.Lock() // Lock before writing
|
||||
err := PingData(c)
|
||||
writeMutex.Unlock() // Unlock after writing
|
||||
if err != nil {
|
||||
common.LogError(c, "ping data error: "+err.Error())
|
||||
common.SafeSendBool(stopChan, true)
|
||||
// 使用超时机制防止写操作阻塞
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
done <- PingData(c)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
common.LogError(c, "ping data error: "+err.Error())
|
||||
return
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping data sent")
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
common.LogError(c, "ping data send timeout")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping data sent")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
if common.DebugEnabled {
|
||||
println("ping data goroutine stopped")
|
||||
}
|
||||
return
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
// 监听客户端断开连接
|
||||
return
|
||||
case <-pingTimeout.C:
|
||||
common.LogError(c, "ping goroutine max duration reached")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Scanner goroutine with improved error handling
|
||||
wg.Add(1)
|
||||
common.RelayCtxGo(ctx, func() {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
if r := recover(); r != nil {
|
||||
common.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r))
|
||||
}
|
||||
common.SafeSendBool(stopChan, true)
|
||||
if common.DebugEnabled {
|
||||
println("scanner goroutine exited")
|
||||
}
|
||||
}()
|
||||
|
||||
for scanner.Scan() {
|
||||
// 检查是否需要停止
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
ticker.Reset(streamingTimeout)
|
||||
data := scanner.Text()
|
||||
if common.DebugEnabled {
|
||||
@@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
data = strings.TrimSuffix(data, "\r")
|
||||
if !strings.HasPrefix(data, "[DONE]") {
|
||||
info.SetFirstResponseTime()
|
||||
writeMutex.Lock() // Lock before writing
|
||||
success := dataHandler(data)
|
||||
writeMutex.Unlock() // Unlock after writing
|
||||
if !success {
|
||||
break
|
||||
|
||||
// 使用超时机制防止写操作阻塞
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
done <- dataHandler(data)
|
||||
}()
|
||||
|
||||
select {
|
||||
case success := <-done:
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
common.LogError(c, "data handler timeout")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,17 +235,18 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
common.LogError(c, "scanner error: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
common.SafeSendBool(stopChan, true)
|
||||
})
|
||||
|
||||
// 主循环等待完成或超时
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 超时处理逻辑
|
||||
common.LogError(c, "streaming timeout")
|
||||
common.SafeSendBool(stopChan, true)
|
||||
case <-stopChan:
|
||||
// 正常结束
|
||||
common.LogInfo(c, "streaming finished")
|
||||
case <-c.Request.Context().Done():
|
||||
// 客户端断开连接
|
||||
common.LogInfo(c, "client disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
modelRatio := priceData.ModelRatio
|
||||
groupRatio := priceData.GroupRatio
|
||||
modelPrice := priceData.ModelPrice
|
||||
userGroupRatio := priceData.UserGroupRatio
|
||||
|
||||
// Convert values to decimal for precise calculation
|
||||
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
|
||||
@@ -510,7 +511,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
if extraContent != "" {
|
||||
logContent += ", " + extraContent
|
||||
}
|
||||
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
|
||||
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
|
||||
if imageTokens != 0 {
|
||||
other["image"] = true
|
||||
other["image_ratio"] = imageRatio
|
||||
|
||||
@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/setup", controller.GetSetup)
|
||||
apiRouter.POST("/setup", controller.PostSetup)
|
||||
apiRouter.GET("/status", controller.GetStatus)
|
||||
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
|
||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
||||
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
|
||||
apiRouter.GET("/notice", controller.GetNotice)
|
||||
@@ -105,6 +106,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
|
||||
channelRoute.POST("/fetch_models", controller.FetchModels)
|
||||
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
|
||||
channelRoute.GET("/tag/models", controller.GetTagModels)
|
||||
}
|
||||
tokenRoute := apiRouter.Group("/token")
|
||||
tokenRoute.Use(middleware.UserAuth())
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
func SetRelayRouter(router *gin.Engine) {
|
||||
router.Use(middleware.CORS())
|
||||
router.Use(middleware.DecompressRequestMiddleware())
|
||||
router.Use(middleware.StatsMiddleware())
|
||||
// https://platform.openai.com/docs/api-reference/introduction
|
||||
modelsRouter := router.Group("/v1/models")
|
||||
modelsRouter.Use(middleware.TokenAuth())
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
||||
cacheTokens int, cacheRatio float64, modelPrice float64) map[string]interface{} {
|
||||
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
other["model_ratio"] = modelRatio
|
||||
other["group_ratio"] = groupRatio
|
||||
@@ -16,6 +16,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
||||
other["cache_tokens"] = cacheTokens
|
||||
other["cache_ratio"] = cacheRatio
|
||||
other["model_price"] = modelPrice
|
||||
other["user_group_ratio"] = userGroupRatio
|
||||
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
|
||||
if relayInfo.ReasoningEffort != "" {
|
||||
other["reasoning_effort"] = relayInfo.ReasoningEffort
|
||||
@@ -30,8 +31,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
||||
return other
|
||||
}
|
||||
|
||||
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
|
||||
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
|
||||
info["ws"] = true
|
||||
info["audio_input"] = usage.InputTokenDetails.AudioTokens
|
||||
info["audio_output"] = usage.OutputTokenDetails.AudioTokens
|
||||
@@ -42,8 +43,8 @@ func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
return info
|
||||
}
|
||||
|
||||
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice)
|
||||
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
|
||||
info["audio"] = true
|
||||
info["audio_input"] = usage.PromptTokensDetails.AudioTokens
|
||||
info["audio_output"] = usage.CompletionTokenDetails.AudioTokens
|
||||
@@ -55,8 +56,8 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
}
|
||||
|
||||
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
|
||||
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
|
||||
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
|
||||
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
|
||||
info["claude"] = true
|
||||
info["cache_creation_tokens"] = cacheCreationTokens
|
||||
info["cache_creation_ratio"] = cacheCreationRatio
|
||||
|
||||
@@ -94,6 +94,10 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
|
||||
audioInputTokens := usage.InputTokenDetails.AudioTokens
|
||||
audioOutTokens := usage.OutputTokenDetails.AudioTokens
|
||||
groupRatio := setting.GetGroupRatio(relayInfo.Group)
|
||||
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
|
||||
if ok {
|
||||
groupRatio = userGroupRatio
|
||||
}
|
||||
modelRatio, _ := operation_setting.GetModelRatio(modelName)
|
||||
|
||||
quotaInfo := QuotaInfo{
|
||||
@@ -145,6 +149,11 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
||||
audioRatio := decimal.NewFromFloat(operation_setting.GetAudioRatio(relayInfo.OriginModelName))
|
||||
audioCompletionRatio := decimal.NewFromFloat(operation_setting.GetAudioCompletionRatio(modelName))
|
||||
|
||||
actualGroupRatio := groupRatio
|
||||
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
|
||||
if ok {
|
||||
actualGroupRatio = userGroupRatio
|
||||
}
|
||||
quotaInfo := QuotaInfo{
|
||||
InputDetails: TokenDetails{
|
||||
TextTokens: textInputTokens,
|
||||
@@ -157,7 +166,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
||||
ModelName: modelName,
|
||||
UsePrice: usePrice,
|
||||
ModelRatio: modelRatio,
|
||||
GroupRatio: groupRatio,
|
||||
GroupRatio: actualGroupRatio,
|
||||
}
|
||||
|
||||
quota := calculateAudioQuota(quotaInfo)
|
||||
@@ -189,7 +198,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
||||
logContent += ", " + extraContent
|
||||
}
|
||||
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
|
||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, userGroupRatio)
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel,
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||
}
|
||||
@@ -207,7 +216,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
modelRatio := priceData.ModelRatio
|
||||
groupRatio := priceData.GroupRatio
|
||||
modelPrice := priceData.ModelPrice
|
||||
|
||||
userGroupRatio := priceData.UserGroupRatio
|
||||
cacheRatio := priceData.CacheRatio
|
||||
cacheTokens := usage.PromptTokensDetails.CachedTokens
|
||||
|
||||
@@ -256,7 +265,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
}
|
||||
|
||||
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
|
||||
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice)
|
||||
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, userGroupRatio)
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName,
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||
}
|
||||
@@ -281,6 +290,12 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
modelPrice := priceData.ModelPrice
|
||||
usePrice := priceData.UsePrice
|
||||
|
||||
actualGroupRatio := groupRatio
|
||||
userGroupRatio, ok := setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
|
||||
if ok {
|
||||
actualGroupRatio = userGroupRatio
|
||||
}
|
||||
|
||||
quotaInfo := QuotaInfo{
|
||||
InputDetails: TokenDetails{
|
||||
TextTokens: textInputTokens,
|
||||
@@ -293,7 +308,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
ModelName: relayInfo.OriginModelName,
|
||||
UsePrice: usePrice,
|
||||
ModelRatio: modelRatio,
|
||||
GroupRatio: groupRatio,
|
||||
GroupRatio: actualGroupRatio,
|
||||
}
|
||||
|
||||
quota := calculateAudioQuota(quotaInfo)
|
||||
@@ -333,7 +348,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
logContent += ", " + extraContent
|
||||
}
|
||||
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice)
|
||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, userGroupRatio)
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel,
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/tiktoken-go/tokenizer"
|
||||
"github.com/tiktoken-go/tokenizer/codec"
|
||||
"image"
|
||||
"log"
|
||||
"math"
|
||||
@@ -11,78 +13,63 @@ import (
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/setting/operation_setting"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pkoukk/tiktoken-go"
|
||||
)
|
||||
|
||||
// tokenEncoderMap won't grow after initialization
|
||||
var tokenEncoderMap = map[string]*tiktoken.Tiktoken{}
|
||||
var defaultTokenEncoder *tiktoken.Tiktoken
|
||||
var o200kTokenEncoder *tiktoken.Tiktoken
|
||||
var defaultTokenEncoder tokenizer.Codec
|
||||
|
||||
// tokenEncoderMap is used to store token encoders for different models
|
||||
var tokenEncoderMap = make(map[string]tokenizer.Codec)
|
||||
|
||||
// tokenEncoderMutex protects tokenEncoderMap for concurrent access
|
||||
var tokenEncoderMutex sync.RWMutex
|
||||
|
||||
func InitTokenEncoders() {
|
||||
common.SysLog("initializing token encoders")
|
||||
cl100TokenEncoder, err := tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE)
|
||||
if err != nil {
|
||||
common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error()))
|
||||
}
|
||||
defaultTokenEncoder = cl100TokenEncoder
|
||||
o200kTokenEncoder, err = tiktoken.GetEncoding(tiktoken.MODEL_O200K_BASE)
|
||||
if err != nil {
|
||||
common.FatalLog(fmt.Sprintf("failed to get gpt-4o token encoder: %s", err.Error()))
|
||||
}
|
||||
for model, _ := range operation_setting.GetDefaultModelRatioMap() {
|
||||
if strings.HasPrefix(model, "gpt-3.5") {
|
||||
tokenEncoderMap[model] = cl100TokenEncoder
|
||||
} else if strings.HasPrefix(model, "gpt-4") {
|
||||
if strings.HasPrefix(model, "gpt-4o") {
|
||||
tokenEncoderMap[model] = o200kTokenEncoder
|
||||
} else {
|
||||
tokenEncoderMap[model] = defaultTokenEncoder
|
||||
}
|
||||
} else if strings.HasPrefix(model, "o") {
|
||||
tokenEncoderMap[model] = o200kTokenEncoder
|
||||
} else {
|
||||
tokenEncoderMap[model] = defaultTokenEncoder
|
||||
}
|
||||
}
|
||||
defaultTokenEncoder = codec.NewCl100kBase()
|
||||
common.SysLog("token encoders initialized")
|
||||
}
|
||||
|
||||
func getModelDefaultTokenEncoder(model string) *tiktoken.Tiktoken {
|
||||
if strings.HasPrefix(model, "gpt-4o") || strings.HasPrefix(model, "chatgpt-4o") || strings.HasPrefix(model, "o1") {
|
||||
return o200kTokenEncoder
|
||||
func getTokenEncoder(model string) tokenizer.Codec {
|
||||
// First, try to get the encoder from cache with read lock
|
||||
tokenEncoderMutex.RLock()
|
||||
if encoder, exists := tokenEncoderMap[model]; exists {
|
||||
tokenEncoderMutex.RUnlock()
|
||||
return encoder
|
||||
}
|
||||
return defaultTokenEncoder
|
||||
tokenEncoderMutex.RUnlock()
|
||||
|
||||
// If not in cache, create new encoder with write lock
|
||||
tokenEncoderMutex.Lock()
|
||||
defer tokenEncoderMutex.Unlock()
|
||||
|
||||
// Double-check if another goroutine already created the encoder
|
||||
if encoder, exists := tokenEncoderMap[model]; exists {
|
||||
return encoder
|
||||
}
|
||||
|
||||
// Create new encoder
|
||||
modelCodec, err := tokenizer.ForModel(tokenizer.Model(model))
|
||||
if err != nil {
|
||||
// Cache the default encoder for this model to avoid repeated failures
|
||||
tokenEncoderMap[model] = defaultTokenEncoder
|
||||
return defaultTokenEncoder
|
||||
}
|
||||
|
||||
// Cache the new encoder
|
||||
tokenEncoderMap[model] = modelCodec
|
||||
return modelCodec
|
||||
}
|
||||
|
||||
func getTokenEncoder(model string) *tiktoken.Tiktoken {
|
||||
tokenEncoder, ok := tokenEncoderMap[model]
|
||||
if ok && tokenEncoder != nil {
|
||||
return tokenEncoder
|
||||
}
|
||||
// 如果ok(即model在tokenEncoderMap中),但是tokenEncoder为nil,说明可能是自定义模型
|
||||
if ok {
|
||||
tokenEncoder, err := tiktoken.EncodingForModel(model)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error()))
|
||||
tokenEncoder = getModelDefaultTokenEncoder(model)
|
||||
}
|
||||
tokenEncoderMap[model] = tokenEncoder
|
||||
return tokenEncoder
|
||||
}
|
||||
// 如果model不在tokenEncoderMap中,直接返回默认的tokenEncoder
|
||||
return getModelDefaultTokenEncoder(model)
|
||||
}
|
||||
|
||||
func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int {
|
||||
func getTokenNum(tokenEncoder tokenizer.Codec, text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
return len(tokenEncoder.Encode(text, nil, nil))
|
||||
tkm, _ := tokenEncoder.Count(text)
|
||||
return tkm
|
||||
}
|
||||
|
||||
func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) {
|
||||
@@ -261,12 +248,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream
|
||||
//}
|
||||
tokenNum += 1000
|
||||
case "tool_use":
|
||||
tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
|
||||
inputJSON, _ := json.Marshal(mediaMessage.Input)
|
||||
tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
|
||||
if mediaMessage.Input != nil {
|
||||
tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
|
||||
inputJSON, _ := json.Marshal(mediaMessage.Input)
|
||||
tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
|
||||
}
|
||||
case "tool_result":
|
||||
contentJSON, _ := json.Marshal(mediaMessage.Content)
|
||||
tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
|
||||
if mediaMessage.Content != nil {
|
||||
contentJSON, _ := json.Marshal(mediaMessage.Content)
|
||||
tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +377,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod
|
||||
for _, message := range messages {
|
||||
tokenNum += tokensPerMessage
|
||||
tokenNum += getTokenNum(tokenEncoder, message.Role)
|
||||
if len(message.Content) > 0 {
|
||||
if message.Content != nil {
|
||||
if message.Name != nil {
|
||||
tokenNum += tokensPerName
|
||||
tokenNum += getTokenNum(tokenEncoder, *message.Name)
|
||||
|
||||
327
setting/console.go
Normal file
327
setting/console.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"one-api/common"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidateConsoleSettings 验证控制台设置信息格式
|
||||
func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||
if settingsStr == "" {
|
||||
return nil // 空字符串是合法的
|
||||
}
|
||||
|
||||
switch settingType {
|
||||
case "ApiInfo":
|
||||
return validateApiInfo(settingsStr)
|
||||
case "Announcements":
|
||||
return validateAnnouncements(settingsStr)
|
||||
case "FAQ":
|
||||
return validateFAQ(settingsStr)
|
||||
default:
|
||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||
}
|
||||
}
|
||||
|
||||
// validateApiInfo 验证API信息格式
|
||||
func validateApiInfo(apiInfoStr string) error {
|
||||
var apiInfoList []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil {
|
||||
return fmt.Errorf("API信息格式错误:%s", err.Error())
|
||||
}
|
||||
|
||||
// 验证数组长度
|
||||
if len(apiInfoList) > 50 {
|
||||
return fmt.Errorf("API信息数量不能超过50个")
|
||||
}
|
||||
|
||||
// 允许的颜色值
|
||||
validColors := map[string]bool{
|
||||
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
|
||||
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
|
||||
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
|
||||
"violet": true, "grey": true,
|
||||
}
|
||||
|
||||
// URL正则表达式,支持域名和IP地址格式
|
||||
// 域名格式:https://example.com 或 https://sub.example.com:8080
|
||||
// IP地址格式:https://192.168.1.1 或 https://192.168.1.1:8080
|
||||
urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?::[0-9]{1,5})?(?:/.*)?$`)
|
||||
|
||||
for i, apiInfo := range apiInfoList {
|
||||
// 检查必填字段
|
||||
urlStr, ok := apiInfo["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
|
||||
}
|
||||
|
||||
route, ok := apiInfo["route"].(string)
|
||||
if !ok || route == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
|
||||
}
|
||||
|
||||
description, ok := apiInfo["description"].(string)
|
||||
if !ok || description == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
|
||||
}
|
||||
|
||||
color, ok := apiInfo["color"].(string)
|
||||
if !ok || color == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
if !urlRegex.MatchString(urlStr) {
|
||||
return fmt.Errorf("第%d个API信息的URL格式不正确", i+1)
|
||||
}
|
||||
|
||||
// 验证URL可解析性
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error())
|
||||
}
|
||||
|
||||
// 验证字段长度
|
||||
if len(urlStr) > 500 {
|
||||
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
|
||||
if len(route) > 100 {
|
||||
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
|
||||
}
|
||||
|
||||
if len(description) > 200 {
|
||||
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
|
||||
// 验证颜色值
|
||||
if !validColors[color] {
|
||||
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
||||
}
|
||||
|
||||
// 检查并过滤危险字符(防止XSS)
|
||||
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
for _, dangerous := range dangerousChars {
|
||||
if strings.Contains(strings.ToLower(description), dangerous) {
|
||||
return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(route), dangerous) {
|
||||
return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateApiInfo 保持向后兼容的函数
|
||||
func ValidateApiInfo(apiInfoStr string) error {
|
||||
return validateApiInfo(apiInfoStr)
|
||||
}
|
||||
|
||||
// GetApiInfo 获取API信息列表
|
||||
func GetApiInfo() []map[string]interface{} {
|
||||
// 从OptionMap中获取API信息,如果不存在则返回空数组
|
||||
common.OptionMapRWMutex.RLock()
|
||||
apiInfoStr, exists := common.OptionMap["ApiInfo"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if !exists || apiInfoStr == "" {
|
||||
// 如果没有配置,返回空数组
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// 解析存储的API信息
|
||||
var apiInfo []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
|
||||
// 如果解析失败,返回空数组
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
return apiInfo
|
||||
}
|
||||
|
||||
// validateAnnouncements 验证系统公告格式
|
||||
func validateAnnouncements(announcementsStr string) error {
|
||||
var announcementsList []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(announcementsStr), &announcementsList); err != nil {
|
||||
return fmt.Errorf("系统公告格式错误:%s", err.Error())
|
||||
}
|
||||
|
||||
// 验证数组长度
|
||||
if len(announcementsList) > 100 {
|
||||
return fmt.Errorf("系统公告数量不能超过100个")
|
||||
}
|
||||
|
||||
// 允许的类型值
|
||||
validTypes := map[string]bool{
|
||||
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
|
||||
}
|
||||
|
||||
for i, announcement := range announcementsList {
|
||||
// 检查必填字段
|
||||
content, ok := announcement["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
|
||||
}
|
||||
|
||||
// 检查发布日期字段
|
||||
publishDate, exists := announcement["publishDate"]
|
||||
if !exists {
|
||||
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
|
||||
}
|
||||
|
||||
publishDateStr, ok := publishDate.(string)
|
||||
if !ok || publishDateStr == "" {
|
||||
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
|
||||
}
|
||||
|
||||
// 验证ISO日期格式
|
||||
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
|
||||
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
|
||||
}
|
||||
|
||||
// 验证可选字段
|
||||
if announcementType, exists := announcement["type"]; exists {
|
||||
if typeStr, ok := announcementType.(string); ok {
|
||||
if !validTypes[typeStr] {
|
||||
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证字段长度
|
||||
if len(content) > 500 {
|
||||
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
|
||||
}
|
||||
|
||||
if extra, exists := announcement["extra"]; exists {
|
||||
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
|
||||
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并过滤危险字符(防止XSS)
|
||||
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
for _, dangerous := range dangerousChars {
|
||||
if strings.Contains(strings.ToLower(content), dangerous) {
|
||||
return fmt.Errorf("第%d个公告的内容包含不允许的内容", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFAQ 验证常见问答格式
|
||||
func validateFAQ(faqStr string) error {
|
||||
var faqList []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(faqStr), &faqList); err != nil {
|
||||
return fmt.Errorf("常见问答格式错误:%s", err.Error())
|
||||
}
|
||||
|
||||
// 验证数组长度
|
||||
if len(faqList) > 100 {
|
||||
return fmt.Errorf("常见问答数量不能超过100个")
|
||||
}
|
||||
|
||||
for i, faq := range faqList {
|
||||
// 检查必填字段
|
||||
title, ok := faq["title"].(string)
|
||||
if !ok || title == "" {
|
||||
return fmt.Errorf("第%d个问答缺少标题字段", i+1)
|
||||
}
|
||||
|
||||
content, ok := faq["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return fmt.Errorf("第%d个问答缺少内容字段", i+1)
|
||||
}
|
||||
|
||||
// 验证字段长度
|
||||
if len(title) > 200 {
|
||||
return fmt.Errorf("第%d个问答的标题长度不能超过200字符", i+1)
|
||||
}
|
||||
|
||||
if len(content) > 1000 {
|
||||
return fmt.Errorf("第%d个问答的内容长度不能超过1000字符", i+1)
|
||||
}
|
||||
|
||||
// 检查并过滤危险字符(防止XSS)
|
||||
dangerousChars := []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
for _, dangerous := range dangerousChars {
|
||||
if strings.Contains(strings.ToLower(title), dangerous) {
|
||||
return fmt.Errorf("第%d个问答的标题包含不允许的内容", i+1)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(content), dangerous) {
|
||||
return fmt.Errorf("第%d个问答的内容包含不允许的内容", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAnnouncements 获取系统公告列表(返回最新的前20条)
|
||||
func GetAnnouncements() []map[string]interface{} {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
announcementsStr, exists := common.OptionMap["Announcements"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if !exists || announcementsStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
var announcements []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(announcementsStr), &announcements); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// 按发布日期降序排序(最新的在前)
|
||||
sort.Slice(announcements, func(i, j int) bool {
|
||||
dateI, okI := announcements[i]["publishDate"].(string)
|
||||
dateJ, okJ := announcements[j]["publishDate"].(string)
|
||||
|
||||
if !okI || !okJ {
|
||||
return false
|
||||
}
|
||||
|
||||
timeI, errI := time.Parse(time.RFC3339, dateI)
|
||||
timeJ, errJ := time.Parse(time.RFC3339, dateJ)
|
||||
|
||||
if errI != nil || errJ != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return timeI.After(timeJ)
|
||||
})
|
||||
|
||||
// 限制返回前20条
|
||||
if len(announcements) > 20 {
|
||||
announcements = announcements[:20]
|
||||
}
|
||||
|
||||
return announcements
|
||||
}
|
||||
|
||||
// GetFAQ 获取常见问答列表
|
||||
func GetFAQ() []map[string]interface{} {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
faqStr, exists := common.OptionMap["FAQ"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if !exists || faqStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
var faq []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(faqStr), &faq); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
return faq
|
||||
}
|
||||
@@ -14,10 +14,19 @@ var groupRatio = map[string]float64{
|
||||
}
|
||||
var groupRatioMutex sync.RWMutex
|
||||
|
||||
var (
|
||||
GroupGroupRatio = map[string]map[string]float64{
|
||||
"vip": {
|
||||
"edit_this": 0.9,
|
||||
},
|
||||
}
|
||||
groupGroupRatioMutex sync.RWMutex
|
||||
)
|
||||
|
||||
func GetGroupRatioCopy() map[string]float64 {
|
||||
groupRatioMutex.RLock()
|
||||
defer groupRatioMutex.RUnlock()
|
||||
|
||||
|
||||
groupRatioCopy := make(map[string]float64)
|
||||
for k, v := range groupRatio {
|
||||
groupRatioCopy[k] = v
|
||||
@@ -28,7 +37,7 @@ func GetGroupRatioCopy() map[string]float64 {
|
||||
func ContainsGroupRatio(name string) bool {
|
||||
groupRatioMutex.RLock()
|
||||
defer groupRatioMutex.RUnlock()
|
||||
|
||||
|
||||
_, ok := groupRatio[name]
|
||||
return ok
|
||||
}
|
||||
@@ -36,7 +45,7 @@ func ContainsGroupRatio(name string) bool {
|
||||
func GroupRatio2JSONString() string {
|
||||
groupRatioMutex.RLock()
|
||||
defer groupRatioMutex.RUnlock()
|
||||
|
||||
|
||||
jsonBytes, err := json.Marshal(groupRatio)
|
||||
if err != nil {
|
||||
common.SysError("error marshalling model ratio: " + err.Error())
|
||||
@@ -47,7 +56,7 @@ func GroupRatio2JSONString() string {
|
||||
func UpdateGroupRatioByJSONString(jsonStr string) error {
|
||||
groupRatioMutex.Lock()
|
||||
defer groupRatioMutex.Unlock()
|
||||
|
||||
|
||||
groupRatio = make(map[string]float64)
|
||||
return json.Unmarshal([]byte(jsonStr), &groupRatio)
|
||||
}
|
||||
@@ -55,7 +64,7 @@ func UpdateGroupRatioByJSONString(jsonStr string) error {
|
||||
func GetGroupRatio(name string) float64 {
|
||||
groupRatioMutex.RLock()
|
||||
defer groupRatioMutex.RUnlock()
|
||||
|
||||
|
||||
ratio, ok := groupRatio[name]
|
||||
if !ok {
|
||||
common.SysError("group ratio not found: " + name)
|
||||
@@ -64,6 +73,40 @@ func GetGroupRatio(name string) float64 {
|
||||
return ratio
|
||||
}
|
||||
|
||||
func GetGroupGroupRatio(group, name string) (float64, bool) {
|
||||
groupGroupRatioMutex.RLock()
|
||||
defer groupGroupRatioMutex.RUnlock()
|
||||
|
||||
gp, ok := GroupGroupRatio[group]
|
||||
if !ok {
|
||||
return -1, false
|
||||
}
|
||||
ratio, ok := gp[name]
|
||||
if !ok {
|
||||
return -1, false
|
||||
}
|
||||
return ratio, true
|
||||
}
|
||||
|
||||
func GroupGroupRatio2JSONString() string {
|
||||
groupGroupRatioMutex.RLock()
|
||||
defer groupGroupRatioMutex.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(GroupGroupRatio)
|
||||
if err != nil {
|
||||
common.SysError("error marshalling group-group ratio: " + err.Error())
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func UpdateGroupGroupRatioByJSONString(jsonStr string) error {
|
||||
groupGroupRatioMutex.Lock()
|
||||
defer groupGroupRatioMutex.Unlock()
|
||||
|
||||
GroupGroupRatio = make(map[string]map[string]float64)
|
||||
return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio)
|
||||
}
|
||||
|
||||
func CheckGroupRatio(jsonStr string) error {
|
||||
checkGroupRatio := make(map[string]float64)
|
||||
err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# React Template
|
||||
|
||||
## Basic Usages
|
||||
|
||||
```shell
|
||||
# Runs the app in the development mode
|
||||
npm start
|
||||
|
||||
# Builds the app for production to the `build` folder
|
||||
npm run build
|
||||
```
|
||||
|
||||
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
|
||||
for example: `REACT_APP_SERVER=http://your.domain.com`.
|
||||
|
||||
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
|
||||
|
||||
## Reference
|
||||
|
||||
1. https://github.com/OIerDb-ng/OIerDb
|
||||
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
|
||||
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -37,9 +37,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"semantic-ui-offline": "^2.5.0",
|
||||
"semantic-ui-react": "^2.1.3",
|
||||
"sse": "https://github.com/mpetazzoni/sse.js",
|
||||
"sse.js": "^2.6.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
@@ -69,7 +67,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/semi-webpack-plugin": "^2.78.0",
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
|
||||
5584
web/pnpm-lock.yaml
generated
5584
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 550 KiB |
@@ -32,7 +32,6 @@ import OIDCIcon from '../common/logo/OIDCIcon.js';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [inputs, setInputs] = useState({
|
||||
@@ -266,7 +265,7 @@ const LoginForm = () => {
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
@@ -500,19 +499,8 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* 背景图片容器 - 放大并保持居中 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* 半透明遮罩层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
<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">
|
||||
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Spin, Typography, Space } from '@douyinfe/semi-ui';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import Loading from '../common/Loading';
|
||||
|
||||
const OAuth2Callback = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [prompt, setPrompt] = useState('处理中...');
|
||||
const [processing, setProcessing] = useState(true);
|
||||
const [prompt, setPrompt] = useState(t('处理中...'));
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
@@ -20,25 +21,25 @@ const OAuth2Callback = (props) => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (message === 'bind') {
|
||||
showSuccess('绑定成功!');
|
||||
navigate('/setting');
|
||||
showSuccess(t('绑定成功!'));
|
||||
navigate('/console/setting');
|
||||
} else {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/token');
|
||||
showSuccess(t('登录成功!'));
|
||||
navigate('/console/token');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
if (count === 0) {
|
||||
setPrompt(`操作失败,重定向至登录界面中...`);
|
||||
navigate('/setting'); // in case this is failed to bind GitHub
|
||||
setPrompt(t('操作失败,重定向至登录界面中...'));
|
||||
navigate('/console/setting'); // in case this is failed to bind GitHub
|
||||
return;
|
||||
}
|
||||
count++;
|
||||
setPrompt(`出现错误,第 ${count} 次重试中...`);
|
||||
setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
|
||||
await new Promise((resolve) => setTimeout(resolve, count * 2000));
|
||||
await sendCode(code, state, count);
|
||||
}
|
||||
@@ -50,17 +51,7 @@ const OAuth2Callback = (props) => {
|
||||
sendCode(code, state, 0).then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
|
||||
<Space vertical align="center">
|
||||
<Spin size="large" spinning={processing}>
|
||||
<div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
|
||||
<Typography.Text type="secondary">{prompt}</Typography.Text>
|
||||
</div>
|
||||
</Spin>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
return <Loading prompt={prompt} />;
|
||||
};
|
||||
|
||||
export default OAuth2Callback;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconLock } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -15,13 +14,14 @@ const PasswordResetConfirm = () => {
|
||||
token: '',
|
||||
});
|
||||
const { email, token } = inputs;
|
||||
const isValidResetLink = email && token;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -30,10 +30,16 @@ const PasswordResetConfirm = () => {
|
||||
let token = searchParams.get('token');
|
||||
let email = searchParams.get('email');
|
||||
setInputs({
|
||||
token,
|
||||
email,
|
||||
token: token || '',
|
||||
email: email || '',
|
||||
});
|
||||
}, []);
|
||||
if (formApi) {
|
||||
formApi.setValues({
|
||||
email: email || '',
|
||||
newPassword: newPassword || ''
|
||||
});
|
||||
}
|
||||
}, [searchParams, newPassword, formApi]);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
@@ -49,7 +55,10 @@ const PasswordResetConfirm = () => {
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (!email || !token) return;
|
||||
if (!email || !token) {
|
||||
showError(t('无效的重置链接,请重新发起密码重置请求'));
|
||||
return;
|
||||
}
|
||||
setDisableButton(true);
|
||||
setLoading(true);
|
||||
const res = await API.post(`/api/user/reset`, {
|
||||
@@ -61,7 +70,7 @@ const PasswordResetConfirm = () => {
|
||||
let password = res.data.data;
|
||||
setNewPassword(password);
|
||||
await copy(password);
|
||||
showNotice(`${t('密码已重置并已复制到剪贴板')}: ${password}`);
|
||||
showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -69,24 +78,13 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* 背景图片容器 - 放大并保持居中 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* 半透明遮罩层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
<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="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
@@ -94,16 +92,28 @@ const PasswordResetConfirm = () => {
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
{!isValidResetLink && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description={t('无效的重置链接,请重新发起密码重置请求')}
|
||||
className="mb-4 !rounded-lg"
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
initValues={{ email: email || '', newPassword: newPassword || '' }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Form.Input
|
||||
field="email"
|
||||
label={t('邮箱')}
|
||||
name="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
value={email}
|
||||
readOnly
|
||||
disabled={true}
|
||||
prefix={<IconMail />}
|
||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
||||
/>
|
||||
|
||||
{newPassword && (
|
||||
@@ -113,14 +123,21 @@ const PasswordResetConfirm = () => {
|
||||
name="newPassword"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
value={newPassword}
|
||||
readOnly
|
||||
disabled={true}
|
||||
prefix={<IconLock />}
|
||||
onClick={(e) => {
|
||||
e.target.select();
|
||||
navigator.clipboard.writeText(newPassword);
|
||||
showNotice(`${t('密码已复制到剪贴板')}: ${newPassword}`);
|
||||
}}
|
||||
suffix={
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
onClick={async () => {
|
||||
await copy(newPassword);
|
||||
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -133,9 +150,9 @@ const PasswordResetConfirm = () => {
|
||||
size="large"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton || newPassword}
|
||||
disabled={disableButton || newPassword || !isValidResetLink}
|
||||
>
|
||||
{newPassword ? t('密码重置完成') : t('提交')}
|
||||
{newPassword ? t('密码重置完成') : t('确认重置密码')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMail } from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -55,7 +54,10 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (!email) return;
|
||||
if (!email) {
|
||||
showError(t('请输入邮箱地址'));
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
|
||||
return;
|
||||
@@ -76,24 +78,13 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* 背景图片容器 - 放大并保持居中 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* 半透明遮罩层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
<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="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
|
||||
@@ -33,7 +33,6 @@ import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||
import TelegramLoginButton from 'react-telegram-login/src';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const RegisterForm = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -272,7 +271,7 @@ const RegisterForm = () => {
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
@@ -379,7 +378,7 @@ const RegisterForm = () => {
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
@@ -542,17 +541,8 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
<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">
|
||||
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
|
||||
@@ -14,7 +14,7 @@ const Loading = ({ prompt: name = '', size = 'large' }) => {
|
||||
tip={null}
|
||||
/>
|
||||
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
|
||||
{name ? t('加载{{name}}中...', { name }) : t('加载中...')}
|
||||
{name ? t('{{name}}', { name }) : t('加载中...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,36 +40,36 @@ const FooterBar = () => {
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('功能特性')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('功能特性')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('API 文档')}</a>
|
||||
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">neko-api-key-tool</a>
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">neko-api-key-tool</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">VoAPI</a> */}
|
||||
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,14 +81,12 @@ const FooterBar = () => {
|
||||
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="text-sm">
|
||||
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
|
||||
<span className="!text-semi-color-primary">Douyin FE</span>
|
||||
<span className="!text-semi-color-text-1"> & </span>
|
||||
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
|
||||
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
|
||||
<span className="!text-semi-color-text-1"> & </span>
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
), [logo, systemName, t, currentYear, isDemoSiteMode]);
|
||||
|
||||
@@ -363,7 +363,7 @@ const HeaderBar = () => {
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={styleState.isMobile}
|
||||
/>
|
||||
<div className="w-full px-4">
|
||||
<div className="w-full px-2">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden">
|
||||
|
||||
@@ -134,11 +134,10 @@ const PageLayout = () => {
|
||||
<Content
|
||||
style={{
|
||||
flex: '1 0 auto',
|
||||
overflowY: styleState.isMobile ? 'visible' : 'auto',
|
||||
overflowY: styleState.isMobile ? 'visible' : 'hidden',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
padding: shouldInnerPadding ? '24px' : '0',
|
||||
padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
|
||||
position: 'relative',
|
||||
marginTop: styleState.isMobile ? '2px' : '0',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
|
||||
79
web/src/components/settings/DashboardSetting.js
Normal file
79
web/src/components/settings/DashboardSetting.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../helpers';
|
||||
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
|
||||
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
|
||||
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
|
||||
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js';
|
||||
|
||||
const DashboardSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
ApiInfo: '',
|
||||
Announcements: '',
|
||||
FAQ: '',
|
||||
UptimeKumaUrl: '',
|
||||
UptimeKumaSlug: '',
|
||||
});
|
||||
|
||||
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 in inputs) {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError('刷新失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* API信息管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* 系统公告管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAnnouncements options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* 常见问答管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsFAQ options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* Uptime Kuma 监控设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsUptimeKuma options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSetting;
|
||||
@@ -30,6 +30,7 @@ const OperationSetting = () => {
|
||||
CompletionRatio: '',
|
||||
ModelPrice: '',
|
||||
GroupRatio: '',
|
||||
GroupGroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
@@ -74,6 +75,7 @@ const OperationSetting = () => {
|
||||
if (
|
||||
item.key === 'ModelRatio' ||
|
||||
item.key === 'GroupRatio' ||
|
||||
item.key === 'GroupGroupRatio' ||
|
||||
item.key === 'UserUsableGroups' ||
|
||||
item.key === 'CompletionRatio' ||
|
||||
item.key === 'ModelPrice' ||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { useTheme } from '../../context/Theme';
|
||||
import {
|
||||
Avatar,
|
||||
Banner,
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
AutoComplete,
|
||||
Checkbox,
|
||||
Tabs,
|
||||
TabPane,
|
||||
TabPane
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
IconKey,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconChevronUp
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
|
||||
import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
|
||||
@@ -64,6 +65,7 @@ const PersonalSetting = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [inputs, setInputs] = useState({
|
||||
wechat_verification_code: '',
|
||||
@@ -101,6 +103,7 @@ const PersonalSetting = () => {
|
||||
webhookSecret: '',
|
||||
notificationEmail: '',
|
||||
acceptUnsetModelRatioModel: false,
|
||||
recordIpLog: false,
|
||||
});
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [showWebhookDocs, setShowWebhookDocs] = useState(true);
|
||||
@@ -145,6 +148,7 @@ const PersonalSetting = () => {
|
||||
notificationEmail: settings.notification_email || '',
|
||||
acceptUnsetModelRatioModel:
|
||||
settings.accept_unset_model_ratio_model || false,
|
||||
recordIpLog: settings.record_ip_log || false,
|
||||
});
|
||||
}
|
||||
}, [userState?.user?.setting]);
|
||||
@@ -344,7 +348,7 @@ const PersonalSetting = () => {
|
||||
const handleNotificationSettingChange = (type, value) => {
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
[type]: value.target ? value.target.value : value, // 处理 Radio 事件对象
|
||||
[type]: value.target ? value.target.value !== undefined ? value.target.value : value.target.checked : value, // handle checkbox properly
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -360,16 +364,17 @@ const PersonalSetting = () => {
|
||||
notification_email: notificationSettings.notificationEmail,
|
||||
accept_unset_model_ratio_model:
|
||||
notificationSettings.acceptUnsetModelRatioModel,
|
||||
record_ip_log: notificationSettings.recordIpLog,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('通知设置已更新'));
|
||||
showSuccess(t('设置保存成功'));
|
||||
await getUserData();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新通知设置失败'));
|
||||
showError(t('设置保存失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -384,107 +389,81 @@ const PersonalSetting = () => {
|
||||
<Card className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部用户信息区域 */}
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
|
||||
className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
|
||||
background: theme === 'dark'
|
||||
? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
|
||||
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
|
||||
position: 'relative'
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<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-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
|
||||
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 dark:bg-slate-500 opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 dark:bg-slate-400 opacity-8 rounded-full"></div>
|
||||
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 dark:bg-slate-500 opacity-6 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
|
||||
<div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
|
||||
<div className="flex justify-between items-start mb-4 sm:mb-6">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<Avatar
|
||||
size='large'
|
||||
color={stringToColor(getUsername())}
|
||||
border={{ motion: true }}
|
||||
contentMotion={true}
|
||||
className="mr-3 sm:mr-4 shadow-lg flex-shrink-0"
|
||||
className="mr-3 sm:mr-4 shadow-md flex-shrink-0 bg-slate-500 dark:bg-slate-400"
|
||||
>
|
||||
{getAvatarText()}
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
|
||||
<div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
|
||||
{getUsername()}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
|
||||
{isRoot() ? (
|
||||
<Tag
|
||||
color='red'
|
||||
size='small'
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#dc2626',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
className="!rounded-full"
|
||||
className="!rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
style={{ fontWeight: '500' }}
|
||||
>
|
||||
{t('超级管理员')}
|
||||
</Tag>
|
||||
) : isAdmin() ? (
|
||||
<Tag
|
||||
color='orange'
|
||||
size='small'
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#ea580c',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
className="!rounded-full"
|
||||
className="!rounded-full bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||
style={{ fontWeight: '500' }}
|
||||
>
|
||||
{t('管理员')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
color='blue'
|
||||
size='small'
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#2563eb',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
className="!rounded-full"
|
||||
className="!rounded-full bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
|
||||
style={{ fontWeight: '500' }}
|
||||
>
|
||||
{t('普通用户')}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag
|
||||
color='green'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#16a34a',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
className="!rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
|
||||
style={{ fontWeight: '500' }}
|
||||
>
|
||||
ID: {userState?.user?.id}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
|
||||
}}
|
||||
>
|
||||
<IconUser size="default" style={{ color: 'white' }} />
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
|
||||
<IconUser size="default" className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
<div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
|
||||
{t('当前余额')}
|
||||
</div>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
|
||||
{renderQuota(userState?.user?.quota)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -492,33 +471,33 @@ const PersonalSetting = () => {
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
|
||||
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t('历史消耗')}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
|
||||
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
|
||||
{renderQuota(userState?.user?.used_quota)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t('请求次数')}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
|
||||
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
|
||||
{userState.user?.request_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t('用户分组')}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
|
||||
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
|
||||
{userState?.user?.group || t('默认')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -537,10 +516,10 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="gap-6 py-4">
|
||||
{/* 可用模型部分 */}
|
||||
<div className="bg-gray-50 rounded-xl">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3">
|
||||
<Settings size={20} className="text-purple-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<Settings size={20} className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
|
||||
@@ -629,7 +608,7 @@ const PersonalSetting = () => {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-3">
|
||||
<div className="bg-white dark:bg-gray-700 rounded-lg p-3">
|
||||
{(() => {
|
||||
// 根据当前选中的分类过滤模型
|
||||
const categories = getModelCategories(t);
|
||||
@@ -737,8 +716,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mr-3">
|
||||
<IconMail size="default" className="text-red-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<IconMail size="default" className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{t('邮箱')}</div>
|
||||
@@ -771,8 +750,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3">
|
||||
<SiWechat size={20} className="text-green-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<SiWechat size={20} className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{t('微信')}</div>
|
||||
@@ -808,8 +787,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mr-3">
|
||||
<IconGithubLogo size="default" className="text-gray-700" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<IconGithubLogo size="default" className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{t('GitHub')}</div>
|
||||
@@ -844,8 +823,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center mr-3">
|
||||
<IconShield size="default" className="text-indigo-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<IconShield size="default" className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{t('OIDC')}</div>
|
||||
@@ -883,8 +862,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3">
|
||||
<SiTelegram size={20} className="text-blue-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<SiTelegram size={20} className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{t('Telegram')}</div>
|
||||
@@ -926,8 +905,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3">
|
||||
<SiLinux size={20} className="text-orange-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
|
||||
<SiLinux size={20} className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{t('LinuxDO')}</div>
|
||||
@@ -978,8 +957,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start w-full sm:w-auto">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconKey size="large" className="text-blue-500" />
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconKey size="large" className="text-slate-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Typography.Title heading={6} className="mb-1">
|
||||
@@ -1006,7 +985,7 @@ const PersonalSetting = () => {
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={generateAccessToken}
|
||||
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 w-full sm:w-auto"
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
|
||||
icon={<IconKey />}
|
||||
>
|
||||
{systemToken ? t('重新生成') : t('生成令牌')}
|
||||
@@ -1022,8 +1001,8 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start w-full sm:w-auto">
|
||||
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconLock size="large" className="text-orange-500" />
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconLock size="large" className="text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className="mb-1">
|
||||
@@ -1038,7 +1017,7 @@ const PersonalSetting = () => {
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
className="!rounded-lg !bg-orange-500 hover:!bg-orange-600 w-full sm:w-auto"
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
|
||||
icon={<IconLock />}
|
||||
>
|
||||
{t('修改密码')}
|
||||
@@ -1054,11 +1033,11 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start w-full sm:w-auto">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconDelete size="large" className="text-red-500" />
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<IconDelete size="large" className="text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className="mb-1 text-red-600">
|
||||
<Typography.Title heading={6} className="mb-1 text-slate-700">
|
||||
{t('删除账户')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="tertiary" className="text-sm">
|
||||
@@ -1070,7 +1049,7 @@ const PersonalSetting = () => {
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={() => setShowAccountDeleteModal(true)}
|
||||
className="!rounded-lg w-full sm:w-auto"
|
||||
className="!rounded-lg w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600"
|
||||
icon={<IconDelete />}
|
||||
>
|
||||
{t('删除账户')}
|
||||
@@ -1087,7 +1066,7 @@ const PersonalSetting = () => {
|
||||
tab={
|
||||
<div className="flex items-center">
|
||||
<Bell size={16} className="mr-2" />
|
||||
{t('通知设置')}
|
||||
{t('其他设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='notification'
|
||||
@@ -1111,7 +1090,7 @@ const PersonalSetting = () => {
|
||||
>
|
||||
<Radio value='email' className="!p-4 !rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<IconMail className="mr-2 text-blue-500" />
|
||||
<IconMail className="mr-2 text-slate-600" />
|
||||
<div>
|
||||
<div className="font-medium">{t('邮件通知')}</div>
|
||||
<div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
|
||||
@@ -1120,7 +1099,7 @@ const PersonalSetting = () => {
|
||||
</Radio>
|
||||
<Radio value='webhook' className="!p-4 !rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Webhook size={16} className="mr-2 text-green-500" />
|
||||
<Webhook size={16} className="mr-2 text-slate-600" />
|
||||
<div>
|
||||
<div className="font-medium">{t('Webhook通知')}</div>
|
||||
<div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
|
||||
@@ -1167,11 +1146,11 @@ const PersonalSetting = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 rounded-xl">
|
||||
<div className="bg-slate-50 rounded-xl">
|
||||
<div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
|
||||
<div className="flex items-center">
|
||||
<Globe size={16} className="mr-2 text-yellow-600" />
|
||||
<Typography.Text strong className="text-yellow-800">
|
||||
<Globe size={16} className="mr-2 text-slate-600" />
|
||||
<Typography.Text strong className="text-slate-700">
|
||||
{t('Webhook请求结构')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -1252,28 +1231,68 @@ const PersonalSetting = () => {
|
||||
<TabPane
|
||||
tab={t('价格设置')}
|
||||
itemKey='price'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="space-y-4">
|
||||
{/* 接受未设置价格模型 */}
|
||||
<div className="bg-white rounded-xl">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
|
||||
<Shield size={20} className="text-slate-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">
|
||||
{t('接受未设置价格模型')}
|
||||
</Typography.Text>
|
||||
<div className="text-gray-500 text-sm">
|
||||
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={notificationSettings.acceptUnsetModelRatioModel}
|
||||
onChange={(e) =>
|
||||
handleNotificationSettingChange(
|
||||
'acceptUnsetModelRatioModel',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
className="ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane
|
||||
tab={t('IP记录')}
|
||||
itemKey='ip'
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="bg-white rounded-xl">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mt-1">
|
||||
<Shield size={20} className="text-orange-500" />
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
|
||||
<ShieldCheck size={20} className="text-slate-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">
|
||||
{t('接受未设置价格模型')}
|
||||
{t('记录请求与错误日志 IP')}
|
||||
</Typography.Text>
|
||||
<div className="text-gray-500 text-sm">
|
||||
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
|
||||
{t('开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址')}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={notificationSettings.acceptUnsetModelRatioModel}
|
||||
checked={notificationSettings.recordIpLog}
|
||||
onChange={(e) =>
|
||||
handleNotificationSettingChange(
|
||||
'acceptUnsetModelRatioModel',
|
||||
'recordIpLog',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
@@ -1292,7 +1311,7 @@ const PersonalSetting = () => {
|
||||
type='primary'
|
||||
onClick={saveNotificationSettings}
|
||||
size="large"
|
||||
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600"
|
||||
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
|
||||
icon={<IconSetting />}
|
||||
>
|
||||
{t('保存设置')}
|
||||
@@ -1408,7 +1427,7 @@ const PersonalSetting = () => {
|
||||
theme="solid"
|
||||
size='large'
|
||||
onClick={bindWeChat}
|
||||
className="!rounded-lg w-full !bg-green-500 hover:!bg-green-600"
|
||||
className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700"
|
||||
icon={<SiWechat size={16} />}
|
||||
>
|
||||
{t('绑定')}
|
||||
|
||||
@@ -6,15 +6,31 @@ import {
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroup,
|
||||
renderNumberWithPoint,
|
||||
renderQuota
|
||||
renderQuota,
|
||||
getChannelIcon,
|
||||
renderQuotaWithAmount
|
||||
} from '../../helpers/index.js';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
TestTube,
|
||||
Zap,
|
||||
Timer,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Coins,
|
||||
Tags
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
@@ -27,13 +43,15 @@ import {
|
||||
Typography,
|
||||
Checkbox,
|
||||
Card,
|
||||
Select
|
||||
Form
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import EditChannel from '../../pages/Channel/EditChannel.js';
|
||||
import {
|
||||
IconList,
|
||||
IconTreeTriangleDown,
|
||||
IconFilter,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSetting,
|
||||
@@ -64,7 +82,12 @@ const ChannelsTable = () => {
|
||||
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
|
||||
}
|
||||
return (
|
||||
<Tag size='large' color={type2label[type]?.color} shape='circle'>
|
||||
<Tag
|
||||
size='large'
|
||||
color={type2label[type]?.color}
|
||||
shape='circle'
|
||||
prefixIcon={getChannelIcon(type)}
|
||||
>
|
||||
{type2label[type]?.label}
|
||||
</Tag>
|
||||
);
|
||||
@@ -74,7 +97,7 @@ const ChannelsTable = () => {
|
||||
return (
|
||||
<Tag
|
||||
color='light-blue'
|
||||
prefixIcon={<IconList />}
|
||||
prefixIcon={<Tags size={14} />}
|
||||
size='large'
|
||||
shape='circle'
|
||||
type='light'
|
||||
@@ -88,25 +111,25 @@ const ChannelsTable = () => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag size='large' color='green' shape='circle'>
|
||||
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag size='large' color='yellow' shape='circle'>
|
||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag size='large' color='yellow' shape='circle'>
|
||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag size='large' color='grey' shape='circle'>
|
||||
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -118,31 +141,31 @@ const ChannelsTable = () => {
|
||||
time = time.toFixed(2) + t(' 秒');
|
||||
if (responseTime === 0) {
|
||||
return (
|
||||
<Tag size='large' color='grey' shape='circle'>
|
||||
<Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
|
||||
{t('未测试')}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 1000) {
|
||||
return (
|
||||
<Tag size='large' color='green' shape='circle'>
|
||||
<Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 3000) {
|
||||
return (
|
||||
<Tag size='large' color='lime' shape='circle'>
|
||||
<Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else if (responseTime <= 5000) {
|
||||
return (
|
||||
<Tag size='large' color='yellow' shape='circle'>
|
||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag size='large' color='red' shape='circle'>
|
||||
<Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
|
||||
{time}
|
||||
</Tag>
|
||||
);
|
||||
@@ -324,19 +347,20 @@ const ChannelsTable = () => {
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tooltip content={t('已用额度')}>
|
||||
<Tag color='white' type='ghost' size='large' shape='circle'>
|
||||
<Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('剩余额度') + record.balance + t(',点击更新')}>
|
||||
<Tooltip content={t('剩余额度$') + record.balance + t(',点击更新')}>
|
||||
<Tag
|
||||
color='white'
|
||||
type='ghost'
|
||||
size='large'
|
||||
shape='circle'
|
||||
prefixIcon={<Coins size={14} />}
|
||||
onClick={() => updateChannelBalance(record)}
|
||||
>
|
||||
${renderNumberWithPoint(record.balance)}
|
||||
{renderQuotaWithAmount(record.balance)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
@@ -345,7 +369,7 @@ const ChannelsTable = () => {
|
||||
} else {
|
||||
return (
|
||||
<Tooltip content={t('已用额度')}>
|
||||
<Tag color='white' type='ghost' size='large' shape='circle'>
|
||||
<Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
|
||||
{renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
@@ -631,6 +655,44 @@ const ChannelsTable = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [idSort, setIdSort] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [channelCount, setChannelCount] = useState(pageSize);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [enableBatchDelete, setEnableBatchDelete] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
const [showEditTag, setShowEditTag] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState('');
|
||||
const [selectedChannels, setSelectedChannels] = useState([]);
|
||||
const [enableTagMode, setEnableTagMode] = useState(false);
|
||||
const [showBatchSetTag, setShowBatchSetTag] = useState(false);
|
||||
const [batchSetTagValue, setBatchSetTagValue] = useState('');
|
||||
const [showModelTestModal, setShowModelTestModal] = useState(false);
|
||||
const [currentTestChannel, setCurrentTestChannel] = useState(null);
|
||||
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModels, setTestingModels] = useState(new Set());
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [testQueue, setTestQueue] = useState([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchGroup: '',
|
||||
searchModel: '',
|
||||
};
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
@@ -668,8 +730,6 @@ const ChannelsTable = () => {
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
size="middle"
|
||||
centered={true}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
@@ -714,37 +774,6 @@ const ChannelsTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [idSort, setIdSort] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searchGroup, setSearchGroup] = useState('');
|
||||
const [searchModel, setSearchModel] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [channelCount, setChannelCount] = useState(pageSize);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [enableBatchDelete, setEnableBatchDelete] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
const [showEditTag, setShowEditTag] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState('');
|
||||
const [selectedChannels, setSelectedChannels] = useState([]);
|
||||
const [enableTagMode, setEnableTagMode] = useState(false);
|
||||
const [showBatchSetTag, setShowBatchSetTag] = useState(false);
|
||||
const [batchSetTagValue, setBatchSetTagValue] = useState('');
|
||||
const [showModelTestModal, setShowModelTestModal] = useState(false);
|
||||
const [currentTestChannel, setCurrentTestChannel] = useState(null);
|
||||
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testingModels, setTestingModels] = useState(new Set());
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [testQueue, setTestQueue] = useState([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
|
||||
const removeRecord = (record) => {
|
||||
let newDataSource = [...channels];
|
||||
if (record.id != null) {
|
||||
@@ -836,32 +865,22 @@ const ChannelsTable = () => {
|
||||
tagChannelDates.response_time = tagChannelDates.response_time / 2;
|
||||
}
|
||||
}
|
||||
// data.key = '' + data.id
|
||||
setChannels(channelDates);
|
||||
if (channelDates.length >= pageSize) {
|
||||
setChannelCount(channelDates.length + pageSize);
|
||||
} else {
|
||||
setChannelCount(channelDates.length);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => {
|
||||
const loadChannels = async (page, pageSize, idSort, enableTagMode) => {
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
|
||||
);
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setChannelFormat(data, enableTagMode);
|
||||
} else {
|
||||
let newChannels = [...channels];
|
||||
newChannels.splice(startIdx * pageSize, data.length, ...data);
|
||||
setChannelFormat(newChannels, enableTagMode);
|
||||
}
|
||||
const { items, total } = data;
|
||||
setChannelFormat(items, enableTagMode);
|
||||
setChannelCount(total);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -874,7 +893,6 @@ const ChannelsTable = () => {
|
||||
channelToCopy.created_time = null;
|
||||
channelToCopy.balance = 0;
|
||||
channelToCopy.used_quota = 0;
|
||||
// 删除可能导致类型不匹配的字段
|
||||
delete channelToCopy.test_time;
|
||||
delete channelToCopy.response_time;
|
||||
if (!channelToCopy) {
|
||||
@@ -896,15 +914,11 @@ const ChannelsTable = () => {
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||||
await loadChannels(activePage, pageSize, idSort, enableTagMode);
|
||||
} else {
|
||||
await searchChannels(
|
||||
searchKeyword,
|
||||
searchGroup,
|
||||
searchModel,
|
||||
enableTagMode,
|
||||
);
|
||||
await searchChannels(enableTagMode);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -919,7 +933,7 @@ const ChannelsTable = () => {
|
||||
setPageSize(localPageSize);
|
||||
setEnableTagMode(localEnableTagMode);
|
||||
setEnableBatchDelete(localEnableBatchDelete);
|
||||
loadChannels(0, localPageSize, localIdSort, localEnableTagMode)
|
||||
loadChannels(1, localPageSize, localIdSort, localEnableTagMode)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
@@ -1010,29 +1024,39 @@ const ChannelsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const searchChannels = async (
|
||||
searchKeyword,
|
||||
searchGroup,
|
||||
searchModel,
|
||||
enableTagMode,
|
||||
) => {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||||
// setActivePage(1);
|
||||
return;
|
||||
}
|
||||
// 获取表单值的辅助函数,确保所有值都是字符串
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
searchGroup: formValues.searchGroup || '',
|
||||
searchModel: formValues.searchModel || '',
|
||||
};
|
||||
};
|
||||
|
||||
const searchChannels = async (enableTagMode) => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
|
||||
setSearching(true);
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setChannelFormat(data, enableTagMode);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
try {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setChannelFormat(data, enableTagMode);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const updateChannelProperty = (channelId, updateFn) => {
|
||||
@@ -1155,24 +1179,18 @@ const ChannelsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
let pageData = channels.slice(
|
||||
(activePage - 1) * pageSize,
|
||||
activePage * pageSize,
|
||||
);
|
||||
let pageData = channels;
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(channels.length / pageSize) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => { });
|
||||
}
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
loadChannels(0, size, idSort, enableTagMode)
|
||||
loadChannels(1, size, idSort, enableTagMode)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
@@ -1182,8 +1200,6 @@ const ChannelsTable = () => {
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
// add 'all' option
|
||||
// res.data.data.unshift('all');
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -1478,7 +1494,7 @@ const ChannelsTable = () => {
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('id-sort', v + '');
|
||||
setIdSort(v);
|
||||
loadChannels(0, pageSize, v, enableTagMode);
|
||||
loadChannels(activePage, pageSize, v, enableTagMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1505,7 +1521,8 @@ const ChannelsTable = () => {
|
||||
onChange={(v) => {
|
||||
localStorage.setItem('enable-tag-mode', v + '');
|
||||
setEnableTagMode(v);
|
||||
loadChannels(0, pageSize, idSort, v);
|
||||
setActivePage(1);
|
||||
loadChannels(1, pageSize, idSort, v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1553,58 +1570,80 @@ const ChannelsTable = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v.trim());
|
||||
}}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Input
|
||||
prefix={<IconFilter />}
|
||||
placeholder={t('模型关键字')}
|
||||
value={searchModel}
|
||||
loading={searching}
|
||||
onChange={(v) => {
|
||||
setSearchModel(v.trim());
|
||||
}}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Select
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
{ label: t('选择分组'), value: null },
|
||||
...groupOptions,
|
||||
]}
|
||||
value={searchGroup}
|
||||
onChange={(v) => {
|
||||
setSearchGroup(v);
|
||||
searchChannels(searchKeyword, v, searchModel, enableTagMode);
|
||||
}}
|
||||
className="!rounded-full w-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
|
||||
}}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={() => searchChannels(enableTagMode)}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="flex flex-col md:flex-row items-center gap-4 w-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<div className="relative w-full md:w-64">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索渠道的 ID,名称,密钥和API地址 ...')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Form.Input
|
||||
field="searchModel"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型关键字')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Form.Select
|
||||
field="searchGroup"
|
||||
placeholder={t('选择分组')}
|
||||
optionList={[
|
||||
{ label: t('选择分组'), value: null },
|
||||
...groupOptions,
|
||||
]}
|
||||
className="!rounded-full w-full"
|
||||
showClear
|
||||
pure
|
||||
onChange={() => {
|
||||
// 延迟执行搜索,让表单值先更新
|
||||
setTimeout(() => {
|
||||
searchChannels(enableTagMode);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1645,7 +1684,7 @@ const ChannelsTable = () => {
|
||||
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: channels.length,
|
||||
total: channelCount,
|
||||
}),
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
@@ -1663,6 +1702,14 @@ const ChannelsTable = () => {
|
||||
}
|
||||
: null
|
||||
}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
loading={loading}
|
||||
@@ -1756,7 +1803,6 @@ const ChannelsTable = () => {
|
||||
</div>
|
||||
}
|
||||
maskClosable={!isBatchTesting}
|
||||
centered={true}
|
||||
className="!rounded-lg"
|
||||
size="large"
|
||||
>
|
||||
@@ -1857,7 +1903,6 @@ const ChannelsTable = () => {
|
||||
key: model
|
||||
}))}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
@@ -39,24 +39,19 @@ import {
|
||||
Card,
|
||||
Typography,
|
||||
Divider,
|
||||
Input,
|
||||
DatePicker,
|
||||
Form,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
||||
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
|
||||
import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Route } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ key: 'all', text: 'all', value: 'all' },
|
||||
{ key: 'self', text: 'current user', value: 'self' },
|
||||
];
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
'blue',
|
||||
@@ -197,7 +192,7 @@ const LogsTable = () => {
|
||||
if (!modelMapped) {
|
||||
return renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -214,7 +209,7 @@ const LogsTable = () => {
|
||||
</Text>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
@@ -225,7 +220,7 @@ const LogsTable = () => {
|
||||
{renderModelTag(other.upstream_model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, other.upstream_model_name).then(
|
||||
(r) => { },
|
||||
(r) => {},
|
||||
);
|
||||
},
|
||||
})}
|
||||
@@ -236,10 +231,10 @@ const LogsTable = () => {
|
||||
>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
},
|
||||
suffixIcon: (
|
||||
<IconForward
|
||||
<Route
|
||||
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
|
||||
/>
|
||||
),
|
||||
@@ -265,6 +260,7 @@ const LogsTable = () => {
|
||||
COMPLETION: 'completion',
|
||||
COST: 'cost',
|
||||
RETRY: 'retry',
|
||||
IP: 'ip',
|
||||
DETAILS: 'details',
|
||||
};
|
||||
|
||||
@@ -306,6 +302,7 @@ const LogsTable = () => {
|
||||
[COLUMN_KEYS.COMPLETION]: true,
|
||||
[COLUMN_KEYS.COST]: true,
|
||||
[COLUMN_KEYS.RETRY]: isAdminUser,
|
||||
[COLUMN_KEYS.IP]: true,
|
||||
[COLUMN_KEYS.DETAILS]: true,
|
||||
};
|
||||
};
|
||||
@@ -490,6 +487,9 @@ const LogsTable = () => {
|
||||
title: t('用时/首字'),
|
||||
dataIndex: 'use_time',
|
||||
render: (text, record, index) => {
|
||||
if (!(record.type === 2 || record.type === 5)) {
|
||||
return <></>;
|
||||
}
|
||||
if (record.is_stream) {
|
||||
let other = getLogOther(record.other);
|
||||
return (
|
||||
@@ -550,12 +550,45 @@ const LogsTable = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.IP,
|
||||
title: (
|
||||
<div className="flex items-center gap-1">
|
||||
{t('IP')}
|
||||
<Tooltip content={t('只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录')}>
|
||||
<IconHelpCircle className="text-gray-400 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'ip',
|
||||
render: (text, record, index) => {
|
||||
return (record.type === 2 || record.type === 5) && text ? (
|
||||
<Tooltip content={text}>
|
||||
<Tag
|
||||
color='orange'
|
||||
size='large'
|
||||
shape='circle'
|
||||
onClick={(event) => {
|
||||
copyText(event, text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.RETRY,
|
||||
title: t('重试'),
|
||||
dataIndex: 'retry',
|
||||
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||
render: (text, record, index) => {
|
||||
if (!(record.type === 2 || record.type === 5)) {
|
||||
return <></>;
|
||||
}
|
||||
let content = t('渠道') + `:${record.channel}`;
|
||||
if (record.other !== '') {
|
||||
let other = JSON.parse(record.other);
|
||||
@@ -603,21 +636,23 @@ const LogsTable = () => {
|
||||
}
|
||||
let content = other?.claude
|
||||
? renderClaudeModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
);
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
);
|
||||
return (
|
||||
<Paragraph
|
||||
ellipsis={{
|
||||
@@ -737,39 +772,71 @@ const LogsTable = () => {
|
||||
const [logType, setLogType] = useState(0);
|
||||
const isAdminUser = isAdmin();
|
||||
let now = new Date();
|
||||
// 初始化start_timestamp为今天0点
|
||||
const [inputs, setInputs] = useState({
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
username: '',
|
||||
token_name: '',
|
||||
model_name: '',
|
||||
start_timestamp: timestamp2string(getTodayStartTimestamp()),
|
||||
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||||
channel: '',
|
||||
group: '',
|
||||
});
|
||||
const {
|
||||
username,
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
} = inputs;
|
||||
dateRange: [
|
||||
timestamp2string(getTodayStartTimestamp()),
|
||||
timestamp2string(now.getTime() / 1000 + 3600),
|
||||
],
|
||||
logType: '0',
|
||||
};
|
||||
|
||||
const [stat, setStat] = useState({
|
||||
quota: 0,
|
||||
token: 0,
|
||||
});
|
||||
|
||||
const handleInputChange = (value, name) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数,确保所有值都是字符串
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
|
||||
// 处理时间范围
|
||||
let start_timestamp = timestamp2string(getTodayStartTimestamp());
|
||||
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
||||
|
||||
if (
|
||||
formValues.dateRange &&
|
||||
Array.isArray(formValues.dateRange) &&
|
||||
formValues.dateRange.length === 2
|
||||
) {
|
||||
start_timestamp = formValues.dateRange[0];
|
||||
end_timestamp = formValues.dateRange[1];
|
||||
}
|
||||
|
||||
return {
|
||||
username: formValues.username || '',
|
||||
token_name: formValues.token_name || '',
|
||||
model_name: formValues.model_name || '',
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel: formValues.channel || '',
|
||||
group: formValues.group || '',
|
||||
logType: formValues.logType ? parseInt(formValues.logType) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getLogSelfStat = async () => {
|
||||
const {
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
group,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
const currentLogType = formLogType !== undefined ? formLogType : logType;
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let url = `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
||||
let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
||||
url = encodeURI(url);
|
||||
let res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -781,9 +848,20 @@ const LogsTable = () => {
|
||||
};
|
||||
|
||||
const getLogStat = async () => {
|
||||
const {
|
||||
username,
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
const currentLogType = formLogType !== undefined ? formLogType : logType;
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let url = `/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
||||
let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
||||
url = encodeURI(url);
|
||||
let res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -907,27 +985,27 @@ const LogsTable = () => {
|
||||
key: t('日志详情'),
|
||||
value: other?.claude
|
||||
? renderClaudeLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
false,
|
||||
1.0,
|
||||
undefined,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
false,
|
||||
1.0,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
@@ -958,6 +1036,7 @@ const LogsTable = () => {
|
||||
other?.audio_ratio,
|
||||
other?.audio_completion_ratio,
|
||||
other?.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other?.cache_tokens || 0,
|
||||
other?.cache_ratio || 1.0,
|
||||
);
|
||||
@@ -969,6 +1048,7 @@ const LogsTable = () => {
|
||||
other.model_price,
|
||||
other.completion_ratio,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
@@ -982,6 +1062,7 @@ const LogsTable = () => {
|
||||
other?.model_price,
|
||||
other?.completion_ratio,
|
||||
other?.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other?.cache_tokens || 0,
|
||||
other?.cache_ratio || 1.0,
|
||||
other?.image || false,
|
||||
@@ -1016,16 +1097,35 @@ const LogsTable = () => {
|
||||
setLogs(logs);
|
||||
};
|
||||
|
||||
const loadLogs = async (startIdx, pageSize, logType = 0) => {
|
||||
const loadLogs = async (startIdx, pageSize, customLogType = null) => {
|
||||
setLoading(true);
|
||||
|
||||
let url = '';
|
||||
const {
|
||||
username,
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
|
||||
// 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
|
||||
const currentLogType =
|
||||
customLogType !== null
|
||||
? customLogType
|
||||
: formLogType !== undefined
|
||||
? formLogType
|
||||
: logType;
|
||||
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
if (isAdminUser) {
|
||||
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
||||
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
|
||||
} else {
|
||||
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
||||
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
||||
}
|
||||
url = encodeURI(url);
|
||||
const res = await API.get(url);
|
||||
@@ -1045,7 +1145,7 @@ const LogsTable = () => {
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadLogs(page, pageSize, logType).then((r) => { });
|
||||
loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
@@ -1062,7 +1162,7 @@ const LogsTable = () => {
|
||||
const refresh = async () => {
|
||||
setActivePage(1);
|
||||
handleEyeClick();
|
||||
await loadLogs(activePage, pageSize, logType);
|
||||
await loadLogs(1, pageSize); // 不传入logType,让其从表单获取最新值
|
||||
};
|
||||
|
||||
const copyText = async (e, text) => {
|
||||
@@ -1083,9 +1183,15 @@ const LogsTable = () => {
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
handleEyeClick();
|
||||
}, []);
|
||||
|
||||
// 当 formApi 可用时,初始化统计
|
||||
useEffect(() => {
|
||||
if (formApi) {
|
||||
handleEyeClick();
|
||||
}
|
||||
}, [formApi]);
|
||||
|
||||
const expandRowRender = (record, index) => {
|
||||
return <Descriptions data={expandData[record.key]} />;
|
||||
};
|
||||
@@ -1149,115 +1255,156 @@ const LogsTable = () => {
|
||||
<Divider margin='12px' />
|
||||
|
||||
{/* 搜索表单区域 */}
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||
{/* 时间选择器 */}
|
||||
<div className='col-span-1 lg:col-span-2'>
|
||||
<DatePicker
|
||||
className='w-full'
|
||||
value={[start_timestamp, end_timestamp]}
|
||||
type='dateTimeRange'
|
||||
onChange={(value) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
handleInputChange(value[0], 'start_timestamp');
|
||||
handleInputChange(value[1], 'end_timestamp');
|
||||
}
|
||||
}}
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={refresh}
|
||||
allowEmpty={true}
|
||||
autoComplete='off'
|
||||
layout='vertical'
|
||||
trigger='change'
|
||||
stopValidateWithError={false}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||
{/* 时间选择器 */}
|
||||
<div className='col-span-1 lg:col-span-2'>
|
||||
<Form.DatePicker
|
||||
field='dateRange'
|
||||
className='w-full'
|
||||
type='dateTimeRange'
|
||||
placeholder={[t('开始时间'), t('结束时间')]}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 其他搜索字段 */}
|
||||
<Form.Input
|
||||
field='token_name'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('令牌名称')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型名称')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='group'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('分组')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
|
||||
{isAdminUser && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='channel'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
<Form.Input
|
||||
field='username'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('用户名称')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 日志类型选择器 */}
|
||||
<Select
|
||||
value={logType.toString()}
|
||||
placeholder={t('日志类型')}
|
||||
className='!rounded-full'
|
||||
onChange={(value) => {
|
||||
setLogType(parseInt(value));
|
||||
loadLogs(0, pageSize, parseInt(value));
|
||||
}}
|
||||
>
|
||||
<Select.Option value='0'>{t('全部')}</Select.Option>
|
||||
<Select.Option value='1'>{t('充值')}</Select.Option>
|
||||
<Select.Option value='2'>{t('消费')}</Select.Option>
|
||||
<Select.Option value='3'>{t('管理')}</Select.Option>
|
||||
<Select.Option value='4'>{t('系统')}</Select.Option>
|
||||
<Select.Option value='5'>{t('错误')}</Select.Option>
|
||||
</Select>
|
||||
|
||||
{/* 其他搜索字段 */}
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('令牌名称')}
|
||||
value={token_name}
|
||||
onChange={(value) => handleInputChange(value, 'token_name')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型名称')}
|
||||
value={model_name}
|
||||
onChange={(value) => handleInputChange(value, 'model_name')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('分组')}
|
||||
value={group}
|
||||
onChange={(value) => handleInputChange(value, 'group')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
|
||||
{isAdminUser && (
|
||||
<>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
value={channel}
|
||||
onChange={(value) => handleInputChange(value, 'channel')}
|
||||
className='!rounded-full'
|
||||
{/* 操作按钮区域 */}
|
||||
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
|
||||
{/* 日志类型选择器 */}
|
||||
<div className='w-full sm:w-auto'>
|
||||
<Form.Select
|
||||
field='logType'
|
||||
placeholder={t('日志类型')}
|
||||
className='!rounded-full w-full sm:w-auto min-w-[120px]'
|
||||
showClear
|
||||
/>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('用户名称')}
|
||||
value={username}
|
||||
onChange={(value) => handleInputChange(value, 'username')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
pure
|
||||
onChange={() => {
|
||||
// 延迟执行搜索,让表单值先更新
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<Form.Select.Option value='0'>
|
||||
{t('全部')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='1'>
|
||||
{t('充值')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='2'>
|
||||
{t('消费')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='3'>
|
||||
{t('管理')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='4'>
|
||||
{t('系统')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='5'>
|
||||
{t('错误')}
|
||||
</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className='flex justify-between items-center pt-2'>
|
||||
<div></div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
<div className='flex gap-2 w-full sm:w-auto justify-end'>
|
||||
<Button
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
setLogType(0);
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
}
|
||||
shadows='always'
|
||||
@@ -1268,7 +1415,8 @@ const LogsTable = () => {
|
||||
{...(hasExpandableRows() && {
|
||||
expandedRowRender: expandRowRender,
|
||||
expandRowByClick: true,
|
||||
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
|
||||
rowExpandable: (record) =>
|
||||
expandData[record.key] && expandData[record.key].length > 0,
|
||||
})}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
@@ -1276,6 +1424,18 @@ const LogsTable = () => {
|
||||
scroll={{ x: 'max-content' }}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
|
||||
@@ -1,35 +1,65 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Palette,
|
||||
ZoomIn,
|
||||
Shuffle,
|
||||
Move,
|
||||
FileText,
|
||||
Blend,
|
||||
Upload,
|
||||
Minimize2,
|
||||
RotateCcw,
|
||||
PaintBucket,
|
||||
Focus,
|
||||
Move3D,
|
||||
Monitor,
|
||||
UserCheck,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
FileX,
|
||||
Pause,
|
||||
XCircle,
|
||||
Loader,
|
||||
AlertCircle,
|
||||
Hash
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
timestamp2string
|
||||
} from '../../helpers';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Empty,
|
||||
Form,
|
||||
ImagePreview,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Typography
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconSetting
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -154,103 +184,103 @@ const LogsTable = () => {
|
||||
switch (type) {
|
||||
case 'IMAGINE':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Palette size={14} />}>
|
||||
{t('绘图')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UPSCALE':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<ZoomIn size={14} />}>
|
||||
{t('放大')}
|
||||
</Tag>
|
||||
);
|
||||
case 'VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
{t('变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'HIGH_VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
{t('强变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'LOW_VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
{t('弱变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'PAN':
|
||||
return (
|
||||
<Tag color='cyan' size='large' shape='circle'>
|
||||
<Tag color='cyan' size='large' shape='circle' prefixIcon={<Move size={14} />}>
|
||||
{t('平移')}
|
||||
</Tag>
|
||||
);
|
||||
case 'DESCRIBE':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
|
||||
{t('图生文')}
|
||||
</Tag>
|
||||
);
|
||||
case 'BLEND':
|
||||
return (
|
||||
<Tag color='lime' size='large' shape='circle'>
|
||||
<Tag color='lime' size='large' shape='circle' prefixIcon={<Blend size={14} />}>
|
||||
{t('图混合')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UPLOAD':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Upload size={14} />}>
|
||||
上传文件
|
||||
</Tag>
|
||||
);
|
||||
case 'SHORTEN':
|
||||
return (
|
||||
<Tag color='pink' size='large' shape='circle'>
|
||||
<Tag color='pink' size='large' shape='circle' prefixIcon={<Minimize2 size={14} />}>
|
||||
{t('缩词')}
|
||||
</Tag>
|
||||
);
|
||||
case 'REROLL':
|
||||
return (
|
||||
<Tag color='indigo' size='large' shape='circle'>
|
||||
<Tag color='indigo' size='large' shape='circle' prefixIcon={<RotateCcw size={14} />}>
|
||||
{t('重绘')}
|
||||
</Tag>
|
||||
);
|
||||
case 'INPAINT':
|
||||
return (
|
||||
<Tag color='violet' size='large' shape='circle'>
|
||||
<Tag color='violet' size='large' shape='circle' prefixIcon={<PaintBucket size={14} />}>
|
||||
{t('局部重绘-提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 'ZOOM':
|
||||
return (
|
||||
<Tag color='teal' size='large' shape='circle'>
|
||||
<Tag color='teal' size='large' shape='circle' prefixIcon={<Focus size={14} />}>
|
||||
{t('变焦')}
|
||||
</Tag>
|
||||
);
|
||||
case 'CUSTOM_ZOOM':
|
||||
return (
|
||||
<Tag color='teal' size='large' shape='circle'>
|
||||
<Tag color='teal' size='large' shape='circle' prefixIcon={<Move3D size={14} />}>
|
||||
{t('自定义变焦-提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 'MODAL':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<Monitor size={14} />}>
|
||||
{t('窗口处理')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SWAP_FACE':
|
||||
return (
|
||||
<Tag color='light-green' size='large' shape='circle'>
|
||||
<Tag color='light-green' size='large' shape='circle' prefixIcon={<UserCheck size={14} />}>
|
||||
{t('换脸')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -261,31 +291,31 @@ const LogsTable = () => {
|
||||
switch (code) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 21:
|
||||
return (
|
||||
<Tag color='lime' size='large' shape='circle'>
|
||||
<Tag color='lime' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('等待中')}
|
||||
</Tag>
|
||||
);
|
||||
case 22:
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Copy size={14} />}>
|
||||
{t('重复提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileX size={14} />}>
|
||||
{t('未提交')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -296,43 +326,43 @@ const LogsTable = () => {
|
||||
switch (type) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('成功')}
|
||||
</Tag>
|
||||
);
|
||||
case 'NOT_START':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
|
||||
{t('未启动')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('队列中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
|
||||
{t('执行中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'FAILURE':
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('失败')}
|
||||
</Tag>
|
||||
);
|
||||
case 'MODAL':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||
{t('窗口等待')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -362,7 +392,7 @@ const LogsTable = () => {
|
||||
const color = durationSec > 60 ? 'red' : 'green';
|
||||
|
||||
return (
|
||||
<Tag color={color} size='large' shape='circle'>
|
||||
<Tag color={color} size='large' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{durationSec} {t('秒')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -398,6 +428,7 @@ const LogsTable = () => {
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
prefixIcon={<Hash size={14} />}
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
@@ -462,7 +493,7 @@ const LogsTable = () => {
|
||||
percent={text ? parseInt(text.replace('%', '')) : 0}
|
||||
showInfo={true}
|
||||
aria-label='drawing progress'
|
||||
style={{ minWidth: '200px' }}
|
||||
style={{ minWidth: '160px' }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -483,6 +514,7 @@ const LogsTable = () => {
|
||||
setModalImageUrl(text);
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查看图片')}
|
||||
</Button>
|
||||
@@ -569,8 +601,7 @@ const LogsTable = () => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
|
||||
const [logType, setLogType] = useState(0);
|
||||
const [logCount, setLogCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
@@ -578,86 +609,93 @@ const LogsTable = () => {
|
||||
// 定义模态框图片URL的状态和更新函数
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
let now = new Date();
|
||||
// 初始化start_timestamp为前一天
|
||||
const [inputs, setInputs] = useState({
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
channel_id: '',
|
||||
mj_id: '',
|
||||
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
|
||||
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||||
});
|
||||
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
|
||||
dateRange: [
|
||||
timestamp2string(now.getTime() / 1000 - 2592000),
|
||||
timestamp2string(now.getTime() / 1000 + 3600)
|
||||
],
|
||||
};
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
const [stat, setStat] = useState({
|
||||
quota: 0,
|
||||
token: 0,
|
||||
});
|
||||
|
||||
const handleInputChange = (value, name) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
|
||||
const setLogsFormat = (logs) => {
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||
logs[i].key = '' + logs[i].id;
|
||||
// 处理时间范围
|
||||
let start_timestamp = timestamp2string(now.getTime() / 1000 - 2592000);
|
||||
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
||||
|
||||
if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
|
||||
start_timestamp = formValues.dateRange[0];
|
||||
end_timestamp = formValues.dateRange[1];
|
||||
}
|
||||
// data.key = '' + data.id
|
||||
setLogs(logs);
|
||||
setLogCount(logs.length + pageSize);
|
||||
// console.log(logCount);
|
||||
|
||||
return {
|
||||
channel_id: formValues.channel_id || '',
|
||||
mj_id: formValues.mj_id || '',
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
|
||||
setLoading(true);
|
||||
const enrichLogs = (items) => {
|
||||
return items.map((log) => ({
|
||||
...log,
|
||||
timestamp2string: timestamp2string(log.created_at),
|
||||
key: '' + log.id,
|
||||
}));
|
||||
};
|
||||
|
||||
let url = '';
|
||||
const syncPageData = (payload) => {
|
||||
const items = enrichLogs(payload.items || []);
|
||||
setLogs(items);
|
||||
setLogCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
};
|
||||
|
||||
const loadLogs = async (page = 1, size = pageSize) => {
|
||||
setLoading(true);
|
||||
const { channel_id, mj_id, start_timestamp, end_timestamp } = getFormValues();
|
||||
let localStartTimestamp = Date.parse(start_timestamp);
|
||||
let localEndTimestamp = Date.parse(end_timestamp);
|
||||
if (isAdminUser) {
|
||||
url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
} else {
|
||||
url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
}
|
||||
const url = isAdminUser
|
||||
? `/api/mj/?p=${page}&page_size=${size}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
|
||||
: `/api/mj/self/?p=${page}&page_size=${size}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setLogsFormat(data);
|
||||
} else {
|
||||
let newLogs = [...logs];
|
||||
newLogs.splice(startIdx * pageSize, data.length, ...data);
|
||||
setLogsFormat(newLogs);
|
||||
}
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pageData = logs.slice(
|
||||
(activePage - 1) * pageSize,
|
||||
activePage * pageSize,
|
||||
);
|
||||
const pageData = logs;
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(logs.length / pageSize) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
loadLogs(page - 1, pageSize).then((r) => { });
|
||||
}
|
||||
loadLogs(page, pageSize).then();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('mj-page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
await loadLogs(0, size);
|
||||
await loadLogs(1, size);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
// setLoading(true);
|
||||
setActivePage(1);
|
||||
await loadLogs(0, pageSize);
|
||||
await loadLogs(1, pageSize);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
@@ -672,8 +710,8 @@ const LogsTable = () => {
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(0, localPageSize).then();
|
||||
}, [logType]);
|
||||
loadLogs(1, localPageSize).then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
|
||||
@@ -788,70 +826,93 @@ const LogsTable = () => {
|
||||
<Divider margin="12px" />
|
||||
|
||||
{/* 搜索表单区域 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 时间选择器 */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
value={[start_timestamp, end_timestamp]}
|
||||
type='dateTimeRange'
|
||||
onChange={(value) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
handleInputChange(value[0], 'start_timestamp');
|
||||
handleInputChange(value[1], 'end_timestamp');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={refresh}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 时间选择器 */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<Form.DatePicker
|
||||
field='dateRange'
|
||||
className="w-full"
|
||||
type='dateTimeRange'
|
||||
placeholder={[t('开始时间'), t('结束时间')]}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
value={mj_id}
|
||||
onChange={(value) => handleInputChange(value, 'mj_id')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Input
|
||||
{/* 任务 ID */}
|
||||
<Form.Input
|
||||
field='mj_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
value={channel_id}
|
||||
onChange={(value) => handleInputChange(value, 'channel_id')}
|
||||
placeholder={t('任务 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Form.Input
|
||||
field='channel_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
}
|
||||
shadows='always'
|
||||
@@ -859,12 +920,20 @@ const LogsTable = () => {
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
dataSource={pageData}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
@@ -877,9 +946,7 @@ const LogsTable = () => {
|
||||
total: logCount,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -17,14 +17,19 @@ import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
Dropdown,
|
||||
Empty
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IconVerify,
|
||||
IconHelpCircle,
|
||||
IconSearch,
|
||||
IconCopy,
|
||||
IconInfoCircle,
|
||||
IconLayers,
|
||||
IconLayers
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
@@ -489,6 +494,14 @@ const ModelPricing = () => {
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
className="custom-table"
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
pageSize: pageSize,
|
||||
|
||||
@@ -8,20 +8,33 @@ import {
|
||||
renderQuota
|
||||
} from '../../helpers';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Minus,
|
||||
HelpCircle,
|
||||
Coins
|
||||
} from 'lucide-react';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Empty,
|
||||
Form,
|
||||
Modal,
|
||||
Popover,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Typography
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IconPlus,
|
||||
IconCopy,
|
||||
@@ -31,7 +44,7 @@ import {
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
IconMore
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditRedemption from '../../pages/Redemption/EditRedemption';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -49,25 +62,25 @@ const RedemptionsTable = () => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('未使用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Minus size={14} />}>
|
||||
{t('已使用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='black' size='large' shape='circle'>
|
||||
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -95,7 +108,13 @@ const RedemptionsTable = () => {
|
||||
title: t('额度'),
|
||||
dataIndex: 'quota',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderQuota(parseInt(text))}</div>;
|
||||
return (
|
||||
<div>
|
||||
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -223,7 +242,6 @@ const RedemptionsTable = () => {
|
||||
const [redemptions, setRedemptions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
|
||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||
@@ -233,6 +251,22 @@ const RedemptionsTable = () => {
|
||||
});
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
};
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
};
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
@@ -340,8 +374,14 @@ const RedemptionsTable = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const searchRedemptions = async (keyword, page, pageSize) => {
|
||||
if (searchKeyword === '') {
|
||||
const searchRedemptions = async (keyword = null, page, pageSize) => {
|
||||
// 如果没有传递keyword参数,从表单获取值
|
||||
if (keyword === null) {
|
||||
const formValues = getFormValues();
|
||||
keyword = formValues.searchKeyword;
|
||||
}
|
||||
|
||||
if (keyword === '') {
|
||||
await loadRedemptions(page, pageSize);
|
||||
return;
|
||||
}
|
||||
@@ -361,10 +401,6 @@ const RedemptionsTable = () => {
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (value) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortRedemption = (key) => {
|
||||
if (redemptions.length === 0) return;
|
||||
setLoading(true);
|
||||
@@ -381,6 +417,7 @@ const RedemptionsTable = () => {
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
const { searchKeyword } = getFormValues();
|
||||
if (searchKeyword === '') {
|
||||
loadRedemptions(page, pageSize).then();
|
||||
} else {
|
||||
@@ -457,28 +494,59 @@ const RedemptionsTable = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('关键字(id或者名称)')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={() => {
|
||||
setActivePage(1);
|
||||
searchRedemptions(null, 1, pageSize);
|
||||
}}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="w-full md:w-auto order-1 md:order-2"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('关键字(id或者名称)')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
setActivePage(1);
|
||||
loadRedemptions(1, pageSize);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
searchRedemptions(searchKeyword, 1, pageSize).then();
|
||||
}}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -517,6 +585,7 @@ const RedemptionsTable = () => {
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
const { searchKeyword } = getFormValues();
|
||||
if (searchKeyword === '') {
|
||||
loadRedemptions(1, size).then();
|
||||
} else {
|
||||
@@ -528,6 +597,14 @@ const RedemptionsTable = () => {
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
></Table>
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Music,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Pause,
|
||||
Clock,
|
||||
Play,
|
||||
XCircle,
|
||||
Loader,
|
||||
List,
|
||||
Hash
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
timestamp2string
|
||||
} from '../../helpers';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Input,
|
||||
Empty,
|
||||
Form,
|
||||
Layout,
|
||||
Modal,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Typography
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconSetting
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -97,7 +114,7 @@ function renderDuration(submit_time, finishTime) {
|
||||
|
||||
// 返回带有样式的颜色标签
|
||||
return (
|
||||
<Tag color={color} size='large'>
|
||||
<Tag color={color} size='large' prefixIcon={<Clock size={14} />}>
|
||||
{durationSec} 秒
|
||||
</Tag>
|
||||
);
|
||||
@@ -188,19 +205,19 @@ const LogsTable = () => {
|
||||
switch (type) {
|
||||
case 'MUSIC':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Music size={14} />}>
|
||||
{t('生成音乐')}
|
||||
</Tag>
|
||||
);
|
||||
case 'LYRICS':
|
||||
return (
|
||||
<Tag color='pink' size='large' shape='circle'>
|
||||
<Tag color='pink' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
|
||||
{t('生成歌词')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -211,13 +228,13 @@ const LogsTable = () => {
|
||||
switch (type) {
|
||||
case 'suno':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
|
||||
Suno
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -228,55 +245,55 @@ const LogsTable = () => {
|
||||
switch (type) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('成功')}
|
||||
</Tag>
|
||||
);
|
||||
case 'NOT_START':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
|
||||
{t('未启动')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('队列中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<Play size={14} />}>
|
||||
{t('执行中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'FAILURE':
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('失败')}
|
||||
</Tag>
|
||||
);
|
||||
case 'QUEUED':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<List size={14} />}>
|
||||
{t('排队中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UNKNOWN':
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
case '':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
|
||||
{t('正在提交')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -321,6 +338,7 @@ const LogsTable = () => {
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
prefixIcon={<Hash size={14} />}
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
@@ -395,7 +413,7 @@ const LogsTable = () => {
|
||||
percent={text ? parseInt(text.replace('%', '')) : 0}
|
||||
showInfo={true}
|
||||
aria-label='task progress'
|
||||
style={{ minWidth: '200px' }}
|
||||
style={{ minWidth: '160px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -433,87 +451,102 @@ const LogsTable = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
|
||||
const [logType] = useState(0);
|
||||
const [logCount, setLogCount] = useState(0);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(1, localPageSize).then();
|
||||
}, []);
|
||||
|
||||
let now = new Date();
|
||||
// 初始化start_timestamp为前一天
|
||||
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const [inputs, setInputs] = useState({
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
channel_id: '',
|
||||
task_id: '',
|
||||
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
|
||||
end_timestamp: '',
|
||||
});
|
||||
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
|
||||
|
||||
const handleInputChange = (value, name) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
dateRange: [
|
||||
timestamp2string(zeroNow.getTime() / 1000),
|
||||
timestamp2string(now.getTime() / 1000 + 3600)
|
||||
],
|
||||
};
|
||||
|
||||
const setLogsFormat = (logs) => {
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||
logs[i].key = '' + logs[i].id;
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
|
||||
// 处理时间范围
|
||||
let start_timestamp = timestamp2string(zeroNow.getTime() / 1000);
|
||||
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
||||
|
||||
if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
|
||||
start_timestamp = formValues.dateRange[0];
|
||||
end_timestamp = formValues.dateRange[1];
|
||||
}
|
||||
// data.key = '' + data.id
|
||||
setLogs(logs);
|
||||
setLogCount(logs.length + ITEMS_PER_PAGE);
|
||||
// console.log(logCount);
|
||||
|
||||
return {
|
||||
channel_id: formValues.channel_id || '',
|
||||
task_id: formValues.task_id || '',
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
|
||||
setLoading(true);
|
||||
const enrichLogs = (items) => {
|
||||
return items.map((log) => ({
|
||||
...log,
|
||||
timestamp2string: timestamp2string(log.created_at),
|
||||
key: '' + log.id,
|
||||
}));
|
||||
};
|
||||
|
||||
let url = '';
|
||||
const syncPageData = (payload) => {
|
||||
const items = enrichLogs(payload.items || []);
|
||||
setLogs(items);
|
||||
setLogCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
};
|
||||
|
||||
const loadLogs = async (page = 1, size = pageSize) => {
|
||||
setLoading(true);
|
||||
const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues();
|
||||
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
|
||||
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
|
||||
if (isAdminUser) {
|
||||
url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
} else {
|
||||
url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
}
|
||||
let url = isAdminUser
|
||||
? `/api/task/?p=${page}&page_size=${size}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
|
||||
: `/api/task/self?p=${page}&page_size=${size}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
let { success, message, data } = res.data;
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setLogsFormat(data);
|
||||
} else {
|
||||
let newLogs = [...logs];
|
||||
newLogs.splice(startIdx * pageSize, data.length, ...data);
|
||||
setLogsFormat(newLogs);
|
||||
}
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pageData = logs.slice(
|
||||
(activePage - 1) * pageSize,
|
||||
activePage * pageSize,
|
||||
);
|
||||
const pageData = logs;
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(logs.length / pageSize) + 1) {
|
||||
loadLogs(page - 1, pageSize).then((r) => { });
|
||||
}
|
||||
loadLogs(page, pageSize).then();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('task-page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
await loadLogs(0, size);
|
||||
await loadLogs(1, size);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setActivePage(1);
|
||||
await loadLogs(0, pageSize);
|
||||
await loadLogs(1, pageSize);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
@@ -524,12 +557,6 @@ const LogsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(0, localPageSize).then();
|
||||
}, [logType]);
|
||||
|
||||
// 列选择器模态框
|
||||
const renderColumnSelector = () => {
|
||||
return (
|
||||
@@ -628,70 +655,93 @@ const LogsTable = () => {
|
||||
<Divider margin="12px" />
|
||||
|
||||
{/* 搜索表单区域 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 时间选择器 */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
value={[start_timestamp, end_timestamp]}
|
||||
type='dateTimeRange'
|
||||
onChange={(value) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
handleInputChange(value[0], 'start_timestamp');
|
||||
handleInputChange(value[1], 'end_timestamp');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={refresh}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 时间选择器 */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<Form.DatePicker
|
||||
field='dateRange'
|
||||
className="w-full"
|
||||
type='dateTimeRange'
|
||||
placeholder={[t('开始时间'), t('结束时间')]}
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
value={task_id}
|
||||
onChange={(value) => handleInputChange(value, 'task_id')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Input
|
||||
{/* 任务 ID */}
|
||||
<Form.Input
|
||||
field='task_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
value={channel_id}
|
||||
onChange={(value) => handleInputChange(value, 'channel_id')}
|
||||
placeholder={t('任务 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Form.Input
|
||||
field='channel_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
}
|
||||
shadows='always'
|
||||
@@ -699,12 +749,20 @@ const LogsTable = () => {
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
dataSource={pageData}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
@@ -717,9 +775,7 @@ const LogsTable = () => {
|
||||
total: logCount,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroup,
|
||||
renderQuota
|
||||
renderQuota,
|
||||
getQuotaPerUnit
|
||||
} from '../../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
@@ -14,13 +15,29 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Modal,
|
||||
Space,
|
||||
SplitButtonGroup,
|
||||
Table,
|
||||
Tag,
|
||||
Input,
|
||||
Tag
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
Shield,
|
||||
XCircle,
|
||||
Clock,
|
||||
Gauge,
|
||||
HelpCircle,
|
||||
Infinity,
|
||||
Coins
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
IconPlus,
|
||||
@@ -32,7 +49,7 @@ import {
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
IconMore
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditToken from '../../pages/Token/EditToken';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -49,38 +66,38 @@ const TokensTable = () => {
|
||||
case 1:
|
||||
if (model_limits_enabled) {
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
|
||||
{t('已启用:限制模型')}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Gauge size={14} />}>
|
||||
{t('已耗尽')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='black' size='large' shape='circle'>
|
||||
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -111,21 +128,45 @@ const TokensTable = () => {
|
||||
title: t('已用额度'),
|
||||
dataIndex: 'used_quota',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderQuota(parseInt(text))}</div>;
|
||||
return (
|
||||
<div>
|
||||
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('剩余额度'),
|
||||
dataIndex: 'remain_quota',
|
||||
render: (text, record, index) => {
|
||||
const getQuotaColor = (quotaValue) => {
|
||||
const quotaPerUnit = getQuotaPerUnit();
|
||||
const dollarAmount = quotaValue / quotaPerUnit;
|
||||
|
||||
if (dollarAmount <= 0) {
|
||||
return 'red';
|
||||
} else if (dollarAmount <= 100) {
|
||||
return 'yellow';
|
||||
} else {
|
||||
return 'green';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{record.unlimited_quota ? (
|
||||
<Tag size={'large'} color={'white'} shape='circle'>
|
||||
<Tag size={'large'} color={'white'} shape='circle' prefixIcon={<Infinity size={14} />}>
|
||||
{t('无限制')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag size={'large'} color={'light-blue'} shape='circle'>
|
||||
<Tag
|
||||
size={'large'}
|
||||
color={getQuotaColor(parseInt(text))}
|
||||
shape='circle'
|
||||
prefixIcon={<Coins size={14} />}
|
||||
>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
)}
|
||||
@@ -335,14 +376,29 @@ const TokensTable = () => {
|
||||
const [tokenCount, setTokenCount] = useState(pageSize);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searchToken, setSearchToken] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [chats, setChats] = useState([]);
|
||||
const [editingToken, setEditingToken] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchToken: '',
|
||||
};
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
searchToken: formValues.searchToken || '',
|
||||
};
|
||||
};
|
||||
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
@@ -352,31 +408,20 @@ const TokensTable = () => {
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const setTokensFormat = (tokens) => {
|
||||
setTokens(tokens);
|
||||
if (tokens.length >= pageSize) {
|
||||
setTokenCount(tokens.length + pageSize);
|
||||
} else {
|
||||
setTokenCount(tokens.length);
|
||||
}
|
||||
// 将后端返回的数据写入状态
|
||||
const syncPageData = (payload) => {
|
||||
setTokens(payload.items || []);
|
||||
setTokenCount(payload.total || 0);
|
||||
setActivePage(payload.page || 1);
|
||||
setPageSize(payload.page_size || pageSize);
|
||||
};
|
||||
|
||||
let pageData = tokens.slice(
|
||||
(activePage - 1) * pageSize,
|
||||
activePage * pageSize,
|
||||
);
|
||||
const loadTokens = async (startIdx) => {
|
||||
const loadTokens = async (page = 1, size = pageSize) => {
|
||||
setLoading(true);
|
||||
const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
|
||||
const res = await API.get(`/api/token/?p=${page}&size=${size}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setTokensFormat(data);
|
||||
} else {
|
||||
let newTokens = [...tokens];
|
||||
newTokens.splice(startIdx * pageSize, data.length, ...data);
|
||||
setTokensFormat(newTokens);
|
||||
}
|
||||
syncPageData(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -384,7 +429,7 @@ const TokensTable = () => {
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await loadTokens(activePage - 1);
|
||||
await loadTokens(1);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
@@ -416,10 +461,8 @@ const TokensTable = () => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadTokens(0)
|
||||
loadTokens(1)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
@@ -433,7 +476,7 @@ const TokensTable = () => {
|
||||
|
||||
if (idx > -1) {
|
||||
newDataSource.splice(idx, 1);
|
||||
setTokensFormat(newDataSource);
|
||||
setTokens(newDataSource);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -464,7 +507,7 @@ const TokensTable = () => {
|
||||
} else {
|
||||
record.status = token.status;
|
||||
}
|
||||
setTokensFormat(newTokens);
|
||||
setTokens(newTokens);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -472,9 +515,9 @@ const TokensTable = () => {
|
||||
};
|
||||
|
||||
const searchTokens = async () => {
|
||||
const { searchKeyword, searchToken } = getFormValues();
|
||||
if (searchKeyword === '' && searchToken === '') {
|
||||
await loadTokens(0);
|
||||
setActivePage(1);
|
||||
await loadTokens(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
@@ -483,7 +526,8 @@ const TokensTable = () => {
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setTokensFormat(data);
|
||||
setTokens(data);
|
||||
setTokenCount(data.length);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
@@ -491,14 +535,6 @@ const TokensTable = () => {
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (value) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const handleSearchTokenChange = async (value) => {
|
||||
setSearchToken(value.trim());
|
||||
};
|
||||
|
||||
const sortToken = (key) => {
|
||||
if (tokens.length === 0) return;
|
||||
setLoading(true);
|
||||
@@ -514,10 +550,12 @@ const TokensTable = () => {
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(tokens.length / pageSize) + 1) {
|
||||
loadTokens(page - 1).then((r) => { });
|
||||
}
|
||||
loadTokens(page, pageSize).then();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
setPageSize(size);
|
||||
await loadTokens(1, size);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
@@ -580,36 +618,65 @@ const TokensTable = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-56">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索关键字')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={searchTokens}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="w-full md:w-auto order-1 md:order-2"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索关键字')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchToken"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('密钥')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
searchTokens();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full md:w-56">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('密钥')}
|
||||
value={searchToken}
|
||||
onChange={handleSearchTokenChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={searchTokens}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -631,7 +698,7 @@ const TokensTable = () => {
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pageData}
|
||||
dataSource={tokens}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
@@ -643,17 +710,22 @@ const TokensTable = () => {
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: tokens.length,
|
||||
total: tokenCount,
|
||||
}),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
},
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
></Table>
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
|
||||
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
Crown,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Minus,
|
||||
Coins,
|
||||
Activity,
|
||||
Users,
|
||||
DollarSign,
|
||||
UserPlus
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Empty,
|
||||
Form,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Typography
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -23,7 +42,7 @@ import {
|
||||
IconMore,
|
||||
IconUserAdd,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconArrowDown
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import AddUser from '../../pages/User/AddUser';
|
||||
@@ -39,25 +58,25 @@ const UsersTable = () => {
|
||||
switch (role) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag size='large' color='blue' shape='circle'>
|
||||
<Tag size='large' color='blue' shape='circle' prefixIcon={<User size={14} />}>
|
||||
{t('普通用户')}
|
||||
</Tag>
|
||||
);
|
||||
case 10:
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
|
||||
{t('管理员')}
|
||||
</Tag>
|
||||
);
|
||||
case 100:
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Crown size={14} />}>
|
||||
{t('超级管理员')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知身份')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -67,16 +86,16 @@ const UsersTable = () => {
|
||||
const renderStatus = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
|
||||
return <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
|
||||
case 2:
|
||||
return (
|
||||
<Tag size='large' color='red' shape='circle'>
|
||||
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已封禁')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag size='large' color='grey' shape='circle'>
|
||||
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -106,13 +125,13 @@ const UsersTable = () => {
|
||||
return (
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
|
||||
{t('剩余')}: {renderQuota(record.quota)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
|
||||
{t('已用')}: {renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
|
||||
{t('调用')}: {renderNumber(record.request_count)}
|
||||
</Tag>
|
||||
</Space>
|
||||
@@ -127,13 +146,13 @@ const UsersTable = () => {
|
||||
return (
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
|
||||
{t('邀请')}: {renderNumber(record.aff_count)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
|
||||
{t('收益')}: {renderQuota(record.aff_history_quota)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
|
||||
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
|
||||
</Tag>
|
||||
</Space>
|
||||
@@ -155,7 +174,7 @@ const UsersTable = () => {
|
||||
return (
|
||||
<div>
|
||||
{record.DeletedAt !== null ? (
|
||||
<Tag color='red' shape='circle'>{t('已注销')}</Tag>
|
||||
<Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>
|
||||
) : (
|
||||
renderStatus(text)
|
||||
)}
|
||||
@@ -285,9 +304,7 @@ const UsersTable = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchGroup, setSearchGroup] = useState('');
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
@@ -296,6 +313,24 @@ const UsersTable = () => {
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchGroup: '',
|
||||
};
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
searchKeyword: formValues.searchKeyword || '',
|
||||
searchGroup: formValues.searchGroup || '',
|
||||
};
|
||||
};
|
||||
|
||||
const removeRecord = (key) => {
|
||||
let newDataSource = [...users];
|
||||
if (key != null) {
|
||||
@@ -363,9 +398,16 @@ const UsersTable = () => {
|
||||
const searchUsers = async (
|
||||
startIdx,
|
||||
pageSize,
|
||||
searchKeyword,
|
||||
searchGroup,
|
||||
searchKeyword = null,
|
||||
searchGroup = null,
|
||||
) => {
|
||||
// 如果没有传递参数,从表单获取值
|
||||
if (searchKeyword === null || searchGroup === null) {
|
||||
const formValues = getFormValues();
|
||||
searchKeyword = formValues.searchKeyword;
|
||||
searchGroup = formValues.searchGroup;
|
||||
}
|
||||
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadUsers(startIdx, pageSize);
|
||||
@@ -387,12 +429,9 @@ const UsersTable = () => {
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (value) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
const { searchKeyword, searchGroup } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
loadUsers(page, pageSize).then();
|
||||
} else {
|
||||
@@ -413,10 +452,11 @@ const UsersTable = () => {
|
||||
|
||||
const refresh = async () => {
|
||||
setActivePage(1);
|
||||
if (searchKeyword === '') {
|
||||
await loadUsers(activePage, pageSize);
|
||||
const { searchKeyword, searchGroup } = getFormValues();
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
await loadUsers(1, pageSize);
|
||||
} else {
|
||||
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
|
||||
await searchUsers(1, pageSize, searchKeyword, searchGroup);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -488,41 +528,76 @@ const UsersTable = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={() => {
|
||||
setActivePage(1);
|
||||
searchUsers(1, pageSize);
|
||||
}}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="w-full md:w-auto order-1 md:order-2"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Form.Select
|
||||
field="searchGroup"
|
||||
placeholder={t('选择分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(value) => {
|
||||
// 分组变化时自动搜索
|
||||
setTimeout(() => {
|
||||
setActivePage(1);
|
||||
searchUsers(1, pageSize);
|
||||
}, 100);
|
||||
}}
|
||||
className="!rounded-full w-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
setActivePage(1);
|
||||
loadUsers(1, pageSize);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Select
|
||||
placeholder={t('选择分组')}
|
||||
optionList={groupOptions}
|
||||
value={searchGroup}
|
||||
onChange={(value) => {
|
||||
setSearchGroup(value);
|
||||
searchUsers(activePage, pageSize, searchKeyword, value);
|
||||
}}
|
||||
className="!rounded-full w-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
|
||||
}}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -570,6 +645,14 @@ const UsersTable = () => {
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={handleRow}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,36 @@ export let API = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
function patchAPIInstance(instance) {
|
||||
const originalGet = instance.get.bind(instance);
|
||||
const inFlightGetRequests = new Map();
|
||||
|
||||
const genKey = (url, config = {}) => {
|
||||
const params = config.params ? JSON.stringify(config.params) : '{}';
|
||||
return `${url}?${params}`;
|
||||
};
|
||||
|
||||
instance.get = (url, config = {}) => {
|
||||
if (config?.disableDuplicate) {
|
||||
return originalGet(url, config);
|
||||
}
|
||||
|
||||
const key = genKey(url, config);
|
||||
if (inFlightGetRequests.has(key)) {
|
||||
return inFlightGetRequests.get(key);
|
||||
}
|
||||
|
||||
const reqPromise = originalGet(url, config).finally(() => {
|
||||
inFlightGetRequests.delete(key);
|
||||
});
|
||||
|
||||
inFlightGetRequests.set(key, reqPromise);
|
||||
return reqPromise;
|
||||
};
|
||||
}
|
||||
|
||||
patchAPIInstance(API);
|
||||
|
||||
export function updateAPI() {
|
||||
API = axios.create({
|
||||
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
|
||||
@@ -22,6 +52,8 @@ export function updateAPI() {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
|
||||
patchAPIInstance(API);
|
||||
}
|
||||
|
||||
API.interceptors.response.use(
|
||||
|
||||
@@ -24,6 +24,13 @@ import {
|
||||
XAI,
|
||||
Ollama,
|
||||
Doubao,
|
||||
Suno,
|
||||
Xinference,
|
||||
OpenRouter,
|
||||
Dify,
|
||||
Coze,
|
||||
SiliconCloud,
|
||||
FastGPT,
|
||||
} from '@lobehub/icons';
|
||||
|
||||
import {
|
||||
@@ -308,6 +315,87 @@ export const getModelCategories = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* 根据渠道类型返回对应的厂商图标
|
||||
* @param {number} channelType - 渠道类型值
|
||||
* @returns {JSX.Element|null} - 对应的厂商图标组件
|
||||
*/
|
||||
export function getChannelIcon(channelType) {
|
||||
const iconSize = 14;
|
||||
|
||||
switch (channelType) {
|
||||
case 1: // OpenAI
|
||||
case 3: // Azure OpenAI
|
||||
return <OpenAI size={iconSize} />;
|
||||
case 2: // Midjourney Proxy
|
||||
case 5: // Midjourney Proxy Plus
|
||||
return <Midjourney size={iconSize} />;
|
||||
case 36: // Suno API
|
||||
return <Suno size={iconSize} />;
|
||||
case 4: // Ollama
|
||||
return <Ollama size={iconSize} />;
|
||||
case 14: // Anthropic Claude
|
||||
case 33: // AWS Claude
|
||||
return <Claude.Color size={iconSize} />;
|
||||
case 41: // Vertex AI
|
||||
return <Gemini.Color size={iconSize} />;
|
||||
case 34: // Cohere
|
||||
return <Cohere.Color size={iconSize} />;
|
||||
case 39: // Cloudflare
|
||||
return <Cloudflare.Color size={iconSize} />;
|
||||
case 43: // DeepSeek
|
||||
return <DeepSeek.Color size={iconSize} />;
|
||||
case 15: // 百度文心千帆
|
||||
case 46: // 百度文心千帆V2
|
||||
return <Wenxin.Color size={iconSize} />;
|
||||
case 17: // 阿里通义千问
|
||||
return <Qwen.Color size={iconSize} />;
|
||||
case 18: // 讯飞星火认知
|
||||
return <Spark.Color size={iconSize} />;
|
||||
case 16: // 智谱 ChatGLM
|
||||
case 26: // 智谱 GLM-4V
|
||||
return <Zhipu.Color size={iconSize} />;
|
||||
case 24: // Google Gemini
|
||||
case 11: // Google PaLM2
|
||||
return <Gemini.Color size={iconSize} />;
|
||||
case 47: // Xinference
|
||||
return <Xinference.Color size={iconSize} />;
|
||||
case 25: // Moonshot
|
||||
return <Moonshot size={iconSize} />;
|
||||
case 20: // OpenRouter
|
||||
return <OpenRouter size={iconSize} />;
|
||||
case 19: // 360 智脑
|
||||
return <Ai360.Color size={iconSize} />;
|
||||
case 23: // 腾讯混元
|
||||
return <Hunyuan.Color size={iconSize} />;
|
||||
case 31: // 零一万物
|
||||
return <Yi.Color size={iconSize} />;
|
||||
case 35: // MiniMax
|
||||
return <Minimax.Color size={iconSize} />;
|
||||
case 37: // Dify
|
||||
return <Dify.Color size={iconSize} />;
|
||||
case 38: // Jina
|
||||
return <Jina size={iconSize} />;
|
||||
case 40: // SiliconCloud
|
||||
return <SiliconCloud.Color size={iconSize} />;
|
||||
case 42: // Mistral AI
|
||||
return <Mistral.Color size={iconSize} />;
|
||||
case 45: // 字节火山方舟、豆包通用
|
||||
return <Doubao.Color size={iconSize} />;
|
||||
case 48: // xAI
|
||||
return <XAI size={iconSize} />;
|
||||
case 49: // Coze
|
||||
return <Coze size={iconSize} />;
|
||||
case 8: // 自定义渠道
|
||||
case 22: // 知识库:FastGPT
|
||||
return <FastGPT.Color size={iconSize} />;
|
||||
case 21: // 知识库:AI Proxy
|
||||
case 44: // 嵌入模型:MokaAI M3E
|
||||
default:
|
||||
return null; // 未知类型或自定义渠道不显示图标
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色列表
|
||||
const colors = [
|
||||
'amber',
|
||||
@@ -519,7 +607,7 @@ export function renderGroup(group) {
|
||||
showSuccess(i18next.t('已复制:') + group);
|
||||
} else {
|
||||
Modal.error({
|
||||
title: t('无法复制到剪贴板,请手动复制'),
|
||||
title: i18next.t('无法复制到剪贴板,请手动复制'),
|
||||
content: group,
|
||||
});
|
||||
}
|
||||
@@ -764,7 +852,7 @@ export function renderQuotaWithAmount(amount) {
|
||||
if (displayInCurrency) {
|
||||
return '$' + amount;
|
||||
} else {
|
||||
return renderUnitWithQuota(amount);
|
||||
return renderNumber(renderUnitWithQuota(amount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,6 +867,30 @@ export function renderQuota(quota, digits = 2) {
|
||||
return renderNumber(quota);
|
||||
}
|
||||
|
||||
function isValidGroupRatio(ratio) {
|
||||
return Number.isFinite(ratio) && ratio !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get effective ratio and label
|
||||
* @param {number} groupRatio - The default group ratio
|
||||
* @param {number} user_group_ratio - The user-specific group ratio
|
||||
* @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
|
||||
*/
|
||||
function getEffectiveRatio(groupRatio, user_group_ratio) {
|
||||
const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
|
||||
const ratioLabel = useUserGroupRatio
|
||||
? i18next.t('专属倍率')
|
||||
: i18next.t('分组倍率');
|
||||
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
||||
|
||||
return {
|
||||
ratio: effectiveRatio,
|
||||
label: ratioLabel,
|
||||
useUserGroupRatio: useUserGroupRatio
|
||||
};
|
||||
}
|
||||
|
||||
export function renderModelPrice(
|
||||
inputTokens,
|
||||
completionTokens,
|
||||
@@ -786,6 +898,7 @@ export function renderModelPrice(
|
||||
modelPrice = -1,
|
||||
completionRatio,
|
||||
groupRatio,
|
||||
user_group_ratio,
|
||||
cacheTokens = 0,
|
||||
cacheRatio = 1.0,
|
||||
image = false,
|
||||
@@ -801,13 +914,17 @@ export function renderModelPrice(
|
||||
audioInputTokens = 0,
|
||||
audioInputPrice = 0,
|
||||
) {
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * 分组倍率:{{ratio}} = ${{total}}',
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -944,11 +1061,12 @@ export function renderModelPrice(
|
||||
|
||||
// 构建输出部分描述
|
||||
const outputDesc = i18next.t(
|
||||
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
|
||||
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -956,21 +1074,23 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
@@ -1002,16 +1122,12 @@ export function renderLogContent(
|
||||
user_group_ratio,
|
||||
image = false,
|
||||
imageRatio = 1.0,
|
||||
useUserGroupRatio = undefined,
|
||||
webSearch = false,
|
||||
webSearchCallCount = 0,
|
||||
fileSearch = false,
|
||||
fileSearchCallCount = 0,
|
||||
) {
|
||||
const ratioLabel = useUserGroupRatio
|
||||
? i18next.t('专属倍率')
|
||||
: i18next.t('分组倍率');
|
||||
const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
||||
const { ratio, label: ratioLabel, useUserGroupRatio: useUserGroupRatio } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
@@ -1060,14 +1176,18 @@ export function renderModelPriceSimple(
|
||||
modelRatio,
|
||||
modelPrice = -1,
|
||||
groupRatio,
|
||||
user_group_ratio,
|
||||
cacheTokens = 0,
|
||||
cacheRatio = 1.0,
|
||||
image = false,
|
||||
imageRatio = 1.0,
|
||||
) {
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('价格:${{price}} * 分组:{{ratio}}', {
|
||||
return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
|
||||
price: modelPrice,
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
});
|
||||
} else {
|
||||
@@ -1102,8 +1222,9 @@ export function renderModelPriceSimple(
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return i18next.t('模型: {{ratio}} * 分组: {{groupRatio}}', {
|
||||
return i18next.t('模型: {{ratio}} * {{ratioType}}:{{groupRatio}}', {
|
||||
ratio: modelRatio,
|
||||
ratioType: ratioLabel,
|
||||
groupRatio: groupRatio,
|
||||
});
|
||||
}
|
||||
@@ -1121,17 +1242,21 @@ export function renderAudioModelPrice(
|
||||
audioRatio,
|
||||
audioCompletionRatio,
|
||||
groupRatio,
|
||||
user_group_ratio,
|
||||
cacheTokens = 0,
|
||||
cacheRatio = 1.0,
|
||||
) {
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
// 1 ratio = $0.002 / 1K tokens
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * 分组倍率:{{ratio}} = ${{total}}',
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -1285,12 +1410,14 @@ export function renderClaudeModelPrice(
|
||||
modelPrice = -1,
|
||||
completionRatio,
|
||||
groupRatio,
|
||||
user_group_ratio,
|
||||
cacheTokens = 0,
|
||||
cacheRatio = 1.0,
|
||||
cacheCreationTokens = 0,
|
||||
cacheCreationRatio = 1.0,
|
||||
) {
|
||||
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
@@ -1372,7 +1499,7 @@ export function renderClaudeModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0 || cacheCreationTokens > 0
|
||||
? i18next.t(
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
||||
'提示 {{nonCacheInput}} 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,
|
||||
@@ -1385,17 +1512,19 @@ export function renderClaudeModelPrice(
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
||||
'提示 {{input}} 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),
|
||||
},
|
||||
)}
|
||||
@@ -1412,10 +1541,12 @@ export function renderClaudeLogContent(
|
||||
completionRatio,
|
||||
modelPrice = -1,
|
||||
groupRatio,
|
||||
user_group_ratio,
|
||||
cacheRatio = 1.0,
|
||||
cacheCreationRatio = 1.0,
|
||||
) {
|
||||
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
@@ -1442,12 +1573,14 @@ export function renderClaudeModelPriceSimple(
|
||||
modelRatio,
|
||||
modelPrice = -1,
|
||||
groupRatio,
|
||||
user_group_ratio,
|
||||
cacheTokens = 0,
|
||||
cacheRatio = 1.0,
|
||||
cacheCreationTokens = 0,
|
||||
cacheCreationRatio = 1.0,
|
||||
) {
|
||||
const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组');
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('价格:${{price}} * {{ratioType}}:{{ratio}}', {
|
||||
|
||||
@@ -6,14 +6,13 @@ import { API } from './api';
|
||||
*/
|
||||
export async function fetchTokenKeys() {
|
||||
try {
|
||||
const response = await API.get('/api/token/?p=0&size=100');
|
||||
const response = await API.get('/api/token/?p=1&size=10');
|
||||
const { success, data } = response.data;
|
||||
if (success) {
|
||||
const activeTokens = data.filter((token) => token.status === 1);
|
||||
return activeTokens.map((token) => token.key);
|
||||
} else {
|
||||
throw new Error('Failed to fetch token keys');
|
||||
}
|
||||
if (!success) throw new Error('Failed to fetch token keys');
|
||||
|
||||
const tokenItems = Array.isArray(data) ? data : data.items || [];
|
||||
const activeTokens = tokenItems.filter((token) => token.status === 1);
|
||||
return activeTokens.map((token) => token.key);
|
||||
} catch (error) {
|
||||
console.error('Error fetching token keys:', error);
|
||||
return [];
|
||||
|
||||
@@ -446,3 +446,66 @@ export const getLastAssistantMessage = (messages) => {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 计算相对时间(几天前、几小时前等)
|
||||
export const getRelativeTime = (publishDate) => {
|
||||
if (!publishDate) return '';
|
||||
|
||||
const now = new Date();
|
||||
const pubDate = new Date(publishDate);
|
||||
|
||||
// 如果日期无效,返回原始字符串
|
||||
if (isNaN(pubDate.getTime())) return publishDate;
|
||||
|
||||
const diffMs = now.getTime() - pubDate.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
// 如果是未来时间,显示具体日期
|
||||
if (diffMs < 0) {
|
||||
return formatDateString(pubDate);
|
||||
}
|
||||
|
||||
// 根据时间差返回相应的描述
|
||||
if (diffSeconds < 60) {
|
||||
return '刚刚';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes} 分钟前`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} 小时前`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} 天前`;
|
||||
} else if (diffWeeks < 4) {
|
||||
return `${diffWeeks} 周前`;
|
||||
} else if (diffMonths < 12) {
|
||||
return `${diffMonths} 个月前`;
|
||||
} else if (diffYears < 2) {
|
||||
return '1 年前';
|
||||
} else {
|
||||
// 超过2年显示具体日期
|
||||
return formatDateString(pubDate);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化日期字符串
|
||||
export const formatDateString = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// 格式化日期时间字符串(包含时间)
|
||||
export const formatDateTimeString = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SSE } from 'sse';
|
||||
import { SSE } from 'sse.js';
|
||||
import {
|
||||
API_ENDPOINTS,
|
||||
MESSAGE_STATUS,
|
||||
@@ -246,9 +246,11 @@ export const useApiRequest = (
|
||||
|
||||
let responseData = '';
|
||||
let hasReceivedFirstResponse = false;
|
||||
let isStreamComplete = false; // 添加标志位跟踪流是否正常完成
|
||||
|
||||
source.addEventListener('message', (e) => {
|
||||
if (e.data === '[DONE]') {
|
||||
isStreamComplete = true; // 标记流正常完成
|
||||
source.close();
|
||||
sseSourceRef.current = null;
|
||||
setDebugData(prev => ({ ...prev, response: responseData }));
|
||||
@@ -290,26 +292,30 @@ export const useApiRequest = (
|
||||
});
|
||||
|
||||
source.addEventListener('error', (e) => {
|
||||
console.error('SSE Error:', e);
|
||||
const errorMessage = e.data || t('请求发生错误');
|
||||
// 只有在流没有正常完成且连接状态异常时才处理错误
|
||||
if (!isStreamComplete && source.readyState !== 2) {
|
||||
console.error('SSE Error:', e);
|
||||
const errorMessage = e.data || t('请求发生错误');
|
||||
|
||||
const errorInfo = handleApiError(new Error(errorMessage));
|
||||
errorInfo.readyState = source.readyState;
|
||||
const errorInfo = handleApiError(new Error(errorMessage));
|
||||
errorInfo.readyState = source.readyState;
|
||||
|
||||
setDebugData(prev => ({
|
||||
...prev,
|
||||
response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
setDebugData(prev => ({
|
||||
...prev,
|
||||
response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
streamMessageUpdate(errorMessage, 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
sseSourceRef.current = null;
|
||||
source.close();
|
||||
streamMessageUpdate(errorMessage, 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
sseSourceRef.current = null;
|
||||
source.close();
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener('readystatechange', (e) => {
|
||||
if (e.readyState >= 2 && source.status !== undefined && source.status !== 200) {
|
||||
// 检查 HTTP 状态错误,但避免与正常关闭重复处理
|
||||
if (e.readyState >= 2 && source.status !== undefined && source.status !== 200 && !isStreamComplete) {
|
||||
const errorInfo = handleApiError(new Error('HTTP状态错误'));
|
||||
errorInfo.status = source.status;
|
||||
errorInfo.readyState = source.readyState;
|
||||
@@ -401,4 +407,4 @@ export const useApiRequest = (
|
||||
streamMessageUpdate,
|
||||
completeMessage,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -265,10 +265,15 @@
|
||||
"设置页脚": "Set Footer",
|
||||
"新版本": "New Version",
|
||||
"关闭": "Close",
|
||||
"密码已重置并已复制到剪贴板": "Password has been reset and copied to clipboard",
|
||||
"密码已重置并已复制到剪贴板:": "Password has been reset and copied to clipboard: ",
|
||||
"密码已复制到剪贴板:": "Password has been copied to clipboard: ",
|
||||
"密码重置确认": "Password Reset Confirmation",
|
||||
"邮箱地址": "Email address",
|
||||
"提交": "Submit",
|
||||
"等待获取邮箱信息...": "Waiting to get email information...",
|
||||
"确认重置密码": "Confirm Password Reset",
|
||||
"无效的重置链接,请重新发起密码重置请求": "Invalid reset link, please initiate a new password reset request",
|
||||
"请输入邮箱地址": "Please enter the email address",
|
||||
"请稍后几秒重试": "Please retry in a few seconds",
|
||||
"正在检查用户环境": "Checking user environment",
|
||||
"重置邮件发送成功": "Reset mail sent successfully",
|
||||
@@ -505,7 +510,7 @@
|
||||
"此项可选,输入镜像站地址,格式为:": "This is optional, enter the mirror site address, the format is:",
|
||||
"模型映射": "Model mapping",
|
||||
"请输入默认 API 版本,例如:2023-03-15-preview,该配置可以被实际的请求查询参数所覆盖": "Please enter the default API version, for example: 2023-03-15-preview, this configuration can be overridden by the actual request query parameters",
|
||||
"默认": "default",
|
||||
"默认": "Default",
|
||||
"图片演示": "Image demo",
|
||||
"注意,系统请求的时模型名称中的点会被剔除,例如: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",
|
||||
@@ -832,7 +837,18 @@
|
||||
"支付宝": "Alipay",
|
||||
"待使用收益": "Proceeds to be used",
|
||||
"邀请人数": "Number of people invited",
|
||||
"兑换余额": "Exchange balance",
|
||||
"兑换码充值": "Redemption code recharge",
|
||||
"使用兑换码快速充值": "Use redemption code to quickly recharge",
|
||||
"支付方式": "Payment method",
|
||||
"邀请奖励": "Invite reward",
|
||||
"或输入自定义金额": "Or enter a custom amount",
|
||||
"选择充值额度": "Select recharge amount",
|
||||
"实付": "Actual payment",
|
||||
"快速方便的充值方式": "Quick and convenient recharge method",
|
||||
"邀请好友获得额外奖励": "Invite friends to get additional rewards",
|
||||
"邀请好友注册,好友充值后您可获得相应奖励": "Invite friends to register, and you can get the corresponding reward after the friend recharges",
|
||||
"通过划转功能将奖励额度转入到您的账户余额中": "Transfer the reward amount to your account balance through the transfer function",
|
||||
"邀请的好友越多,获得的奖励越多": "The more friends you invite, the more rewards you will get",
|
||||
"在线充值": "Online recharge",
|
||||
"充值数量,最低 ": "Recharge quantity, minimum",
|
||||
"请选择充值金额": "Please select the recharge amount",
|
||||
@@ -865,8 +881,7 @@
|
||||
"你好,": "Hello,",
|
||||
"线路监控": "line monitoring",
|
||||
"查看全部": "View all",
|
||||
"高延迟": "high latency",
|
||||
"异常": "abnormal",
|
||||
"异常": "Abnormal",
|
||||
"的未命名令牌": "unnamed token",
|
||||
"令牌更新成功!": "Token updated successfully!",
|
||||
"(origin) Discord原链接": "(origin) Discord original link",
|
||||
@@ -925,7 +940,7 @@
|
||||
"支付中..": "Paying",
|
||||
"查看图片": "View pictures",
|
||||
"并发限制": "Concurrency limit",
|
||||
"正常": "normal",
|
||||
"正常": "Normal",
|
||||
"周期": "cycle",
|
||||
"同步频率10-20分钟": "Synchronization frequency 10-20 minutes",
|
||||
"模型调用占比": "Model call proportion",
|
||||
@@ -949,12 +964,14 @@
|
||||
"任务ID": "Task ID",
|
||||
"周": "week",
|
||||
"总计:": "Total:",
|
||||
"划转": "transfer",
|
||||
"划转到余额": "Transfer to balance",
|
||||
"可用额度": "Available credit",
|
||||
"邀请码:": "Invitation code:",
|
||||
"最低": "lowest",
|
||||
"划转额度": "Transfer amount",
|
||||
"邀请链接": "Invitation link",
|
||||
"划转邀请额度": "Transfer invitation quota",
|
||||
"可用邀请额度": "Available invitation quota",
|
||||
"更多优惠": "More offers",
|
||||
"企业微信": "Enterprise WeChat",
|
||||
"点击解绑WxPusher": "Click to unbind WxPusher",
|
||||
@@ -1388,7 +1405,8 @@
|
||||
"可在初始化后修改": "Can be modified after initialization",
|
||||
"初始化系统": "Initialize system",
|
||||
"支持众多的大模型供应商": "Supporting various LLM providers",
|
||||
"新一代大模型网关与AI资产管理系统,一键接入主流大模型,轻松管理您的AI资产": "Next-generation LLM gateway and AI asset management system, one-click integration with mainstream models, easily manage your AI assets",
|
||||
"统一的大模型接口网关": "The Unified LLMs API Gateway",
|
||||
"更好的价格,更好的稳定性,无需订阅": "Better price, better stability, no subscription required",
|
||||
"开始使用": "Get Started",
|
||||
"关于我们": "About Us",
|
||||
"关于项目": "About Project",
|
||||
@@ -1404,8 +1422,13 @@
|
||||
"演示站点": "Demo Site",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
|
||||
"New API项目仓库地址:": "New API project repository address: ",
|
||||
"NewAPI © {{currentYear}} QuantumNous | 基于 One API v0.5.4 © 2023 JustSong。": "NewAPI © {{currentYear}} QuantumNous | Based on One API v0.5.4 © 2023 JustSong.",
|
||||
"本项目根据MIT许可证授权,需在遵守Apache-2.0协议的前提下使用。": "This project is licensed under the MIT License and must be used in compliance with the Apache-2.0 License.",
|
||||
"© {{currentYear}}": "© {{currentYear}}",
|
||||
"| 基于": " | Based on ",
|
||||
"MIT许可证": "MIT License",
|
||||
"Apache-2.0协议": "Apache-2.0 License",
|
||||
"本项目根据": "This project is licensed under the ",
|
||||
"授权,需在遵守": " and must be used in compliance with the ",
|
||||
"的前提下使用。": ".",
|
||||
"管理员暂时未设置任何关于内容": "The administrator has not set any custom About content yet",
|
||||
"早上好": "Good morning",
|
||||
"中午好": "Good afternoon",
|
||||
@@ -1531,6 +1554,7 @@
|
||||
"关闭公告": "Close Notice",
|
||||
"搜索条件": "Search Conditions",
|
||||
"加载中...": "Loading...",
|
||||
"正在跳转...": "Redirecting...",
|
||||
"暂无公告": "No Notice",
|
||||
"操练场": "Playground",
|
||||
"欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
|
||||
@@ -1556,5 +1580,77 @@
|
||||
"使用统计": "Usage Statistics",
|
||||
"资源消耗": "Resource Consumption",
|
||||
"性能指标": "Performance Indicators",
|
||||
"模型数据分析": "Model Data Analysis"
|
||||
"模型数据分析": "Model Data Analysis",
|
||||
"搜索无结果": "No results found",
|
||||
"仪表盘配置": "Dashboard Configuration",
|
||||
"API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "API information management, you can configure multiple API addresses for status display and load balancing (maximum 50)",
|
||||
"线路描述": "Route description",
|
||||
"颜色": "Color",
|
||||
"标识颜色": "Identifier color",
|
||||
"添加API": "Add API",
|
||||
"API信息": "API Information",
|
||||
"暂无API信息": "No API information",
|
||||
"请输入API地址": "Please enter the API address",
|
||||
"请输入线路描述": "Please enter the route description",
|
||||
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
|
||||
"请输入说明": "Please enter the description",
|
||||
"如:香港线路": "e.g. Hong Kong line",
|
||||
"请联系管理员在系统设置中配置API信息": "Please contact the administrator to configure API information in the system settings.",
|
||||
"请联系管理员在系统设置中配置公告信息": "Please contact the administrator to configure notice information in the system settings.",
|
||||
"请联系管理员在系统设置中配置常见问答": "Please contact the administrator to configure FAQ information in the system settings.",
|
||||
"确定要删除此API信息吗?": "Are you sure you want to delete this API information?",
|
||||
"测速": "Speed Test",
|
||||
"批量删除": "Batch Delete",
|
||||
"常见问答": "FAQ",
|
||||
"进行中": "Ongoing",
|
||||
"警告": "Warning",
|
||||
"添加公告": "Add Notice",
|
||||
"编辑公告": "Edit Notice",
|
||||
"公告内容": "Notice Content",
|
||||
"请输入公告内容": "Please enter the notice content",
|
||||
"发布日期": "Publish Date",
|
||||
"请选择发布日期": "Please select the publish date",
|
||||
"发布时间": "Publish Time",
|
||||
"公告类型": "Notice Type",
|
||||
"说明信息": "Description",
|
||||
"可选,公告的补充说明": "Optional, additional information for the notice",
|
||||
"确定要删除此公告吗?": "Are you sure you want to delete this notice?",
|
||||
"系统公告管理,可以发布系统通知和重要消息": "System notice management, you can publish system notices and important messages",
|
||||
"暂无系统公告": "No system notice",
|
||||
"添加问答": "Add FAQ",
|
||||
"编辑问答": "Edit FAQ",
|
||||
"问题标题": "Question Title",
|
||||
"请输入问题标题": "Please enter the question title",
|
||||
"回答内容": "Answer Content",
|
||||
"请输入回答内容": "Please enter the answer content",
|
||||
"确定要删除此问答吗?": "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)",
|
||||
"暂无常见问答": "No FAQ",
|
||||
"显示最新20条": "Display latest 20",
|
||||
"Uptime Kuma 服务地址": "Uptime Kuma service address",
|
||||
"状态页面 Slug": "Status page slug",
|
||||
"请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com",
|
||||
"请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status",
|
||||
"Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty",
|
||||
"请输入有效的 URL 地址": "Please enter a valid URL address",
|
||||
"状态页面 Slug 不能为空": "Status page slug cannot be empty",
|
||||
"Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens",
|
||||
"请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address",
|
||||
"请输入状态页面 Slug": "Please enter the status page slug",
|
||||
"配置": "Configure",
|
||||
"服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information",
|
||||
"服务可用性": "Service Status",
|
||||
"可用率": "Availability",
|
||||
"有异常": "Abnormal",
|
||||
"高延迟": "High latency",
|
||||
"维护中": "Maintenance",
|
||||
"暂无监控数据": "No monitoring data",
|
||||
"请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings.",
|
||||
"IP记录": "IP Record",
|
||||
"记录请求与错误日志 IP": "Record request and error log IP",
|
||||
"开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址": "After enabling, only \"consumption\" and \"error\" logs will record your client IP address",
|
||||
"只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录": "Only when the user sets IP recording, the IP recording of request and error type logs will be performed",
|
||||
"设置保存成功": "Settings saved successfully",
|
||||
"设置保存失败": "Settings save failed"
|
||||
}
|
||||
@@ -15,20 +15,9 @@
|
||||
|
||||
/* ==================== 全局基础样式 ==================== */
|
||||
body {
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
font-family:
|
||||
Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scrollbar-width: none;
|
||||
color: var(--semi-color-text-0) !important;
|
||||
background-color: var(--semi-color-bg-0) !important;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
display: none;
|
||||
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', sans-serif;
|
||||
color: var(--semi-color-text-0);
|
||||
background-color: var(--semi-color-bg-0);
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -36,34 +25,20 @@ code {
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== 布局相关样式 ==================== */
|
||||
.semi-layout::-webkit-scrollbar,
|
||||
.semi-layout-content::-webkit-scrollbar,
|
||||
.semi-sider::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.semi-layout-content::-webkit-scrollbar-thumb,
|
||||
.semi-sider::-webkit-scrollbar-thumb {
|
||||
background: var(--semi-color-tertiary-light-default);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.semi-layout-content::-webkit-scrollbar-thumb:hover,
|
||||
.semi-sider::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--semi-color-tertiary);
|
||||
}
|
||||
|
||||
.semi-layout-content::-webkit-scrollbar-track,
|
||||
.semi-sider::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.semi-layout,
|
||||
.semi-layout-content,
|
||||
.semi-sider {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* ==================== 导航和侧边栏样式 ==================== */
|
||||
@@ -73,6 +48,10 @@ code {
|
||||
.semi-page-item,
|
||||
.semi-navigation-item,
|
||||
.semi-tag-closable,
|
||||
.semi-input-wrapper,
|
||||
.semi-tabs-tab-button,
|
||||
.semi-select,
|
||||
.semi-button,
|
||||
.semi-datepicker-range-input {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
@@ -322,6 +301,24 @@ code {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* 卡片内容容器通用样式 */
|
||||
.card-content-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-content-fade-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
background: linear-gradient(transparent, var(--semi-color-bg-1));
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* ==================== 调试面板特定样式 ==================== */
|
||||
.debug-panel .semi-tabs {
|
||||
height: 100% !important;
|
||||
@@ -377,7 +374,8 @@ code {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 隐藏模型设置区域的滚动条 */
|
||||
/* 隐藏卡片内容区域的滚动条 */
|
||||
.card-content-scroll::-webkit-scrollbar,
|
||||
.model-settings-scroll::-webkit-scrollbar,
|
||||
.thinking-content-scroll::-webkit-scrollbar,
|
||||
.custom-request-textarea .semi-input::-webkit-scrollbar,
|
||||
@@ -385,6 +383,7 @@ code {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-content-scroll,
|
||||
.model-settings-scroll,
|
||||
.thinking-content-scroll,
|
||||
.custom-request-textarea .semi-input,
|
||||
@@ -414,41 +413,6 @@ code {
|
||||
|
||||
/* ==================== 响应式/移动端样式 ==================== */
|
||||
@media only screen and (max-width: 767px) {
|
||||
#root>section>header>section>div>div>div>div.semi-navigation-footer>div>a>li {
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
#root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li {
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
#root>section>header>section>div>div>div>div.semi-navigation-footer>div:nth-child(1)>a>li {
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.semi-navigation-horizontal .semi-navigation-header {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* 确保移动端内容可滚动 */
|
||||
.semi-layout-content {
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
overscroll-behavior-y: auto !important;
|
||||
}
|
||||
|
||||
/* 修复移动端下拉刷新 */
|
||||
body {
|
||||
overflow: visible !important;
|
||||
overscroll-behavior-y: auto !important;
|
||||
position: static !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* 确保内容区域在移动端可以正常滚动 */
|
||||
#root {
|
||||
overflow: visible !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* 移动端表格样式调整 */
|
||||
.semi-table-tbody,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import '@douyinfe/semi-ui/dist/css/semi.css';
|
||||
import { UserProvider } from './context/User';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { StatusProvider } from './context/Status';
|
||||
|
||||
@@ -3,7 +3,6 @@ import { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const About = () => {
|
||||
@@ -42,14 +41,65 @@ const About = () => {
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
|
||||
{t('New API项目仓库地址:')}
|
||||
<Link to='https://github.com/QuantumNous/new-api' target="_blank">
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
https://github.com/QuantumNous/new-api
|
||||
</Link>
|
||||
</a>
|
||||
<p>
|
||||
{t('NewAPI © {{currentYear}} QuantumNous | 基于 One API v0.5.4 © 2023 JustSong。', { currentYear })}
|
||||
<a
|
||||
href="https://github.com/QuantumNous/new-api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
NewAPI
|
||||
</a> {t('© {{currentYear}}', { currentYear })} <a
|
||||
href="https://github.com/QuantumNous"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
QuantumNous
|
||||
</a> {t('| 基于')} <a
|
||||
href="https://github.com/songquanpeng/one-api/releases/tag/v0.5.4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
One API v0.5.4
|
||||
</a> © 2023 <a
|
||||
href="https://github.com/songquanpeng"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
JustSong
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('本项目根据MIT许可证授权,需在遵守Apache-2.0协议的前提下使用。')}
|
||||
{t('本项目根据')}
|
||||
<a
|
||||
href="https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
{t('MIT许可证')}
|
||||
</a>
|
||||
{t('授权,需在遵守')}
|
||||
<a
|
||||
href="https://github.com/QuantumNous/new-api/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!text-semi-color-primary"
|
||||
>
|
||||
{t('Apache-2.0协议')}
|
||||
</a>
|
||||
{t('的前提下使用。')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -846,7 +846,7 @@ const EditChannel = (props) => {
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer mt-1 block"
|
||||
className="!text-semi-color-primary cursor-pointer mt-1 block"
|
||||
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
|
||||
>
|
||||
{t('填入模板')}
|
||||
@@ -940,7 +940,7 @@ const EditChannel = (props) => {
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer mt-1 block"
|
||||
className="!text-semi-color-primary cursor-pointer mt-1 block"
|
||||
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
|
||||
>
|
||||
{t('填入模板')}
|
||||
@@ -1062,7 +1062,7 @@ const EditChannel = (props) => {
|
||||
/>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer"
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => {
|
||||
handleInputChange(
|
||||
'setting',
|
||||
@@ -1073,10 +1073,10 @@ const EditChannel = (props) => {
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer"
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
'https://github.com/Calcium-Ion/new-api/blob/main/docs/channel/other_setting.md',
|
||||
'https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md',
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -1146,7 +1146,7 @@ const EditChannel = (props) => {
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer mt-1 block"
|
||||
className="!text-semi-color-primary cursor-pointer mt-1 block"
|
||||
onClick={() => {
|
||||
handleInputChange(
|
||||
'status_code_mapping',
|
||||
|
||||
@@ -194,6 +194,24 @@ const EditTagModal = (props) => {
|
||||
}, [originModelOptions, inputs.models]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTagModels = async () => {
|
||||
if (!tag) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
|
||||
if (res?.data?.success) {
|
||||
const models = res.data.data ? res.data.data.split(',') : [];
|
||||
setInputs((inputs) => ({ ...inputs, models: models }));
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
setInputs({
|
||||
...originInputs,
|
||||
tag: tag,
|
||||
@@ -201,7 +219,8 @@ const EditTagModal = (props) => {
|
||||
});
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
}, [visible]);
|
||||
fetchTagModels().then(); // Call the new function
|
||||
}, [visible, tag]); // Add tag to dependency array
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
@@ -347,6 +366,11 @@ const EditTagModal = (props) => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('模型')}</Text>
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
name='models'
|
||||
@@ -388,19 +412,19 @@ const EditTagModal = (props) => {
|
||||
/>
|
||||
<Space className="mt-2">
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer"
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer"
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}
|
||||
>
|
||||
{t('清空重定向')}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-blue-500 cursor-pointer"
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('model_mapping', '')}
|
||||
>
|
||||
{t('不更改')}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useTokenKeys } from '../../hooks/useTokenKeys';
|
||||
import { Banner, Layout } from '@douyinfe/semi-ui';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChatPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const { keys, serverAddress, isLoading } = useTokenKeys(id);
|
||||
|
||||
@@ -40,12 +42,17 @@ const ChatPage = () => {
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Layout>
|
||||
<Layout.Header>
|
||||
<Banner description={'正在跳转......'} type={'warning'} />
|
||||
</Layout.Header>
|
||||
</Layout>
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spin
|
||||
size="large"
|
||||
spinning={true}
|
||||
tip={null}
|
||||
/>
|
||||
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
|
||||
{t('正在跳转...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,7 @@ import { API, showError, isMobile } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGithubLogo } from '@douyinfe/semi-icons';
|
||||
import exampleImage from '/example.png';
|
||||
import { IconGithubLogo, IconPlay, IconFile } 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';
|
||||
@@ -20,6 +19,7 @@ const Home = () => {
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
@@ -85,132 +85,130 @@ const Home = () => {
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
{/* Banner 部分 */}
|
||||
<div className="w-full border-b border-semi-color-border min-h-[500px] md:h-[650px] lg:h-[750px] relative overflow-x-hidden">
|
||||
<div className="flex flex-col md:flex-row items-center justify-center h-full px-4 py-8 md:py-0">
|
||||
{/* 左侧内容区 */}
|
||||
<div className="flex-shrink-0 w-full md:w-[480px] md:mr-[60px] lg:mr-[120px] mb-8 md:mb-0">
|
||||
<div className="flex items-center gap-2 justify-center md:justify-start">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-semibold text-semi-color-text-0 w-auto leading-normal md:leading-[67px]">
|
||||
{statusState?.status?.system_name || 'New API'}
|
||||
<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="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">
|
||||
{i18n.language === 'en' ? (
|
||||
<>
|
||||
The Unified<br />
|
||||
LLMs API Gateway
|
||||
</>
|
||||
) : (
|
||||
t('统一的大模型接口网关')
|
||||
)}
|
||||
</h1>
|
||||
{statusState?.status?.version && (
|
||||
<Tag color='light-blue' size='large' shape='circle' className="ml-1">
|
||||
{statusState.status.version}
|
||||
</Tag>
|
||||
)}
|
||||
<p className="text-lg md:text-xl lg:text-2xl text-semi-color-text-1 mt-4 md:mt-6">
|
||||
{t('更好的价格,更好的稳定性,无需订阅')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base md:text-lg text-semi-color-text-0 mt-4 md:mt-8 w-full md:w-[480px] leading-7 md:leading-8 text-center md:text-left">
|
||||
{t('新一代大模型网关与AI资产管理系统,一键接入主流大模型,轻松管理您的AI资产')}
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="mt-6 md:mt-10 flex flex-wrap gap-4 justify-center md:justify-start">
|
||||
<div className="flex flex-row gap-4 justify-center items-center">
|
||||
<Link to="/console">
|
||||
<Button theme="solid" type="primary" size="large" className="!rounded-3xl">
|
||||
<Button theme="solid" type="primary" size={isMobile() ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
|
||||
{t('开始使用')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isDemoSiteMode && (
|
||||
{isDemoSiteMode && statusState?.status?.version ? (
|
||||
<Button
|
||||
size="large"
|
||||
className="flex items-center !rounded-3xl"
|
||||
size={isMobile() ? "default" : "large"}
|
||||
className="flex items-center !rounded-3xl px-6 py-2"
|
||||
icon={<IconGithubLogo />}
|
||||
onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
|
||||
>
|
||||
GitHub
|
||||
{statusState.status.version}
|
||||
</Button>
|
||||
) : (
|
||||
docsLink && (
|
||||
<Button
|
||||
size={isMobile() ? "default" : "large"}
|
||||
className="flex items-center !rounded-3xl px-6 py-2"
|
||||
icon={<IconFile />}
|
||||
onClick={() => window.open(docsLink, '_blank')}
|
||||
>
|
||||
{t('文档')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 框架兼容性图标 */}
|
||||
<div className="mt-8 md:mt-16">
|
||||
<div className="flex items-center mb-3 justify-center md:justify-start">
|
||||
<Text type="tertiary" className="text-lg md:text-xl font-light">
|
||||
<div className="mt-12 md:mt-16 lg:mt-20 w-full">
|
||||
<div className="flex items-center mb-6 md:mb-8 justify-center">
|
||||
<Text type="tertiary" className="text-lg md:text-xl lg:text-2xl font-light">
|
||||
{t('支持众多的大模型供应商')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center relative mt-6 md:mt-8 gap-6 md:gap-8 justify-center md:justify-start">
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Moonshot size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<OpenAI size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<XAI size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Zhipu.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Volcengine.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Cohere.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Claude.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Gemini.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Suno size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Minimax.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Wenxin.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Spark.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Qingyan.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<DeepSeek.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Qwen.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Midjourney size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Grok size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<AzureAI.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Hunyuan.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Xinference.Color size={40} />
|
||||
</div>
|
||||
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
|
||||
<Typography.Text className="!text-2xl font-bold">30+</Typography.Text>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
|
||||
<Typography.Text className="!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold">30+</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧图片区域 - 在小屏幕上隐藏或调整位置 */}
|
||||
<div className="flex-shrink-0 relative md:mr-[-200px] lg:mr-[-400px] hidden md:block lg:min-w-[1100px]">
|
||||
<div className="absolute w-[320px] md:w-[500px] lg:w-[640px] h-[320px] md:h-[500px] lg:h-[640px] left-[-25px] md:left-[-40px] lg:left-[-50px] top-[-10px] md:top-[-15px] lg:top-[-20px] opacity-60"
|
||||
style={{ filter: 'blur(120px)' }}>
|
||||
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] top-[80px] md:top-[100px] lg:top-[132px] bg-semi-color-primary rounded-full opacity-30"></div>
|
||||
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] left-[80px] md:left-[120px] lg:left-[166px] bg-semi-color-tertiary rounded-full opacity-30"></div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={exampleImage}
|
||||
alt="application demo"
|
||||
className="relative h-[400px] md:h-[600px] lg:h-[721px] ml-[-15px] md:ml-[-20px] lg:ml-[-30px] mt-[-15px] md:mt-[-20px] lg:mt-[-30px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +221,7 @@ const Home = () => {
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-base md:text-lg p-4 md:p-6 overflow-x-hidden"
|
||||
className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
|
||||
dangerouslySetInnerHTML={{ __html: homePageContent }}
|
||||
></div>
|
||||
)}
|
||||
@@ -234,3 +232,4 @@ const Home = () => {
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user