Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a40d3381 | |||
| bf130c5cde | |||
| f7adf02eb4 | |||
| d0c2d2c6fb | |||
| ee7cedd577 | |||
| 8c8661d0d7 | |||
| d15e14b117 | |||
| 3ab65a8221 | |||
| 7cfaf6c335 | |||
| 2bedd31b42 | |||
| c20060931b | |||
| 8b22161527 | |||
| 3d0ac2d049 | |||
| b81d3427ee | |||
| b4df9955f4 | |||
| 59c582d13c | |||
| 2819e3a1d1 | |||
| ed7f839911 | |||
| 040e8c1da8 | |||
| 0664bb3f65 | |||
| c7cf20391e | |||
| b07f0b9626 | |||
| 53cf37a469 | |||
| 3bda738ec1 | |||
| 160cb28572 | |||
| 274307b0a9 | |||
| a19a63b98c | |||
| 78e4cb3cad | |||
| c734db34e8 | |||
| a18ea3cc16 | |||
| aafbd78887 | |||
| 77897a8101 | |||
| 9b4ffb0875 | |||
| 606a4eee96 | |||
| 9ffb85a36b | |||
| c3b8fa29b2 | |||
| a057eddac1 | |||
| 1110403750 | |||
| 3a2aecbc01 | |||
| 49648d8b80 | |||
| 59d5aef393 | |||
| 48695e0e6f | |||
| e96ca77542 | |||
| 1ad2557668 | |||
| ded3bb9cb1 | |||
| cf1b485389 | |||
| 741aaf4436 | |||
| c66636a0c7 | |||
| f7cdc727df | |||
| 07843d7898 | |||
| 559c98f261 | |||
| b713e277cd | |||
| 08a5243bbc | |||
| 116e0b8f1c | |||
| 70560d5371 |
@@ -19,6 +19,8 @@
|
||||
# HOSTNAME=your-hostname
|
||||
|
||||
# 数据库相关配置
|
||||
# 启用错误日志记录
|
||||
# ERROR_LOG_ENABLED=true
|
||||
# 数据库连接字符串
|
||||
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
|
||||
# 日志数据库连接字符串
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ⚠️ 提交说明 / PR Notice
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
|
||||
|
||||
## 📝 变更描述 / Description
|
||||
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
|
||||
|
||||
## 🚀 变更类型 / Type of change
|
||||
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
|
||||
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
|
||||
- [ ] ⚡ 性能优化 / 重构 (Refactor)
|
||||
- [ ] 📝 文档更新 (Documentation)
|
||||
|
||||
## 🔗 关联任务 / Related Issue
|
||||
- Closes # (如有)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
|
||||
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
|
||||
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
|
||||
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
|
||||
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
|
||||
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
|
||||
## 📸 运行证明 / Proof of Work
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
@@ -1,29 +0,0 @@
|
||||
# ⚠️ 提交警告 / PR Warning
|
||||
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
|
||||
|
||||
---
|
||||
|
||||
## 💡 沟通提示 / Pre-submission
|
||||
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
|
||||
|
||||
## 📝 变更描述 / Description
|
||||
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
|
||||
|
||||
## 🚀 变更类型 / Type of change
|
||||
- [ ] 🐛 Bug 修复 (Bug fix)
|
||||
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
|
||||
- [ ] ⚡ 性能优化 / 重构 (Refactor)
|
||||
- [ ] 📝 文档更新 (Documentation)
|
||||
|
||||
## 🔗 关联任务 / Related Issue
|
||||
- Closes # (如有)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
|
||||
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
|
||||
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
|
||||
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
|
||||
## 📸 运行证明 / Proof of Work
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
@@ -0,0 +1,33 @@
|
||||
name: PR Check
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0.2.1
|
||||
with:
|
||||
max-failures: 4
|
||||
require-description: true
|
||||
|
||||
# require-linked-issue: false
|
||||
blocked-terms: |
|
||||
🤖 Generated with Claude Code
|
||||
|
||||
require-pr-template: true
|
||||
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
|
||||
|
||||
detect-spam-usernames: true
|
||||
min-account-age: 30
|
||||
|
||||
failure-add-pr-labels: "pr-check-failed"
|
||||
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
|
||||
close-pr: true
|
||||
@@ -29,3 +29,5 @@ data/
|
||||
.gomodcache/
|
||||
.gocache-temp
|
||||
.gopath
|
||||
|
||||
token_estimator_test.go
|
||||
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
var SMTPServer = ""
|
||||
var SMTPPort = 587
|
||||
var SMTPSSLEnabled = false
|
||||
var SMTPForceAuthLogin = false
|
||||
var SMTPAccount = ""
|
||||
var SMTPFrom = ""
|
||||
var SMTPToken = ""
|
||||
|
||||
+15
-4
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
|
||||
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
|
||||
}
|
||||
|
||||
func shouldUseSMTPLoginAuth() bool {
|
||||
if SMTPForceAuthLogin {
|
||||
return true
|
||||
}
|
||||
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
|
||||
}
|
||||
|
||||
func getSMTPAuth() smtp.Auth {
|
||||
if shouldUseSMTPLoginAuth() {
|
||||
return LoginAuth(SMTPAccount, SMTPToken)
|
||||
}
|
||||
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
|
||||
}
|
||||
|
||||
func SendEmail(subject string, receiver string, content string) error {
|
||||
if SMTPFrom == "" { // for compatibility
|
||||
SMTPFrom = SMTPAccount
|
||||
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
|
||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
|
||||
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
|
||||
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
|
||||
auth := getSMTPAuth()
|
||||
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
|
||||
to := strings.Split(receiver, ";")
|
||||
var err error
|
||||
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
|
||||
auth = LoginAuth(SMTPAccount, SMTPToken)
|
||||
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
||||
} else {
|
||||
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
||||
}
|
||||
|
||||
@@ -65,4 +65,5 @@ const (
|
||||
|
||||
// ContextKeyLanguage stores the user's language preference for i18n
|
||||
ContextKeyLanguage ContextKey = "language"
|
||||
ContextKeyIsStream ContextKey = "is_stream"
|
||||
)
|
||||
|
||||
@@ -150,6 +150,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
}
|
||||
}
|
||||
cache.WriteContext(c)
|
||||
c.Set("id", 1)
|
||||
|
||||
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
@@ -274,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: err,
|
||||
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
|
||||
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,11 +757,15 @@ func TestChannel(c *gin.Context) {
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testModel, endpointType, isStream)
|
||||
if result.localErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
resp := gin.H{
|
||||
"success": false,
|
||||
"message": result.localErr.Error(),
|
||||
"time": 0.0,
|
||||
})
|
||||
}
|
||||
if result.newAPIError != nil {
|
||||
resp["error_code"] = result.newAPIError.GetErrorCode()
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
tok := time.Now()
|
||||
@@ -769,9 +774,10 @@ func TestChannel(c *gin.Context) {
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
if result.newAPIError != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": result.newAPIError.Error(),
|
||||
"time": consumedTime,
|
||||
"success": false,
|
||||
"message": result.newAPIError.Error(),
|
||||
"time": consumedTime,
|
||||
"error_code": result.newAPIError.GetErrorCode(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
@@ -8,6 +9,30 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
|
||||
if len(pricing) == 0 {
|
||||
return pricing
|
||||
}
|
||||
if len(usableGroup) == 0 {
|
||||
return []model.Pricing{}
|
||||
}
|
||||
|
||||
filtered := make([]model.Pricing, 0, len(pricing))
|
||||
for _, item := range pricing {
|
||||
if common.StringsContains(item.EnableGroup, "all") {
|
||||
filtered = append(filtered, item)
|
||||
continue
|
||||
}
|
||||
for _, group := range item.EnableGroup {
|
||||
if _, ok := usableGroup[group]; ok {
|
||||
filtered = append(filtered, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func GetPricing(c *gin.Context) {
|
||||
pricing := model.GetPricing()
|
||||
userId, exists := c.Get("id")
|
||||
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
|
||||
}
|
||||
|
||||
usableGroup = service.GetUserUsableGroups(group)
|
||||
pricing = filterPricingByUsableGroups(pricing, usableGroup)
|
||||
// check groupRatio contains usableGroup
|
||||
for group := range ratio_setting.GetGroupRatioCopy() {
|
||||
if _, ok := usableGroup[group]; !ok {
|
||||
|
||||
+2
-2
@@ -151,7 +151,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
|
||||
if err != nil {
|
||||
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
|
||||
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
|
||||
startTime = time.Now()
|
||||
}
|
||||
useTimeSeconds := int(time.Since(startTime).Seconds())
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -146,6 +146,12 @@ func RequestStripePay(c *gin.Context) {
|
||||
}
|
||||
|
||||
func StripeWebhook(c *gin.Context) {
|
||||
if setting.StripeWebhookSecret == "" {
|
||||
log.Println("Stripe Webhook Secret 未配置,拒绝处理")
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
|
||||
@@ -154,8 +160,7 @@ func StripeWebhook(c *gin.Context) {
|
||||
}
|
||||
|
||||
signature := c.GetHeader("Stripe-Signature")
|
||||
endpointSecret := setting.StripeWebhookSecret
|
||||
event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
|
||||
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
|
||||
IgnoreAPIVersionMismatch: true,
|
||||
})
|
||||
|
||||
@@ -170,6 +175,10 @@ func StripeWebhook(c *gin.Context) {
|
||||
sessionCompleted(event)
|
||||
case stripe.EventTypeCheckoutSessionExpired:
|
||||
sessionExpired(event)
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
|
||||
sessionAsyncPaymentSucceeded(event)
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
|
||||
sessionAsyncPaymentFailed(event)
|
||||
default:
|
||||
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
|
||||
}
|
||||
@@ -186,7 +195,65 @@ func sessionCompleted(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try complete subscription order first
|
||||
paymentStatus := event.GetObjectValue("payment_status")
|
||||
if paymentStatus != "paid" {
|
||||
log.Printf("Stripe Checkout 支付尚未完成,payment_status: %s, ref: %s(等待异步支付结果)", paymentStatus, referenceId)
|
||||
return
|
||||
}
|
||||
|
||||
fulfillOrder(event, referenceId, customerId)
|
||||
}
|
||||
|
||||
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
|
||||
// that confirm payment after the checkout session completes.
|
||||
func sessionAsyncPaymentSucceeded(event stripe.Event) {
|
||||
customerId := event.GetObjectValue("customer")
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
log.Printf("Stripe 异步支付成功: %s", referenceId)
|
||||
|
||||
fulfillOrder(event, referenceId, customerId)
|
||||
}
|
||||
|
||||
// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
|
||||
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
|
||||
func sessionAsyncPaymentFailed(event stripe.Event) {
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
log.Printf("Stripe 异步支付失败: %s", referenceId)
|
||||
|
||||
if len(referenceId) == 0 {
|
||||
log.Println("异步支付失败事件未提供支付单号")
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Println("异步支付失败,充值订单不存在:", referenceId)
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
log.Printf("异步支付失败,订单状态非pending: %s, ref: %s", topUp.Status, referenceId)
|
||||
return
|
||||
}
|
||||
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
if err := topUp.Update(); err != nil {
|
||||
log.Printf("标记充值订单失败出错: %v, ref: %s", err, referenceId)
|
||||
return
|
||||
}
|
||||
log.Printf("充值订单已标记为失败: %s", referenceId)
|
||||
}
|
||||
|
||||
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
|
||||
func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
|
||||
if len(referenceId) == 0 {
|
||||
log.Println("未提供支付单号")
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
payload := map[string]any{
|
||||
|
||||
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GetQuotaDatesByUser(c *gin.Context) {
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": dates,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserQuotaDates(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
|
||||
+53
-7
@@ -52,10 +52,15 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
err = user.ValidateAndFill()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, model.ErrDatabase):
|
||||
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
case errors.Is(err, model.ErrUserEmptyCredentials):
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,9 +577,6 @@ func UpdateUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if originUser.Quota != updatedUser.Quota {
|
||||
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -841,6 +843,8 @@ func CreateUser(c *gin.Context) {
|
||||
type ManageRequest struct {
|
||||
Id int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Value int `json:"value"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// ManageUser Only admin user can do this
|
||||
@@ -907,6 +911,48 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
case "add_quota":
|
||||
adminName := c.GetString("username")
|
||||
switch req.Mode {
|
||||
case "add":
|
||||
if req.Value <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
return
|
||||
}
|
||||
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)增加用户额度 %s", adminName, logger.LogQuota(req.Value)))
|
||||
case "subtract":
|
||||
if req.Value <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
return
|
||||
}
|
||||
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)减少用户额度 %s", adminName, logger.LogQuota(req.Value)))
|
||||
case "override":
|
||||
oldQuota := user.Quota
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)覆盖用户额度从 %s 为 %s", adminName, logger.LogQuota(oldQuota), logger.LogQuota(req.Value)))
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
|
||||
+53
-1
@@ -3281,6 +3281,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"cache_control": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"inference_geo": {
|
||||
"type": "string"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
@@ -3333,7 +3340,8 @@
|
||||
"enum": [
|
||||
"auto",
|
||||
"any",
|
||||
"tool"
|
||||
"tool",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
@@ -3358,6 +3366,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"context_management": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"output_config": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"output_format": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"container": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3365,6 +3403,20 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standard",
|
||||
"fast"
|
||||
]
|
||||
},
|
||||
"service_tier": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"standard_only"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,6 +18,16 @@ type AudioRequest struct {
|
||||
Speed *float64 `json:"speed,omitempty"`
|
||||
StreamFormat string `json:"stream_format,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
// vllm-omini
|
||||
TaskType json.RawMessage `json:"task_type,omitempty"`
|
||||
Language json.RawMessage `json:"language,omitempty"`
|
||||
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
|
||||
RefText json.RawMessage `json:"ref_text,omitempty"`
|
||||
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
|
||||
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
|
||||
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
|
||||
// TODO:ensure that the logic remains correct after the stream is started.
|
||||
//Stream json.RawMessage `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
@@ -30,6 +30,7 @@ type ChannelOtherSettings struct {
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
|
||||
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
|
||||
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
|
||||
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
|
||||
|
||||
+8
-4
@@ -204,10 +204,11 @@ type ClaudeToolChoice struct {
|
||||
}
|
||||
|
||||
type ClaudeRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []ClaudeMessage `json:"messages,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []ClaudeMessage `json:"messages,omitempty"`
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
// InferenceGeo controls Claude data residency region.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
|
||||
InferenceGeo string `json:"inference_geo,omitempty"`
|
||||
@@ -227,6 +228,9 @@ type ClaudeRequest struct {
|
||||
Thinking *Thinking `json:"thinking,omitempty"`
|
||||
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
// Speed specifies the Claude inference speed mode.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_speed.
|
||||
Speed json.RawMessage `json:"speed,omitempty"`
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
|
||||
@@ -272,7 +272,7 @@ type OpenAIResponsesResponse struct {
|
||||
Status json.RawMessage `json:"status"`
|
||||
Error any `json:"error,omitempty"`
|
||||
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
|
||||
Instructions string `json:"instructions"`
|
||||
Instructions json.RawMessage `json:"instructions"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Model string `json:"model"`
|
||||
Output []ResponsesOutput `json:"output"`
|
||||
|
||||
@@ -8,9 +8,9 @@ require (
|
||||
github.com/abema/go-mp4 v1.4.1
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/bytedance/gopkg v0.1.3
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
@@ -63,9 +63,9 @@ require (
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
|
||||
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -28,6 +28,18 @@ const (
|
||||
MsgBatchTooMany = "common.batch_too_many"
|
||||
)
|
||||
|
||||
// Auth middleware messages
|
||||
const (
|
||||
MsgAuthNotLoggedIn = "auth.not_logged_in"
|
||||
MsgAuthAccessTokenInvalid = "auth.access_token_invalid"
|
||||
MsgAuthUserInfoInvalid = "auth.user_info_invalid"
|
||||
MsgAuthUserIdNotProvided = "auth.user_id_not_provided"
|
||||
MsgAuthUserIdFormatError = "auth.user_id_format_error"
|
||||
MsgAuthUserIdMismatch = "auth.user_id_mismatch"
|
||||
MsgAuthUserBanned = "auth.user_banned"
|
||||
MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
|
||||
)
|
||||
|
||||
// Token related messages
|
||||
const (
|
||||
MsgTokenNameTooLong = "token.name_too_long"
|
||||
@@ -101,6 +113,7 @@ const (
|
||||
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
|
||||
MsgUserTelegramNotBound = "user.telegram_not_bound"
|
||||
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
|
||||
MsgUserQuotaChangeZero = "user.quota_change_zero"
|
||||
)
|
||||
|
||||
// Quota related messages
|
||||
|
||||
+12
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "Invalid parameters"
|
||||
common.database_error: "Database error, please try again later"
|
||||
common.database_error: "Database error, please contact the administrator"
|
||||
common.retry_later: "Please try again later"
|
||||
common.generate_failed: "Generation failed"
|
||||
common.not_found: "Not found"
|
||||
@@ -23,6 +23,16 @@ common.already_exists: "Already exists"
|
||||
common.name_cannot_be_empty: "Name cannot be empty"
|
||||
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
|
||||
auth.access_token_invalid: "Unauthorized, invalid access token"
|
||||
auth.user_info_invalid: "Unauthorized, invalid user info"
|
||||
auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
|
||||
auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
|
||||
auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
|
||||
auth.user_banned: "User has been banned"
|
||||
auth.insufficient_privilege: "Unauthorized, insufficient privileges"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "Token name is too long"
|
||||
token.quota_negative: "Quota value cannot be negative"
|
||||
@@ -91,6 +101,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
|
||||
user.telegram_id_empty: "Telegram ID is empty!"
|
||||
user.telegram_not_bound: "This Telegram account is not bound"
|
||||
user.linux_do_id_empty: "Linux DO ID is empty!"
|
||||
user.quota_change_zero: "Quota change amount cannot be zero"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "Quota cannot be negative!"
|
||||
|
||||
+12
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "无效的参数"
|
||||
common.database_error: "数据库错误,请稍后重试"
|
||||
common.database_error: "数据库出错,请联系管理员"
|
||||
common.retry_later: "请稍后重试"
|
||||
common.generate_failed: "生成失败"
|
||||
common.not_found: "未找到"
|
||||
@@ -24,6 +24,16 @@ common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名称不能为空"
|
||||
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
|
||||
auth.access_token_invalid: "无权进行此操作,access token 无效"
|
||||
auth.user_info_invalid: "无权进行此操作,用户信息无效"
|
||||
auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
|
||||
auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
|
||||
auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
|
||||
auth.user_banned: "用户已被封禁"
|
||||
auth.insufficient_privilege: "无权进行此操作,权限不足"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名称过长"
|
||||
token.quota_negative: "额度值不能为负数"
|
||||
@@ -92,6 +102,7 @@ user.wechat_id_empty: "WeChat id 为空!"
|
||||
user.telegram_id_empty: "Telegram id 为空!"
|
||||
user.telegram_not_bound: "该 Telegram 账户未绑定"
|
||||
user.linux_do_id_empty: "Linux DO id 为空!"
|
||||
user.quota_change_zero: "额度变更量不能为0"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "额度不能为负数!"
|
||||
|
||||
+12
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "無效的參數"
|
||||
common.database_error: "資料庫錯誤,請稍後重試"
|
||||
common.database_error: "資料庫出錯,請聯繫管理員"
|
||||
common.retry_later: "請稍後重試"
|
||||
common.generate_failed: "生成失敗"
|
||||
common.not_found: "未找到"
|
||||
@@ -24,6 +24,16 @@ common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名稱不能為空"
|
||||
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
|
||||
auth.access_token_invalid: "無權進行此操作,access token 無效"
|
||||
auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
|
||||
auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
|
||||
auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
|
||||
auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
|
||||
auth.user_banned: "使用者已被封禁"
|
||||
auth.insufficient_privilege: "無權進行此操作,權限不足"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名稱過長"
|
||||
token.quota_negative: "額度值不能為負數"
|
||||
@@ -92,6 +102,7 @@ user.wechat_id_empty: "WeChat id 為空!"
|
||||
user.telegram_id_empty: "Telegram id 為空!"
|
||||
user.telegram_not_bound: "該 Telegram 帳號未綁定"
|
||||
user.linux_do_id_empty: "Linux DO id 為空!"
|
||||
user.quota_change_zero: "額度變更量不能為0"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "額度不能為負數!"
|
||||
|
||||
+57
-20
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func validUserInfo(username string, role int) bool {
|
||||
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if accessToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录且未提供 access token",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user := model.ValidateAccessToken(accessToken)
|
||||
user, authErr := model.ValidateAccessToken(accessToken)
|
||||
if authErr != nil {
|
||||
if errors.Is(authErr, model.ErrDatabase) {
|
||||
common.SysLog("ValidateAccessToken database error: " + authErr.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
|
||||
})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user != nil && user.Username != "" {
|
||||
if !validUserInfo(user.Username, user.Role) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,用户信息无效",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,access token 无效",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if apiUserIdStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未提供 New-Api-User",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,New-Api-User 格式错误",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if id != apiUserId {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,New-Api-User 与登录用户不匹配",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if status.(int) == common.UserStatusDisabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已被封禁",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if role.(int) < minRole {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,权限不足",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if !validUserInfo(username.(string), role.(int)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,用户信息无效",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
if key == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未提供 Authorization 请求头",
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
|
||||
token, err := model.GetTokenByKey(key, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的令牌",
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
|
||||
})
|
||||
} else {
|
||||
common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userCache, err := model.GetUserCache(token.UserId)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
if userCache.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已被封禁",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
|
||||
if errors.Is(err, model.ErrDatabase) {
|
||||
common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError,
|
||||
common.TranslateMessage(c, i18n.MsgDatabaseError))
|
||||
} else {
|
||||
abortWithOpenAiMessage(c, http.StatusUnauthorized,
|
||||
common.TranslateMessage(c, i18n.MsgTokenInvalid))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
|
||||
|
||||
userCache, err := model.GetUserCache(token.UserId)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
|
||||
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError,
|
||||
common.TranslateMessage(c, i18n.MsgDatabaseError))
|
||||
return
|
||||
}
|
||||
userEnabled := userCache.Status == common.UserStatusEnabled
|
||||
if !userEnabled {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁")
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrDatabase = errors.New("database error")
|
||||
)
|
||||
|
||||
// User auth errors
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUserEmptyCredentials = errors.New("empty credentials")
|
||||
)
|
||||
|
||||
// Token auth errors
|
||||
var (
|
||||
ErrTokenNotProvided = errors.New("token not provided")
|
||||
ErrTokenInvalid = errors.New("token invalid")
|
||||
)
|
||||
|
||||
// Redemption errors
|
||||
var ErrRedeemFailed = errors.New("redeem.failed")
|
||||
|
||||
// 2FA errors
|
||||
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
|
||||
+4
-1
@@ -62,6 +62,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["SMTPAccount"] = ""
|
||||
common.OptionMap["SMTPToken"] = ""
|
||||
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
|
||||
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
|
||||
common.OptionMap["Notice"] = ""
|
||||
common.OptionMap["About"] = ""
|
||||
common.OptionMap["HomePageContent"] = ""
|
||||
@@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
common.ImageDownloadPermission = intValue
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
|
||||
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
|
||||
boolValue := value == "true"
|
||||
switch key {
|
||||
case "PasswordRegisterEnabled":
|
||||
@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.StopOnSensitiveEnabled = boolValue
|
||||
case "SMTPSSLEnabled":
|
||||
common.SMTPSSLEnabled = boolValue
|
||||
case "SMTPForceAuthLogin":
|
||||
common.SMTPForceAuthLogin = boolValue
|
||||
case "WorkerAllowHttpImageRequestEnabled":
|
||||
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
|
||||
case "DefaultUseAutoGroup":
|
||||
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrRedeemFailed is returned when redemption fails due to database error
|
||||
var ErrRedeemFailed = errors.New("redeem.failed")
|
||||
|
||||
type Redemption struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
|
||||
+9
-18
@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
|
||||
|
||||
func ValidateUserToken(key string) (token *Token, err error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("未提供令牌")
|
||||
return nil, ErrTokenNotProvided
|
||||
}
|
||||
token, err = GetTokenByKey(key, false)
|
||||
if err == nil {
|
||||
if token.Status == common.TokenStatusExhausted {
|
||||
keyPrefix := key[:3]
|
||||
keySuffix := key[len(key)-3:]
|
||||
return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]")
|
||||
} else if token.Status == common.TokenStatusExpired {
|
||||
return token, errors.New("该令牌已过期")
|
||||
}
|
||||
if token.Status != common.TokenStatusEnabled {
|
||||
return token, errors.New("该令牌状态不可用")
|
||||
if token.Status == common.TokenStatusExhausted ||
|
||||
token.Status == common.TokenStatusExpired ||
|
||||
token.Status != common.TokenStatusEnabled {
|
||||
return token, ErrTokenInvalid
|
||||
}
|
||||
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
|
||||
if !common.RedisEnabled {
|
||||
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
|
||||
common.SysLog("failed to update token status" + err.Error())
|
||||
}
|
||||
}
|
||||
return token, errors.New("该令牌已过期")
|
||||
return token, ErrTokenInvalid
|
||||
}
|
||||
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
|
||||
if !common.RedisEnabled {
|
||||
// in this case, we can make sure the token is exhausted
|
||||
token.Status = common.TokenStatusExhausted
|
||||
err := token.SelectUpdate()
|
||||
if err != nil {
|
||||
common.SysLog("failed to update token status" + err.Error())
|
||||
}
|
||||
}
|
||||
keyPrefix := key[:3]
|
||||
keySuffix := key[len(key)-3:]
|
||||
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
|
||||
return token, ErrTokenInvalid
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("无效的令牌")
|
||||
} else {
|
||||
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
|
||||
func GetTokenByIds(id int, userId int) (*Token, error) {
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
|
||||
|
||||
// TwoFA 用户2FA设置表
|
||||
type TwoFA struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
|
||||
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
|
||||
return quotaDatas, err
|
||||
}
|
||||
|
||||
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
|
||||
var quotaDatas []*QuotaData
|
||||
err = DB.Table("quota_data").
|
||||
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
|
||||
Where("created_at >= ? and created_at <= ?", startTime, endTime).
|
||||
Group("username, created_at").
|
||||
Find("aDatas).Error
|
||||
return quotaDatas, err
|
||||
}
|
||||
|
||||
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
|
||||
if username != "" {
|
||||
return GetQuotaDataByUsername(username, startTime, endTime)
|
||||
|
||||
+23
-14
@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
|
||||
"username": newUser.Username,
|
||||
"display_name": newUser.DisplayName,
|
||||
"group": newUser.Group,
|
||||
"quota": newUser.Quota,
|
||||
"remark": newUser.Remark,
|
||||
}
|
||||
if updatePassword {
|
||||
@@ -598,13 +597,19 @@ func (user *User) ValidateAndFill() (err error) {
|
||||
password := user.Password
|
||||
username := strings.TrimSpace(user.Username)
|
||||
if username == "" || password == "" {
|
||||
return errors.New("用户名或密码为空")
|
||||
return ErrUserEmptyCredentials
|
||||
}
|
||||
// find by username or email
|
||||
err = DB.Where("username = ? OR email = ?", username, username).First(user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
// find buy username or email
|
||||
DB.Where("username = ? OR email = ?", username, username).First(user)
|
||||
okay := common.ValidatePasswordAndHash(password, user.Password)
|
||||
if !okay || user.Status != common.UserStatusEnabled {
|
||||
return errors.New("用户名或密码错误,或用户已被封禁")
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -755,16 +760,20 @@ func IsAdmin(userId int) bool {
|
||||
// return user.Status == common.UserStatusEnabled, nil
|
||||
//}
|
||||
|
||||
func ValidateAccessToken(token string) (user *User) {
|
||||
func ValidateAccessToken(token string) (*User, error) {
|
||||
if token == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
token = strings.Replace(token, "Bearer ", "", 1)
|
||||
user = &User{}
|
||||
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
|
||||
return user
|
||||
user := &User{}
|
||||
err := DB.Where("access_token = ?", token).First(user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserQuota gets quota from Redis first, falls back to DB if needed
|
||||
@@ -896,7 +905,7 @@ func increaseUserQuota(id int, quota int) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func DecreaseUserQuota(id int, quota int) (err error) {
|
||||
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
@@ -906,7 +915,7 @@ func DecreaseUserQuota(id int, quota int) (err error) {
|
||||
common.SysLog("failed to decrease user quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
if common.BatchUpdateEnabled {
|
||||
if !db && common.BatchUpdateEnabled {
|
||||
addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
|
||||
return nil
|
||||
}
|
||||
@@ -928,7 +937,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
|
||||
if delta > 0 {
|
||||
return IncreaseUserQuota(id, delta, false)
|
||||
} else {
|
||||
return DecreaseUserQuota(id, -delta)
|
||||
return DecreaseUserQuota(id, -delta, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
Type: "adaptive",
|
||||
}
|
||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
claudeRequest.TopP = common.GetPointer[float64](0)
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||
@@ -809,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
||||
if common.DebugEnabled {
|
||||
common.SysLog("claude response usage is not complete, maybe upstream error")
|
||||
}
|
||||
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
|
||||
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
|
||||
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
if claudeInfo.Usage.CompletionTokens == 0 ||
|
||||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
|
||||
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
|
||||
}
|
||||
if claudeInfo.Usage.PromptTokens == 0 {
|
||||
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
|
||||
}
|
||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||
}
|
||||
if claudeInfo.Usage != nil {
|
||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||
|
||||
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
return request, nil
|
||||
if info.RelayMode != constant.RelayModeImagesGenerations {
|
||||
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
|
||||
}
|
||||
return oaiImage2MiniMaxImageRequest(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
if info.RelayMode == constant.RelayModeAudioSpeech {
|
||||
return handleTTSResponse(c, resp, info)
|
||||
}
|
||||
if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
return miniMaxImageHandler(c, resp, info)
|
||||
}
|
||||
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestGetRequestURLForImageGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesGenerations,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: "https://api.minimax.chat",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := GetRequestURL(info)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestURL returned error: %v", err)
|
||||
}
|
||||
|
||||
want := "https://api.minimax.chat/v1/image_generation"
|
||||
if got != want {
|
||||
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertImageRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adaptor := &Adaptor{}
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesGenerations,
|
||||
OriginModelName: "image-01",
|
||||
}
|
||||
request := dto.ImageRequest{
|
||||
Model: "image-01",
|
||||
Prompt: "a red fox in snowfall",
|
||||
Size: "1536x1024",
|
||||
ResponseFormat: "url",
|
||||
N: uintPtr(2),
|
||||
}
|
||||
|
||||
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertImageRequest returned error: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
|
||||
if payload["model"] != "image-01" {
|
||||
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
|
||||
}
|
||||
if payload["prompt"] != request.Prompt {
|
||||
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
|
||||
}
|
||||
if payload["n"] != float64(2) {
|
||||
t.Fatalf("n = %#v, want 2", payload["n"])
|
||||
}
|
||||
if payload["aspect_ratio"] != "3:2" {
|
||||
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
|
||||
}
|
||||
if payload["response_format"] != "url" {
|
||||
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoResponseForImageGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesGenerations,
|
||||
StartTime: time.Unix(1700000000, 0),
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: httptest.NewRecorder().Result().Body,
|
||||
}
|
||||
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
|
||||
|
||||
adaptor := &Adaptor{}
|
||||
usage, err := adaptor.DoResponse(c, resp, info)
|
||||
if err != nil {
|
||||
t.Fatalf("DoResponse returned error: %v", err)
|
||||
}
|
||||
if usage == nil {
|
||||
t.Fatalf("DoResponse returned nil usage")
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
|
||||
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
|
||||
}
|
||||
if strings.Contains(body, `"image_urls"`) {
|
||||
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
|
||||
}
|
||||
}
|
||||
|
||||
type nopReadCloser struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (n nopReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ioNopCloser(body string) nopReadCloser {
|
||||
return nopReadCloser{Reader: strings.NewReader(body)}
|
||||
}
|
||||
|
||||
func uintPtr(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
@@ -8,6 +8,8 @@ var ModelList = []string{
|
||||
"abab6-chat",
|
||||
"abab5.5-chat",
|
||||
"abab5.5s-chat",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"speech-2.5-hd-preview",
|
||||
"speech-2.5-turbo-preview",
|
||||
"speech-02-hd",
|
||||
@@ -19,6 +21,8 @@ var ModelList = []string{
|
||||
"MiniMax-M2",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"image-01",
|
||||
"image-01-live",
|
||||
}
|
||||
|
||||
var ChannelName = "minimax"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MiniMaxImageRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
|
||||
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
|
||||
}
|
||||
|
||||
type MiniMaxImageResponse struct {
|
||||
ID string `json:"id"`
|
||||
Data struct {
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
ImageBase64 []string `json:"image_base64"`
|
||||
} `json:"data"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
}
|
||||
|
||||
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
|
||||
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
|
||||
minimaxRequest := MiniMaxImageRequest{
|
||||
Model: request.Model,
|
||||
Prompt: request.Prompt,
|
||||
ResponseFormat: responseFormat,
|
||||
N: 1,
|
||||
AigcWatermark: request.Watermark,
|
||||
}
|
||||
|
||||
if request.Model == "" {
|
||||
minimaxRequest.Model = "image-01"
|
||||
}
|
||||
if request.N != nil && *request.N > 0 {
|
||||
minimaxRequest.N = int(*request.N)
|
||||
}
|
||||
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
|
||||
minimaxRequest.AspectRatio = aspectRatio
|
||||
}
|
||||
if raw, ok := request.Extra["prompt_optimizer"]; ok {
|
||||
var promptOptimizer bool
|
||||
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
|
||||
minimaxRequest.PromptOptimizer = &promptOptimizer
|
||||
}
|
||||
}
|
||||
|
||||
return minimaxRequest
|
||||
}
|
||||
|
||||
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
|
||||
if raw, ok := request.Extra["aspect_ratio"]; ok {
|
||||
var aspectRatio string
|
||||
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
|
||||
return aspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
switch request.Size {
|
||||
case "1024x1024":
|
||||
return "1:1"
|
||||
case "1792x1024":
|
||||
return "16:9"
|
||||
case "1024x1792":
|
||||
return "9:16"
|
||||
case "1536x1024", "1248x832":
|
||||
return "3:2"
|
||||
case "1024x1536", "832x1248":
|
||||
return "2:3"
|
||||
case "1152x864":
|
||||
return "4:3"
|
||||
case "864x1152":
|
||||
return "3:4"
|
||||
case "1344x576":
|
||||
return "21:9"
|
||||
}
|
||||
|
||||
width, height, ok := parseImageSize(request.Size)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
ratio := reduceAspectRatio(width, height)
|
||||
switch ratio {
|
||||
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
|
||||
return ratio
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseImageSize(size string) (int, int, bool) {
|
||||
parts := strings.Split(size, "x")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
width, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
height, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return width, height, true
|
||||
}
|
||||
|
||||
func reduceAspectRatio(width, height int) string {
|
||||
divisor := gcd(width, height)
|
||||
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
|
||||
}
|
||||
|
||||
func gcd(a, b int) int {
|
||||
for b != 0 {
|
||||
a, b = b, a%b
|
||||
}
|
||||
if a == 0 {
|
||||
return 1
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func normalizeMiniMaxResponseFormat(responseFormat string) string {
|
||||
switch strings.ToLower(responseFormat) {
|
||||
case "", "url":
|
||||
return "url"
|
||||
case "b64_json", "base64":
|
||||
return "base64"
|
||||
default:
|
||||
return responseFormat
|
||||
}
|
||||
}
|
||||
|
||||
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
|
||||
imageResponse := &dto.ImageResponse{
|
||||
Created: info.StartTime.Unix(),
|
||||
}
|
||||
|
||||
for _, imageURL := range response.Data.ImageURLs {
|
||||
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
|
||||
}
|
||||
for _, imageBase64 := range response.Data.ImageBase64 {
|
||||
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
|
||||
}
|
||||
if len(response.Metadata) > 0 {
|
||||
metadata, err := common.Marshal(response.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imageResponse.Metadata = metadata
|
||||
}
|
||||
|
||||
return imageResponse, nil
|
||||
}
|
||||
|
||||
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
var minimaxResponse MiniMaxImageResponse
|
||||
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if minimaxResponse.BaseResp.StatusCode != 0 {
|
||||
return nil, types.WithOpenAIError(types.OpenAIError{
|
||||
Message: minimaxResponse.BaseResp.StatusMsg,
|
||||
Type: "minimax_image_error",
|
||||
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
|
||||
}, resp.StatusCode)
|
||||
}
|
||||
|
||||
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
jsonResponse, err := common.Marshal(openAIResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
if _, err := c.Writer.Write(jsonResponse); err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
return &dto.Usage{}, nil
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
|
||||
case constant.RelayModeImagesGenerations:
|
||||
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
|
||||
case constant.RelayModeAudioSpeech:
|
||||
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
|
||||
default:
|
||||
|
||||
@@ -136,8 +136,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
task = "chat/completions" + task
|
||||
}
|
||||
|
||||
// 特殊处理 responses API
|
||||
if info.RelayMode == relayconstant.RelayModeResponses {
|
||||
// 特殊处理 responses API(包含 compact)
|
||||
if info.RelayMode == relayconstant.RelayModeResponses || info.RelayMode == relayconstant.RelayModeResponsesCompact {
|
||||
responsesApiVersion := "preview"
|
||||
|
||||
subUrl := "/openai/v1/responses"
|
||||
@@ -150,6 +150,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
|
||||
}
|
||||
|
||||
// compact 模式追加 /compact
|
||||
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
|
||||
subUrl = subUrl + "/compact"
|
||||
}
|
||||
|
||||
requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion)
|
||||
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
|
||||
}
|
||||
@@ -369,7 +374,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
a.ResponseFormat = request.ResponseFormat
|
||||
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
|
||||
jsonData, err := json.Marshal(request)
|
||||
jsonData, err := common.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling object: %w", err)
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
|
||||
|
||||
// AliUsage 使用统计
|
||||
type AliUsage struct {
|
||||
Duration int `json:"duration,omitempty"`
|
||||
VideoCount int `json:"video_count,omitempty"`
|
||||
SR int `json:"SR,omitempty"`
|
||||
Duration dto.IntValue `json:"duration,omitempty"`
|
||||
VideoCount dto.IntValue `json:"video_count,omitempty"`
|
||||
SR dto.IntValue `json:"SR,omitempty"`
|
||||
}
|
||||
|
||||
type AliMetadata struct {
|
||||
|
||||
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
|
||||
case relayconstant.RelayModeImagesGenerations:
|
||||
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
|
||||
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
|
||||
default:
|
||||
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
|
||||
|
||||
@@ -32,6 +32,7 @@ var paramOverrideKeyAuditPaths = map[string]struct{}{
|
||||
"upstream_model": {},
|
||||
"service_tier": {},
|
||||
"inference_geo": {},
|
||||
"speed": {},
|
||||
}
|
||||
|
||||
type paramOverrideAuditRecorder struct {
|
||||
|
||||
@@ -2038,6 +2038,8 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
|
||||
input := `{
|
||||
"service_tier":"flex",
|
||||
"inference_geo":"eu",
|
||||
"speed":"fast",
|
||||
"cache_control":{"type":"ephemeral"},
|
||||
"safety_identifier":"user-123",
|
||||
"store":true,
|
||||
"stream_options":{"include_obfuscation":false}
|
||||
@@ -2048,7 +2050,7 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"store":true}`, string(out))
|
||||
assertJSONEqual(t, `{"cache_control":{"type":"ephemeral"},"store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
|
||||
@@ -2067,6 +2069,22 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
|
||||
assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsAllowSpeed(t *testing.T) {
|
||||
input := `{
|
||||
"speed":"fast",
|
||||
"store":true
|
||||
}`
|
||||
settings := dto.ChannelOtherSettings{
|
||||
AllowSpeed: true,
|
||||
}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"speed":"fast","store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -437,6 +438,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
if request != nil {
|
||||
isStream = request.IsStream(c)
|
||||
}
|
||||
c.Set(string(constant.ContextKeyIsStream), isStream)
|
||||
|
||||
// firstResponseTime = time.Now() - 1 second
|
||||
|
||||
@@ -690,6 +692,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
|
||||
type Alias TaskSubmitReq
|
||||
aux := &struct {
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Duration json.RawMessage `json:"duration,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(t),
|
||||
@@ -699,6 +702,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(aux.Duration) > 0 {
|
||||
var durationInt int
|
||||
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
|
||||
t.Duration = durationInt
|
||||
} else {
|
||||
var durationStr string
|
||||
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
|
||||
if v, err := strconv.Atoi(durationStr); err == nil {
|
||||
t.Duration = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(aux.Metadata) > 0 {
|
||||
var metadataStr string
|
||||
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
|
||||
@@ -754,6 +771,7 @@ func FailTaskInfo(reason string) *TaskInfo {
|
||||
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
|
||||
// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持)
|
||||
// inference_geo: Claude 数据驻留推理区域字段(仅 Claude 支持,默认过滤)
|
||||
// speed: Claude 推理速度模式字段(仅 Claude 支持,默认过滤)
|
||||
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
|
||||
// stream_options.include_obfuscation: 响应流混淆控制字段(仅 OpenAI Responses API 支持)
|
||||
@@ -782,6 +800,13 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
}
|
||||
}
|
||||
|
||||
// 默认移除 speed,除非明确允许(避免意外切换 Claude 推理速度模式)
|
||||
if !channelOtherSettings.AllowSpeed {
|
||||
if _, exists := data["speed"]; exists {
|
||||
delete(data, "speed")
|
||||
}
|
||||
}
|
||||
|
||||
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
|
||||
if channelOtherSettings.DisableStore {
|
||||
if _, exists := data["store"]; exists {
|
||||
|
||||
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
|
||||
if err != nil {
|
||||
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
|
||||
}
|
||||
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
}
|
||||
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
@@ -13,6 +14,21 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func modelPriceNotConfiguredError(modelName string, userId int) error {
|
||||
if model.IsAdmin(userId) {
|
||||
return fmt.Errorf(
|
||||
"模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+
|
||||
"Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.",
|
||||
modelName, modelName,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+
|
||||
"Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.",
|
||||
modelName, modelName,
|
||||
)
|
||||
}
|
||||
|
||||
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
|
||||
const claudeCacheCreation1hMultiplier = 6 / 3.75
|
||||
|
||||
@@ -75,7 +91,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !acceptUnsetRatio {
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
|
||||
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
|
||||
}
|
||||
}
|
||||
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
|
||||
@@ -161,7 +177,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !ratioSuccess && !acceptUnsetRatio {
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
|
||||
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
if err != nil {
|
||||
info.OriginModelName = originModelName
|
||||
info.PriceData = originPriceData
|
||||
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
|
||||
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest))
|
||||
}
|
||||
service.PostTextConsumeQuota(c, info, usageDto, nil)
|
||||
|
||||
|
||||
@@ -293,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
|
||||
dataRoute := apiRouter.Group("/data")
|
||||
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
|
||||
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
|
||||
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
|
||||
|
||||
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
|
||||
|
||||
+6
-8
@@ -2,11 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -63,12 +61,12 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
//if err.StatusCode == http.StatusUnauthorized {
|
||||
// return true
|
||||
//}
|
||||
if err.StatusCode == http.StatusForbidden {
|
||||
switch channelType {
|
||||
case constant.ChannelTypeGemini:
|
||||
return true
|
||||
}
|
||||
}
|
||||
//if err.StatusCode == http.StatusForbidden {
|
||||
// switch channelType {
|
||||
// case constant.ChannelTypeGemini:
|
||||
// return true
|
||||
// }
|
||||
//}
|
||||
oaiErr := err.ToOpenAIError()
|
||||
switch oaiErr.Code {
|
||||
case "invalid_api_key":
|
||||
|
||||
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
if rule.IncludeModelName {
|
||||
if len(parts) < 3 {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
minParts := 3
|
||||
if rule.IncludeModelName {
|
||||
minParts = 4
|
||||
}
|
||||
if len(parts) < minParts {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
byRuleName[ruleName]++
|
||||
}
|
||||
|
||||
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
|
||||
}
|
||||
}
|
||||
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if rule.IncludeRuleName && rule.Name != "" {
|
||||
parts = append(parts, rule.Name)
|
||||
}
|
||||
if rule.IncludeModelName && modelName != "" {
|
||||
parts = append(parts, modelName)
|
||||
}
|
||||
if rule.IncludeUsingGroup && usingGroup != "" {
|
||||
parts = append(parts, usingGroup)
|
||||
}
|
||||
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
|
||||
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
|
||||
setChannelAffinityContext(c, channelAffinityMeta{
|
||||
CacheKey: cacheKeyFull,
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||
require.NotNil(t, codexRule)
|
||||
|
||||
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
|
||||
|
||||
@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
|
||||
if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
|
||||
return err
|
||||
}
|
||||
w.consumed = amount
|
||||
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
|
||||
return nil
|
||||
}
|
||||
if delta > 0 {
|
||||
return model.DecreaseUserQuota(w.userId, delta)
|
||||
return model.DecreaseUserQuota(w.userId, delta, false)
|
||||
}
|
||||
return model.IncreaseUserQuota(w.userId, -delta, false)
|
||||
}
|
||||
|
||||
+1
-1
@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
|
||||
} else {
|
||||
// Wallet
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
|
||||
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
|
||||
}
|
||||
if delta > 0 {
|
||||
return model.DecreaseUserQuota(task.UserId, delta)
|
||||
return model.DecreaseUserQuota(task.UserId, delta, false)
|
||||
}
|
||||
return model.IncreaseUserQuota(task.UserId, -delta, false)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
|
||||
|
||||
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
|
||||
|
||||
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
|
||||
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
|
||||
|
||||
IncludeUsingGroup bool `json:"include_using_group"`
|
||||
IncludeModelName bool `json:"include_model_name"`
|
||||
IncludeRuleName bool `json:"include_rule_name"`
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,10 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
|
||||
func GetModelPrice(name string, printErr bool) (float64, bool) {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
if price, ok := modelPriceMap.Get(name); ok {
|
||||
return price, true
|
||||
}
|
||||
|
||||
if strings.HasSuffix(name, CompactModelSuffix) {
|
||||
price, ok := modelPriceMap.Get(CompactWildcardModelKey)
|
||||
if !ok {
|
||||
@@ -372,14 +376,10 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
|
||||
return price, true
|
||||
}
|
||||
|
||||
price, ok := modelPriceMap.Get(name)
|
||||
if !ok {
|
||||
if printErr {
|
||||
common.SysError("model price not found: " + name)
|
||||
}
|
||||
return -1, false
|
||||
if printErr {
|
||||
common.SysError("model price not found: " + name)
|
||||
}
|
||||
return price, true
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func UpdateModelRatioByJSONString(jsonStr string) error {
|
||||
|
||||
@@ -390,6 +390,12 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
|
||||
}
|
||||
}
|
||||
|
||||
func ErrOptionWithStatusCode(statusCode int) NewAPIErrorOptions {
|
||||
return func(e *NewAPIError) {
|
||||
e.StatusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions {
|
||||
return func(e *NewAPIError) {
|
||||
if common.DebugEnabled {
|
||||
|
||||
Vendored
+5
-5
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.12.0",
|
||||
"axios": "1.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
@@ -776,7 +776,7 @@
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
|
||||
|
||||
"axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
|
||||
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||
|
||||
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
|
||||
|
||||
@@ -1104,13 +1104,13 @@
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||
|
||||
@@ -1656,7 +1656,7 @@
|
||||
|
||||
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.13.5",
|
||||
"axios": "1.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
const { Title } = Typography;
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
// 检查是否为 URL
|
||||
// Check whether content is a URL.
|
||||
const isUrl = (content) => {
|
||||
try {
|
||||
new URL(content.trim());
|
||||
@@ -38,27 +38,23 @@ const isUrl = (content) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否为 HTML 内容
|
||||
// Check whether content contains HTML.
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
};
|
||||
|
||||
// 安全地渲染HTML内容
|
||||
// Parse HTML content and extract inline styles.
|
||||
const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map((style) => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [htmlStyles, setHtmlStyles] = useState('');
|
||||
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
|
||||
|
||||
const loadContent = async () => {
|
||||
// 先从缓存中获取
|
||||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
||||
if (cachedContent) {
|
||||
setContent(cachedContent);
|
||||
processContent(cachedContent);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success && data) {
|
||||
setContent(data);
|
||||
processContent(data);
|
||||
localStorage.setItem(cacheKey, data);
|
||||
} else {
|
||||
if (!cachedContent) {
|
||||
@@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (rawContent) => {
|
||||
if (isHtmlContent(rawContent)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
|
||||
setProcessedHtmlContent(htmlContent);
|
||||
setHtmlStyles(styles);
|
||||
} else {
|
||||
setProcessedHtmlContent('');
|
||||
setHtmlStyles('');
|
||||
const htmlPayload = useMemo(() => {
|
||||
if (!isHtmlContent(content)) {
|
||||
return { content: '', styles: '' };
|
||||
}
|
||||
};
|
||||
return sanitizeHtml(content);
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
const { styles } = htmlPayload;
|
||||
|
||||
if (htmlStyles) {
|
||||
if (styles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
styleEl.type = 'text/css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.innerHTML = htmlStyles;
|
||||
styleEl.innerHTML = styles;
|
||||
} else {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
};
|
||||
}, [htmlStyles, cacheKey]);
|
||||
}, [cacheKey, htmlPayload]);
|
||||
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
@@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Empty, Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationFailure,
|
||||
IllustrationFailureDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('[ErrorBoundary]', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationFailure style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
description={t('页面渲染出错,请刷新页面重试')}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('刷新页面')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
||||
import { Server, Gauge, ExternalLink } from 'lucide-react';
|
||||
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
<div className='flex items-center gap-1 mb-1'>
|
||||
<span
|
||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
</span>
|
||||
<Copy
|
||||
size={14}
|
||||
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-gray-500'>{api.description}</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,9 @@ const ChartsPanel = ({
|
||||
spec_model_line,
|
||||
spec_pie,
|
||||
spec_rank_bar,
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
isAdminUser,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG,
|
||||
FLEX_CENTER_GAP2,
|
||||
@@ -51,9 +54,15 @@ const ChartsPanel = ({
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
|
||||
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
||||
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
||||
{isAdminUser && (
|
||||
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
|
||||
)}
|
||||
{isAdminUser && (
|
||||
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
@@ -72,6 +81,12 @@ const ChartsPanel = ({
|
||||
{activeChartTab === '4' && (
|
||||
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '5' && isAdminUser && (
|
||||
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '6' && isAdminUser && (
|
||||
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -86,12 +86,22 @@ const Dashboard = () => {
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const loadUserData = async () => {
|
||||
if (dashboardData.isAdminUser) {
|
||||
const userData = await dashboardData.loadUserQuotaData();
|
||||
if (userData && userData.length > 0) {
|
||||
dashboardCharts.updateUserChartData(userData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
});
|
||||
await loadUserData();
|
||||
await dashboardData.loadUptimeData();
|
||||
};
|
||||
|
||||
@@ -100,10 +110,12 @@ const Dashboard = () => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
await loadUserData();
|
||||
};
|
||||
|
||||
const handleSearchConfirm = async () => {
|
||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||
await loadUserData();
|
||||
};
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
@@ -182,6 +194,9 @@ const Dashboard = () => {
|
||||
spec_model_line={dashboardCharts.spec_model_line}
|
||||
spec_pie={dashboardCharts.spec_pie}
|
||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||
spec_user_rank={dashboardCharts.spec_user_rank}
|
||||
spec_user_trend={dashboardCharts.spec_user_trend}
|
||||
isAdminUser={dashboardData.isAdminUser}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
|
||||
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
import FooterBar from './Footer';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import ErrorBoundary from '../common/ErrorBoundary';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
@@ -216,7 +217,9 @@ const PageLayout = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
{!shouldHideFooter && (
|
||||
<Layout.Footer
|
||||
|
||||
@@ -95,13 +95,15 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
||||
/>
|
||||
<span className='inline-flex'>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
||||
/>
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,8 +21,9 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
|
||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
||||
import ThinkingContent from './ThinkingContent';
|
||||
import { Loader2, Check, X } from 'lucide-react';
|
||||
import { Loader2, Check, X, Settings, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isAdmin } from '../../helpers/utils';
|
||||
|
||||
const MessageContent = ({
|
||||
message,
|
||||
@@ -64,6 +65,44 @@ const MessageContent = ({
|
||||
errorText = t('请求发生错误');
|
||||
}
|
||||
|
||||
if (message.errorCode === 'model_price_error') {
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<div
|
||||
className='rounded-lg p-3 space-y-2'
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<AlertTriangle size={16} className='text-orange-500 shrink-0' />
|
||||
<Typography.Text strong className='!text-[var(--semi-color-text-0)]'>
|
||||
{t('模型价格未配置')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Paragraph
|
||||
className='!text-[var(--semi-color-text-1)] !text-sm !mb-0'
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
>
|
||||
{errorText}
|
||||
</Typography.Paragraph>
|
||||
{isAdmin() && (
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='warning'
|
||||
icon={<Settings size={14} />}
|
||||
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
|
||||
>
|
||||
{t('前往设置')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<Typography.Text className='text-white'>{errorText}</Typography.Text>
|
||||
|
||||
@@ -91,6 +91,7 @@ const SystemSetting = () => {
|
||||
EmailDomainRestrictionEnabled: '',
|
||||
EmailAliasRestrictionEnabled: '',
|
||||
SMTPSSLEnabled: '',
|
||||
SMTPForceAuthLogin: '',
|
||||
EmailDomainWhitelist: [],
|
||||
TelegramOAuthEnabled: '',
|
||||
TelegramBotToken: '',
|
||||
@@ -182,6 +183,7 @@ const SystemSetting = () => {
|
||||
case 'EmailDomainRestrictionEnabled':
|
||||
case 'EmailAliasRestrictionEnabled':
|
||||
case 'SMTPSSLEnabled':
|
||||
case 'SMTPForceAuthLogin':
|
||||
case 'LinuxDOOAuthEnabled':
|
||||
case 'discord.enabled':
|
||||
case 'oidc.enabled':
|
||||
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
|
||||
>
|
||||
{t('启用SMTP SSL')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='SMTPForceAuthLogin'
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('SMTPForceAuthLogin', e)
|
||||
}
|
||||
>
|
||||
{t('强制使用 AUTH LOGIN')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
|
||||
|
||||
@@ -208,6 +208,7 @@ const EditChannelModal = (props) => {
|
||||
allow_safety_identifier: false,
|
||||
allow_include_obfuscation: false,
|
||||
allow_inference_geo: false,
|
||||
allow_speed: false,
|
||||
claude_beta_query: false,
|
||||
upstream_model_update_check_enabled: false,
|
||||
upstream_model_update_auto_sync_enabled: false,
|
||||
@@ -890,6 +891,7 @@ const EditChannelModal = (props) => {
|
||||
parsedSettings.allow_include_obfuscation || false;
|
||||
data.allow_inference_geo =
|
||||
parsedSettings.allow_inference_geo || false;
|
||||
data.allow_speed = parsedSettings.allow_speed || false;
|
||||
data.claude_beta_query = parsedSettings.claude_beta_query || false;
|
||||
data.upstream_model_update_check_enabled =
|
||||
parsedSettings.upstream_model_update_check_enabled === true;
|
||||
@@ -919,6 +921,7 @@ const EditChannelModal = (props) => {
|
||||
data.allow_safety_identifier = false;
|
||||
data.allow_include_obfuscation = false;
|
||||
data.allow_inference_geo = false;
|
||||
data.allow_speed = false;
|
||||
data.claude_beta_query = false;
|
||||
data.upstream_model_update_check_enabled = false;
|
||||
data.upstream_model_update_auto_sync_enabled = false;
|
||||
@@ -936,6 +939,7 @@ const EditChannelModal = (props) => {
|
||||
data.allow_safety_identifier = false;
|
||||
data.allow_include_obfuscation = false;
|
||||
data.allow_inference_geo = false;
|
||||
data.allow_speed = false;
|
||||
data.claude_beta_query = false;
|
||||
data.upstream_model_update_check_enabled = false;
|
||||
data.upstream_model_update_auto_sync_enabled = false;
|
||||
@@ -1776,6 +1780,7 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
if (localInputs.type === 14) {
|
||||
settings.allow_inference_geo = localInputs.allow_inference_geo === true;
|
||||
settings.allow_speed = localInputs.allow_speed === true;
|
||||
settings.claude_beta_query = localInputs.claude_beta_query === true;
|
||||
}
|
||||
}
|
||||
@@ -1823,6 +1828,7 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.allow_safety_identifier;
|
||||
delete localInputs.allow_include_obfuscation;
|
||||
delete localInputs.allow_inference_geo;
|
||||
delete localInputs.allow_speed;
|
||||
delete localInputs.claude_beta_query;
|
||||
delete localInputs.upstream_model_update_check_enabled;
|
||||
delete localInputs.upstream_model_update_auto_sync_enabled;
|
||||
@@ -2480,6 +2486,7 @@ const EditChannelModal = (props) => {
|
||||
</div>
|
||||
<Form.Switch field='allow_service_tier' label={t('允许 service_tier 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} />
|
||||
<Form.Switch field='allow_inference_geo' label={t('允许 inference_geo 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_inference_geo', value)} extraText={t('inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息')} />
|
||||
<Form.Switch field='allow_speed' label={t('允许 speed 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_speed', value)} extraText={t('speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式')} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
|
||||
@@ -168,17 +169,43 @@ const ModelTestModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
|
||||
{testResult.success ? t('成功') : t('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
|
||||
{testResult.success ? t('成功') : t('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{!testResult.success && testResult.message && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Typography.Text
|
||||
type='danger'
|
||||
size='small'
|
||||
className='break-all'
|
||||
style={{ maxWidth: '400px', fontSize: '12px' }}
|
||||
>
|
||||
{testResult.message}
|
||||
</Typography.Text>
|
||||
{testResult.errorCode === 'model_price_error' && (
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='warning'
|
||||
icon={<Settings size={12} />}
|
||||
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{t('前往设置')}
|
||||
</Button>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -360,7 +360,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
{
|
||||
title: t('索引'),
|
||||
dataIndex: 'index',
|
||||
render: (text) => `#${text}`,
|
||||
render: (text) => `#${Number(text) + 1}`,
|
||||
},
|
||||
// {
|
||||
// title: t('密钥预览'),
|
||||
|
||||
@@ -25,8 +25,12 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
getCurrencyConfig,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
quotaToDisplayAmount,
|
||||
displayAmountToQuota,
|
||||
} from '../../../../helpers/quota';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Button,
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
@@ -57,10 +62,12 @@ const EditRedemptionModal = (props) => {
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const [showQuotaInput, setShowQuotaInput] = useState(false);
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
quota: 100000,
|
||||
amount: Number(quotaToDisplayAmount(100000).toFixed(6)),
|
||||
count: 1,
|
||||
expired_time: null,
|
||||
});
|
||||
@@ -79,6 +86,7 @@ const EditRedemptionModal = (props) => {
|
||||
} else {
|
||||
data.expired_time = new Date(data.expired_time * 1000);
|
||||
}
|
||||
data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6));
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
@@ -104,7 +112,12 @@ const EditRedemptionModal = (props) => {
|
||||
setLoading(true);
|
||||
let localInputs = { ...values };
|
||||
localInputs.count = parseInt(localInputs.count) || 0;
|
||||
localInputs.quota = parseInt(localInputs.quota) || 0;
|
||||
localInputs.quota = displayAmountToQuota(localInputs.amount);
|
||||
if (localInputs.quota <= 0) {
|
||||
showError(t('请输入金额'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
localInputs.name = name;
|
||||
if (!localInputs.expired_time) {
|
||||
localInputs.expired_time = 0;
|
||||
@@ -285,37 +298,63 @@ const EditRedemptionModal = (props) => {
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
<Col span={24}>
|
||||
<Form.InputNumber
|
||||
field='amount'
|
||||
label={t('金额')}
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
precision={6}
|
||||
min={0}
|
||||
step={0.000001}
|
||||
style={{ width: '100%' }}
|
||||
type='number'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
extraText={renderQuotaWithPrompt(
|
||||
Number(values.quota) || 0,
|
||||
)}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const amount = val === '' || val == null ? 0 : val;
|
||||
formApiRef.current?.setValue('amount', amount);
|
||||
formApiRef.current?.setValue(
|
||||
'quota',
|
||||
displayAmountToQuota(amount),
|
||||
);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<div
|
||||
className='text-xs cursor-pointer mt-1'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
onClick={() => setShowQuotaInput((v) => !v)}
|
||||
>
|
||||
{showQuotaInput
|
||||
? `▾ ${t('收起原生额度输入')}`
|
||||
: `▸ ${t('使用原生额度输入')}`}
|
||||
</div>
|
||||
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
|
||||
<Form.InputNumber
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('输入额度')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const quota = val === '' || val == null ? 0 : val;
|
||||
formApiRef.current?.setValue('quota', quota);
|
||||
formApiRef.current?.setValue(
|
||||
'amount',
|
||||
Number(quotaToDisplayAmount(quota).toFixed(6)),
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={12}>
|
||||
|
||||
@@ -24,10 +24,14 @@ import {
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroupOption,
|
||||
renderQuotaWithPrompt,
|
||||
getCurrencyConfig,
|
||||
getModelCategories,
|
||||
selectFilter,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
quotaToDisplayAmount,
|
||||
displayAmountToQuota,
|
||||
} from '../../../../helpers/quota';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Button,
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
Form,
|
||||
Col,
|
||||
Row,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
@@ -62,11 +67,13 @@ const EditTokenModal = (props) => {
|
||||
const formApiRef = useRef(null);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [showQuotaInput, setShowQuotaInput] = useState(false);
|
||||
const isEdit = props.editingToken.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
remain_quota: 0,
|
||||
remain_amount: 0,
|
||||
expired_time: -1,
|
||||
unlimited_quota: true,
|
||||
model_limits_enabled: false,
|
||||
@@ -162,6 +169,9 @@ const EditTokenModal = (props) => {
|
||||
} else {
|
||||
data.model_limits = [];
|
||||
}
|
||||
data.remain_amount = Number(
|
||||
quotaToDisplayAmount(data.remain_quota || 0).toFixed(6),
|
||||
);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
@@ -209,7 +219,14 @@ const EditTokenModal = (props) => {
|
||||
setLoading(true);
|
||||
if (isEdit) {
|
||||
let { tokenCount: _tc, ...localInputs } = values;
|
||||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||
localInputs.remain_quota = localInputs.unlimited_quota
|
||||
? 0
|
||||
: displayAmountToQuota(localInputs.remain_amount);
|
||||
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
|
||||
showError(t('请输入金额'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (localInputs.expired_time !== -1) {
|
||||
let time = Date.parse(localInputs.expired_time);
|
||||
if (isNaN(time)) {
|
||||
@@ -245,7 +262,14 @@ const EditTokenModal = (props) => {
|
||||
} else {
|
||||
localInputs.name = baseName;
|
||||
}
|
||||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||
localInputs.remain_quota = localInputs.unlimited_quota
|
||||
? 0
|
||||
: displayAmountToQuota(localInputs.remain_amount);
|
||||
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
|
||||
showError(t('请输入金额'));
|
||||
setLoading(false);
|
||||
break;
|
||||
}
|
||||
|
||||
if (localInputs.expired_time !== -1) {
|
||||
let time = Date.parse(localInputs.expired_time);
|
||||
@@ -497,28 +521,63 @@ const EditTokenModal = (props) => {
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.AutoComplete
|
||||
field='remain_quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
type='number'
|
||||
<Form.InputNumber
|
||||
field='remain_amount'
|
||||
label={t('金额')}
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
precision={6}
|
||||
disabled={values.unlimited_quota}
|
||||
extraText={renderQuotaWithPrompt(values.remain_quota)}
|
||||
rules={
|
||||
values.unlimited_quota
|
||||
? []
|
||||
: [{ required: true, message: t('请输入额度') }]
|
||||
}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
min={0}
|
||||
step={0.000001}
|
||||
onChange={(val) => {
|
||||
const amount = val === '' || val == null ? 0 : val;
|
||||
formApiRef.current?.setValue('remain_amount', amount);
|
||||
formApiRef.current?.setValue(
|
||||
'remain_quota',
|
||||
displayAmountToQuota(amount),
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<div
|
||||
className='text-xs cursor-pointer mt-1'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
onClick={() => setShowQuotaInput((v) => !v)}
|
||||
>
|
||||
{showQuotaInput
|
||||
? `▾ ${t('收起原生额度输入')}`
|
||||
: `▸ ${t('使用原生额度输入')}`}
|
||||
</div>
|
||||
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
|
||||
<Form.InputNumber
|
||||
field='remain_quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('输入额度')}
|
||||
disabled={values.unlimited_quota}
|
||||
min={0}
|
||||
step={500000}
|
||||
rules={
|
||||
values.unlimited_quota
|
||||
? []
|
||||
: [{ required: true, message: t('请输入额度') }]
|
||||
}
|
||||
onChange={(val) => {
|
||||
const quota = val === '' || val == null ? 0 : val;
|
||||
formApiRef.current?.setValue('remain_quota', quota);
|
||||
formApiRef.current?.setValue(
|
||||
'remain_amount',
|
||||
Number(quotaToDisplayAmount(quota).toFixed(6)),
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='unlimited_quota'
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
getCurrencyConfig,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
@@ -46,6 +45,8 @@ import {
|
||||
Row,
|
||||
Col,
|
||||
InputNumber,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconUser,
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
IconClose,
|
||||
IconLink,
|
||||
IconUserGroup,
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import UserBindingManagementModal from './UserBindingManagementModal';
|
||||
|
||||
@@ -63,13 +64,18 @@ const EditUserModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const userId = props.editingUser.id;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
|
||||
const [addQuotaLocal, setAddQuotaLocal] = useState('');
|
||||
const [addAmountLocal, setAddAmountLocal] = useState('');
|
||||
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
|
||||
const [adjustQuotaLocal, setAdjustQuotaLocal] = useState('');
|
||||
const [adjustAmountLocal, setAdjustAmountLocal] = useState('');
|
||||
const [adjustMode, setAdjustMode] = useState('add');
|
||||
const [adjustLoading, setAdjustLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [bindingModalVisible, setBindingModalVisible] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
const [showAdjustQuotaRaw, setShowAdjustQuotaRaw] = useState(false);
|
||||
const [showQuotaInput, setShowQuotaInput] = useState(false);
|
||||
const [inputs, setInputs] = useState(null);
|
||||
|
||||
const isEdit = Boolean(userId);
|
||||
|
||||
@@ -85,6 +91,7 @@ const EditUserModal = (props) => {
|
||||
linux_do_id: '',
|
||||
email: '',
|
||||
quota: 0,
|
||||
quota_amount: 0,
|
||||
group: 'default',
|
||||
remark: '',
|
||||
});
|
||||
@@ -107,13 +114,22 @@ const EditUserModal = (props) => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
data.password = '';
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
data.quota_amount = Number(
|
||||
quotaToDisplayAmount(data.quota || 0).toFixed(6),
|
||||
);
|
||||
setInputs({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputs && formApiRef.current) {
|
||||
formApiRef.current.setValues(inputs);
|
||||
}
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
if (userId) fetchGroups();
|
||||
@@ -132,8 +148,8 @@ const EditUserModal = (props) => {
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
let payload = { ...values };
|
||||
if (typeof payload.quota === 'string')
|
||||
payload.quota = parseInt(payload.quota) || 0;
|
||||
delete payload.quota;
|
||||
delete payload.quota_amount;
|
||||
if (userId) {
|
||||
payload.id = parseInt(userId);
|
||||
}
|
||||
@@ -150,11 +166,60 @@ const EditUserModal = (props) => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
/* --------------------- quota helper -------------------- */
|
||||
const addLocalQuota = () => {
|
||||
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
|
||||
const delta = parseInt(addQuotaLocal) || 0;
|
||||
formApiRef.current?.setValue('quota', current + delta);
|
||||
/* --------------------- atomic quota adjust -------------------- */
|
||||
const adjustQuota = async () => {
|
||||
const quotaVal = parseInt(adjustQuotaLocal) || 0;
|
||||
if (quotaVal <= 0 && adjustMode !== 'override') return;
|
||||
if (adjustMode === 'override' && (adjustQuotaLocal === '' || adjustQuotaLocal == null)) return;
|
||||
setAdjustLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/manage', {
|
||||
id: parseInt(userId),
|
||||
action: 'add_quota',
|
||||
mode: adjustMode,
|
||||
value: adjustMode === 'override' ? quotaVal : Math.abs(quotaVal),
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('调整额度成功'));
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustQuotaLocal('');
|
||||
setAdjustAmountLocal('');
|
||||
const userRes = await API.get(`/api/user/${userId}`);
|
||||
if (userRes.data.success) {
|
||||
const data = userRes.data.data;
|
||||
data.password = '';
|
||||
data.quota_amount = Number(
|
||||
quotaToDisplayAmount(data.quota || 0).toFixed(6),
|
||||
);
|
||||
setInputs({ ...getInitValues(), ...data });
|
||||
}
|
||||
props.refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
}
|
||||
setAdjustLoading(false);
|
||||
};
|
||||
|
||||
const getPreviewText = () => {
|
||||
const current = formApiRef.current?.getValue('quota') || 0;
|
||||
const val = parseInt(adjustQuotaLocal) || 0;
|
||||
let result;
|
||||
switch (adjustMode) {
|
||||
case 'add':
|
||||
result = current + Math.abs(val);
|
||||
return `${t('当前额度')}:${renderQuota(current)},+${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
|
||||
case 'subtract':
|
||||
result = current - Math.abs(val);
|
||||
return `${t('当前额度')}:${renderQuota(current)},-${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
|
||||
case 'override':
|
||||
return `${t('当前额度')}:${renderQuota(current)} → ${renderQuota(val)}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/* --------------------------- UI --------------------------- */
|
||||
@@ -305,24 +370,47 @@ const EditUserModal = (props) => {
|
||||
|
||||
<Col span={10}>
|
||||
<Form.InputNumber
|
||||
field='quota'
|
||||
label={t('剩余额度')}
|
||||
placeholder={t('请输入新的剩余额度')}
|
||||
step={500000}
|
||||
extraText={renderQuotaWithPrompt(values.quota || 0)}
|
||||
rules={[{ required: true, message: t('请输入额度') }]}
|
||||
field='quota_amount'
|
||||
label={t('金额')}
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
precision={6}
|
||||
step={0.000001}
|
||||
style={{ width: '100%' }}
|
||||
readonly
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={14}>
|
||||
<Form.Slot label={t('添加额度')}>
|
||||
<Form.Slot label={t('调整额度')}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
icon={<IconEdit />}
|
||||
onClick={() => setAdjustModalOpen(true)}
|
||||
>
|
||||
{t('调整额度')}
|
||||
</Button>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<div
|
||||
className='text-xs cursor-pointer'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
onClick={() => setShowQuotaInput((v) => !v)}
|
||||
>
|
||||
{showQuotaInput
|
||||
? `▾ ${t('收起原生额度输入')}`
|
||||
: `▸ ${t('使用原生额度输入')}`}
|
||||
</div>
|
||||
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
|
||||
<Form.InputNumber
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
style={{ width: '100%' }}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
@@ -372,81 +460,102 @@ const EditUserModal = (props) => {
|
||||
formApiRef={formApiRef}
|
||||
/>
|
||||
|
||||
{/* 添加额度模态框 */}
|
||||
{/* 调整额度模态框 */}
|
||||
<Modal
|
||||
centered
|
||||
visible={addQuotaModalOpen}
|
||||
onOk={() => {
|
||||
addLocalQuota();
|
||||
setIsModalOpen(false);
|
||||
setAddQuotaLocal('');
|
||||
setAddAmountLocal('');
|
||||
}}
|
||||
visible={adjustModalOpen}
|
||||
onOk={adjustQuota}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustQuotaLocal('');
|
||||
setAdjustAmountLocal('');
|
||||
setAdjustMode('add');
|
||||
}}
|
||||
confirmLoading={adjustLoading}
|
||||
closable={null}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconPlus className='mr-2' />
|
||||
{t('添加额度')}
|
||||
<IconEdit className='mr-2' />
|
||||
{t('调整额度')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
{(() => {
|
||||
const current = formApiRef.current?.getValue('quota') || 0;
|
||||
return (
|
||||
<Text type='secondary' className='block mb-2'>
|
||||
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
<Text type='secondary' className='block mb-2'>
|
||||
{getPreviewText()}
|
||||
</Text>
|
||||
</div>
|
||||
{getCurrencyConfig().type !== 'TOKENS' && (
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('金额')}</Text>
|
||||
<Text size='small' type='tertiary'>
|
||||
{' '}
|
||||
({t('仅用于换算,实际保存的是额度')})
|
||||
</Text>
|
||||
</div>
|
||||
<InputNumber
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
value={addAmountLocal}
|
||||
precision={2}
|
||||
onChange={(val) => {
|
||||
setAddAmountLocal(val);
|
||||
setAddQuotaLocal(
|
||||
val != null && val !== ''
|
||||
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('操作')}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={adjustMode}
|
||||
onChange={(e) => {
|
||||
setAdjustMode(e.target.value);
|
||||
setAdjustQuotaLocal('');
|
||||
setAdjustAmountLocal('');
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Radio value='add'>{t('添加')}</Radio>
|
||||
<Radio value='subtract'>{t('减少')}</Radio>
|
||||
<Radio value='override'>{t('覆盖')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('金额')}</Text>
|
||||
</div>
|
||||
<InputNumber
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
value={adjustAmountLocal}
|
||||
precision={6}
|
||||
min={adjustMode === 'override' ? undefined : 0}
|
||||
step={0.000001}
|
||||
onChange={(val) => {
|
||||
const amount = val === '' || val == null ? '' : val;
|
||||
setAdjustAmountLocal(amount);
|
||||
setAdjustQuotaLocal(
|
||||
amount === ''
|
||||
? ''
|
||||
: adjustMode === 'override'
|
||||
? displayAmountToQuota(amount)
|
||||
: displayAmountToQuota(Math.abs(amount)),
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='text-xs cursor-pointer mt-2'
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
onClick={() => setShowAdjustQuotaRaw((v) => !v)}
|
||||
>
|
||||
{showAdjustQuotaRaw
|
||||
? `▾ ${t('收起原生额度输入')}`
|
||||
: `▸ ${t('使用原生额度输入')}`}
|
||||
</div>
|
||||
<div style={{ display: showAdjustQuotaRaw ? 'block' : 'none' }} className='mt-2'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('额度')}</Text>
|
||||
</div>
|
||||
<InputNumber
|
||||
placeholder={t('输入额度')}
|
||||
value={addQuotaLocal}
|
||||
value={adjustQuotaLocal}
|
||||
min={adjustMode === 'override' ? undefined : 0}
|
||||
onChange={(val) => {
|
||||
setAddQuotaLocal(val);
|
||||
setAddAmountLocal(
|
||||
val != null && val !== ''
|
||||
? Number(
|
||||
(
|
||||
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
|
||||
).toFixed(2),
|
||||
)
|
||||
: '',
|
||||
const quota = val === '' || val == null ? '' : val;
|
||||
setAdjustQuotaLocal(quota);
|
||||
setAdjustAmountLocal(
|
||||
quota === ''
|
||||
? ''
|
||||
: adjustMode === 'override'
|
||||
? Number(quotaToDisplayAmount(quota).toFixed(6))
|
||||
: Number(quotaToDisplayAmount(Math.abs(quota)).toFixed(6)),
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -442,6 +442,14 @@ const SubscriptionPlansCard = ({
|
||||
(subscription?.end_time || 0) * 1000,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
{isActive && subscription?.next_reset_time > 0 && (
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('下一次重置')}:{' '}
|
||||
{new Date(
|
||||
subscription.next_reset_time * 1000,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('总额度')}:{' '}
|
||||
{totalAmount > 0 ? (
|
||||
|
||||
Vendored
+55
@@ -387,3 +387,58 @@ export const generateChartTimePoints = (
|
||||
|
||||
return chartTimePoints;
|
||||
};
|
||||
|
||||
// ========== 用户维度数据处理 ==========
|
||||
export const processUserData = (data, dataExportDefaultTime, limit = 10) => {
|
||||
const userQuotaTotal = new Map();
|
||||
data.forEach((item) => {
|
||||
const prev = userQuotaTotal.get(item.username) || 0;
|
||||
userQuotaTotal.set(item.username, prev + item.quota);
|
||||
});
|
||||
|
||||
const sorted = Array.from(userQuotaTotal.entries()).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
);
|
||||
const topUsers = sorted.slice(0, limit).map(([u]) => u);
|
||||
const topUserSet = new Set(topUsers);
|
||||
|
||||
const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({
|
||||
User: username,
|
||||
Quota: quota,
|
||||
}));
|
||||
|
||||
const showYear = isDataCrossYear(data.map((item) => item.created_at));
|
||||
|
||||
const timeUserMap = new Map();
|
||||
const allTimePoints = new Set();
|
||||
|
||||
data.forEach((item) => {
|
||||
const timeKey = timestamp2string1(
|
||||
item.created_at,
|
||||
dataExportDefaultTime,
|
||||
showYear,
|
||||
);
|
||||
allTimePoints.add(timeKey);
|
||||
const user = topUserSet.has(item.username) ? item.username : null;
|
||||
if (!user) return;
|
||||
const key = `${timeKey}-${user}`;
|
||||
const prev = timeUserMap.get(key) || { quota: 0 };
|
||||
timeUserMap.set(key, { quota: prev.quota + item.quota });
|
||||
});
|
||||
|
||||
const sortedTimePoints = Array.from(allTimePoints).sort();
|
||||
const trendData = [];
|
||||
sortedTimePoints.forEach((time) => {
|
||||
topUsers.forEach((user) => {
|
||||
const key = `${time}-${user}`;
|
||||
const val = timeUserMap.get(key);
|
||||
trendData.push({
|
||||
Time: time,
|
||||
User: user,
|
||||
Quota: val?.quota || 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { rankingData, trendData, topUsers };
|
||||
};
|
||||
|
||||
Vendored
+29
-7
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { getCurrencyConfig } from './render';
|
||||
|
||||
export const getQuotaPerUnit = () => {
|
||||
@@ -7,19 +25,23 @@ export const getQuotaPerUnit = () => {
|
||||
|
||||
export const quotaToDisplayAmount = (quota) => {
|
||||
const q = Number(quota || 0);
|
||||
if (!Number.isFinite(q) || q <= 0) return 0;
|
||||
if (!Number.isFinite(q) || q === 0) return 0;
|
||||
const sign = Math.sign(q);
|
||||
const abs = Math.abs(q);
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return q;
|
||||
const usd = q / getQuotaPerUnit();
|
||||
if (type === 'USD') return usd;
|
||||
return usd * (rate || 1);
|
||||
const usd = abs / getQuotaPerUnit();
|
||||
if (type === 'USD') return sign * usd;
|
||||
return sign * usd * (rate || 1);
|
||||
};
|
||||
|
||||
export const displayAmountToQuota = (amount) => {
|
||||
const val = Number(amount || 0);
|
||||
if (!Number.isFinite(val) || val <= 0) return 0;
|
||||
if (!Number.isFinite(val) || val === 0) return 0;
|
||||
const sign = Math.sign(val);
|
||||
const abs = Math.abs(val);
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return Math.round(val);
|
||||
const usd = type === 'USD' ? val : val / (rate || 1);
|
||||
return Math.round(usd * getQuotaPerUnit());
|
||||
const usd = type === 'USD' ? abs : abs / (rate || 1);
|
||||
return sign * Math.round(usd * getQuotaPerUnit());
|
||||
};
|
||||
|
||||
+5
-3
@@ -890,7 +890,7 @@ export const useChannelsData = () => {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const { success, message, time } = res.data;
|
||||
const { success, message, time, error_code } = res.data;
|
||||
|
||||
// 更新测试结果
|
||||
setModelTestResults((prev) => ({
|
||||
@@ -900,6 +900,7 @@ export const useChannelsData = () => {
|
||||
message,
|
||||
time: time || 0,
|
||||
timestamp: Date.now(),
|
||||
errorCode: error_code || null,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -927,7 +928,7 @@ export const useChannelsData = () => {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(`${t('模型')} ${model}: ${message}`);
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理网络错误
|
||||
@@ -939,9 +940,10 @@ export const useChannelsData = () => {
|
||||
message: error.message || t('网络错误'),
|
||||
time: 0,
|
||||
timestamp: Date.now(),
|
||||
errorCode: null,
|
||||
},
|
||||
}));
|
||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||
showError(error.message || t('测试失败'));
|
||||
} finally {
|
||||
// 从正在测试的模型集合中移除
|
||||
setTestingModels((prev) => {
|
||||
|
||||
+188
-7
@@ -34,8 +34,14 @@ import {
|
||||
updateChartSpec,
|
||||
updateMapValue,
|
||||
initializeMaps,
|
||||
processUserData,
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const USER_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||
];
|
||||
|
||||
export const useDashboardCharts = (
|
||||
dataExportDefaultTime,
|
||||
setTrendData,
|
||||
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// 模型消耗趋势折线图
|
||||
const [spec_model_line, setSpecModelLine] = useState({
|
||||
type: 'line',
|
||||
data: [
|
||||
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型消耗趋势'),
|
||||
text: t('调用趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
tooltip: {
|
||||
@@ -209,13 +214,35 @@ export const useDashboardCharts = (
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => datum['Count'] || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (array) => {
|
||||
array.sort((a, b) => b.value - a.value);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
let value = parseFloat(array[i].value);
|
||||
if (isNaN(value)) value = 0;
|
||||
sum += value;
|
||||
array[i].value = renderNumber(value);
|
||||
}
|
||||
array.unshift({
|
||||
key: t('总计'),
|
||||
value: renderNumber(sum),
|
||||
});
|
||||
return array;
|
||||
},
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
// 模型调用次数排行柱状图
|
||||
const [spec_rank_bar, setSpecRankBar] = useState({
|
||||
type: 'bar',
|
||||
data: [
|
||||
@@ -259,6 +286,103 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// ========== Admin: 用户消耗排行 ==========
|
||||
const [spec_user_rank, setSpecUserRank] = useState({
|
||||
type: 'bar',
|
||||
data: [{ id: 'userRankData', values: [] }],
|
||||
xField: 'rawQuota',
|
||||
yField: 'User',
|
||||
seriesField: 'User',
|
||||
direction: 'horizontal',
|
||||
legends: { visible: false },
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('用户消耗排行'),
|
||||
subtext: '',
|
||||
},
|
||||
bar: {
|
||||
state: { hover: { stroke: '#000', lineWidth: 1 } },
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'outside',
|
||||
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
|
||||
},
|
||||
axes: [{
|
||||
orient: 'left',
|
||||
type: 'band',
|
||||
label: { visible: true },
|
||||
}, {
|
||||
orient: 'bottom',
|
||||
type: 'linear',
|
||||
visible: false,
|
||||
}],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== Admin: 用户消耗趋势 ==========
|
||||
const [spec_user_trend, setSpecUserTrend] = useState({
|
||||
type: 'area',
|
||||
data: [{ id: 'userTrendData', values: [] }],
|
||||
xField: 'Time',
|
||||
yField: 'rawQuota',
|
||||
seriesField: 'User',
|
||||
stack: false,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('用户消耗趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
axes: [{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (value) => renderQuota(value, 2),
|
||||
},
|
||||
}],
|
||||
area: { style: { fillOpacity: 0.15 } },
|
||||
line: { style: { lineWidth: 2 } },
|
||||
point: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
dimension: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => datum['rawQuota'] || 0,
|
||||
}],
|
||||
updateContent: (array) => {
|
||||
array.sort((a, b) => b.value - a.value);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
let value = parseFloat(array[i].value);
|
||||
if (isNaN(value)) value = 0;
|
||||
sum += value;
|
||||
array[i].value = renderQuota(value, 4);
|
||||
}
|
||||
array.unshift({
|
||||
key: t('总计'),
|
||||
value: renderQuota(sum, 4),
|
||||
});
|
||||
return array;
|
||||
},
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== 数据处理函数 ==========
|
||||
const generateModelColors = useCallback((uniqueModels, modelColors) => {
|
||||
const newModelColors = {};
|
||||
@@ -383,13 +507,25 @@ export const useDashboardCharts = (
|
||||
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||||
|
||||
// ===== 模型调用次数排行柱状图 =====
|
||||
const rankData = Array.from(modelTotals)
|
||||
const MAX_RANK_MODELS = 20;
|
||||
const allRankData = Array.from(modelTotals)
|
||||
.map(([model, count]) => ({
|
||||
Model: model,
|
||||
Count: count,
|
||||
}))
|
||||
.sort((a, b) => b.Count - a.Count);
|
||||
|
||||
let rankData;
|
||||
if (allRankData.length > MAX_RANK_MODELS) {
|
||||
const topModels = allRankData.slice(0, MAX_RANK_MODELS);
|
||||
const otherCount = allRankData
|
||||
.slice(MAX_RANK_MODELS)
|
||||
.reduce((sum, item) => sum + item.Count, 0);
|
||||
rankData = [...topModels, { Model: t('其他'), Count: otherCount }];
|
||||
} else {
|
||||
rankData = allRankData;
|
||||
}
|
||||
|
||||
updateChartSpec(
|
||||
setSpecModelLine,
|
||||
modelLineData,
|
||||
@@ -426,6 +562,51 @@ export const useDashboardCharts = (
|
||||
],
|
||||
);
|
||||
|
||||
// ========== 用户维度图表数据处理 ==========
|
||||
const updateUserChartData = useCallback(
|
||||
(data) => {
|
||||
const { rankingData, trendData: userTrend } = processUserData(
|
||||
data,
|
||||
dataExportDefaultTime,
|
||||
10,
|
||||
);
|
||||
|
||||
const userRankValues = rankingData.map((item) => ({
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Quota: getQuotaWithUnit(item.Quota, 4),
|
||||
})).sort((a, b) => b.rawQuota - a.rawQuota);
|
||||
|
||||
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
|
||||
|
||||
setSpecUserRank((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userRankData', values: userRankValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const userTrendValues = userTrend.map((item) => ({
|
||||
Time: item.Time,
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
|
||||
}));
|
||||
|
||||
setSpecUserTrend((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userTrendData', values: userTrendValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[dataExportDefaultTime, t],
|
||||
);
|
||||
|
||||
// ========== 初始化图表主题 ==========
|
||||
useEffect(() => {
|
||||
initVChartSemiTheme({
|
||||
@@ -434,14 +615,14 @@ export const useDashboardCharts = (
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 图表规格
|
||||
spec_pie,
|
||||
spec_line,
|
||||
spec_model_line,
|
||||
spec_rank_bar,
|
||||
|
||||
// 函数
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
updateChartData,
|
||||
updateUserChartData,
|
||||
generateModelColors,
|
||||
};
|
||||
};
|
||||
|
||||
+22
@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
}
|
||||
}, [activeUptimeTab]);
|
||||
|
||||
const loadUserQuotaData = useCallback(async () => {
|
||||
if (!isAdminUser) return [];
|
||||
try {
|
||||
const { start_timestamp, end_timestamp } = inputs;
|
||||
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
return data || [];
|
||||
} else {
|
||||
showError(message);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return [];
|
||||
}
|
||||
}, [inputs, isAdminUser]);
|
||||
|
||||
const getUserData = useCallback(async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
showSearchModal,
|
||||
handleCloseModal,
|
||||
loadQuotaData,
|
||||
loadUserQuotaData,
|
||||
loadUptimeData,
|
||||
getUserData,
|
||||
refresh,
|
||||
|
||||
+43
-6
@@ -196,10 +196,17 @@ export const useApiRequest = (
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody = '';
|
||||
let parsedError = null;
|
||||
try {
|
||||
errorBody = await response.text();
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
if (errorJson?.error) {
|
||||
parsedError = errorJson.error;
|
||||
}
|
||||
} catch (e) {
|
||||
errorBody = '无法读取错误响应体';
|
||||
if (!errorBody) {
|
||||
errorBody = '无法读取错误响应体';
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = handleApiError(
|
||||
@@ -215,9 +222,13 @@ export const useApiRequest = (
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
throw new Error(
|
||||
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||
const err = new Error(
|
||||
parsedError?.message ||
|
||||
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||
);
|
||||
err.errorCode = parsedError?.code || null;
|
||||
err.errorType = parsedError?.type || null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -277,6 +288,7 @@ export const useApiRequest = (
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: t('请求发生错误: ') + error.message,
|
||||
errorCode: error.errorCode || null,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
...autoCollapseState,
|
||||
};
|
||||
@@ -379,7 +391,20 @@ export const useApiRequest = (
|
||||
// 只有在流没有正常完成且连接状态异常时才处理错误
|
||||
if (!isStreamComplete && source.readyState !== 2) {
|
||||
console.error('SSE Error:', e);
|
||||
const errorMessage = e.data || t('请求发生错误');
|
||||
let errorMessage = e.data || t('请求发生错误');
|
||||
let errorCode = null;
|
||||
|
||||
if (e.data) {
|
||||
try {
|
||||
const errorJson = JSON.parse(e.data);
|
||||
if (errorJson?.error) {
|
||||
errorMessage = errorJson.error.message || errorMessage;
|
||||
errorCode = errorJson.error.code || null;
|
||||
}
|
||||
} catch (_) {
|
||||
// not JSON, use raw data as error message
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = handleApiError(new Error(errorMessage));
|
||||
errorInfo.readyState = source.readyState;
|
||||
@@ -393,8 +418,19 @@ export const useApiRequest = (
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
streamMessageUpdate(errorMessage, 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage];
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (lastMessage && lastMessage.status !== MESSAGE_STATUS.COMPLETE && lastMessage.status !== MESSAGE_STATUS.ERROR) {
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: (lastMessage.content || '') + errorMessage,
|
||||
errorCode: errorCode,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
sseSourceRef.current = null;
|
||||
source.close();
|
||||
}
|
||||
@@ -446,6 +482,7 @@ export const useApiRequest = (
|
||||
[
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
setMessage,
|
||||
streamMessageUpdate,
|
||||
completeMessage,
|
||||
t,
|
||||
|
||||
Vendored
+38
-12
@@ -250,6 +250,7 @@
|
||||
"price_xxx 的商品价格 ID,新建产品后可获得": "Product price ID for price_xxx, available after creating new product",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "The speed field controls Claude inference speed mode. Disabled by default to avoid unintentionally switching to fast mode",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "Stripe key for sk_xxx or rk_xxx, sensitive information not displayed",
|
||||
"standard 已被移除,vip 用户看不到": "standard has been removed, vip users cannot see it",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction",
|
||||
@@ -410,7 +411,7 @@
|
||||
"以下上游数据可能不可信:": "The following upstream data may not be reliable: ",
|
||||
"以下文件解析失败,已忽略:{{list}}": "The following files failed to parse and have been ignored: {{list}}",
|
||||
"以及": "and",
|
||||
"仪表盘设置": "Dashboard Settings",
|
||||
"仪表盘设置": "Dashboard",
|
||||
"价格": "Pricing",
|
||||
"价格摘要": "Price Summary",
|
||||
"价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later",
|
||||
@@ -440,9 +441,11 @@
|
||||
"余额充值管理": "Balance recharge management",
|
||||
"作废": "Invalidate",
|
||||
"作废于": "Invalidated at",
|
||||
"下一次重置": "Next reset",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "After invalidation, the subscription becomes invalid immediately. History is not affected. Continue?",
|
||||
"作用域": "Scope",
|
||||
"作用域:包含分组": "Scope: Include Group",
|
||||
"作用域:包含模型名称": "Scope: Include Model Name",
|
||||
"作用域:包含规则名称": "Scope: Include Rule Name",
|
||||
"你似乎并没有修改什么": "You seem to have not modified anything",
|
||||
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "You can manually add them under “Custom model names”, click Fill and submit, or use the actions below to handle them automatically.",
|
||||
@@ -579,6 +582,7 @@
|
||||
"允许 inference_geo 透传": "Allow inference_geo Pass-through",
|
||||
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
|
||||
"允许 service_tier 透传": "Allow service_tier Pass-through",
|
||||
"允许 speed 透传": "Allow speed Pass-through",
|
||||
"允许 stream_options.include_obfuscation 透传": "Allow stream_options.include_obfuscation Pass-through",
|
||||
"允许不安全的 Origin(HTTP)": "Allow insecure Origin (HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "Allow callback (will leak server IP address)",
|
||||
@@ -677,7 +681,7 @@
|
||||
"其他": "Other",
|
||||
"其他注册选项": "Other registration options",
|
||||
"其他登录选项": "Other login options",
|
||||
"其他设置": "Other Settings",
|
||||
"其他设置": "Other",
|
||||
"其他详情": "Other details",
|
||||
"内存 阈值 (%)": "Memory Threshold (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Reject requests when memory usage exceeds this value",
|
||||
@@ -698,7 +702,7 @@
|
||||
"分类名称": "Category Name",
|
||||
"分组": "Group",
|
||||
"分组JSON设置": "Group JSON Settings",
|
||||
"分组与模型定价设置": "Group and Model Pricing Settings",
|
||||
"分组与模型定价设置": "Group & Model Pricing",
|
||||
"分组价格": "Group price",
|
||||
"分组倍率": "Group ratio",
|
||||
"分组倍率设置": "Group ratio settings",
|
||||
@@ -772,6 +776,7 @@
|
||||
"刷新统计": "Refresh Stats",
|
||||
"刷新缓存统计": "Refresh Cache Statistics",
|
||||
"刷新缓存统计失败": "Failed to refresh cache statistics",
|
||||
"刷新页面": "Reload Page",
|
||||
"前往 io.net API Keys": "Go to io.net API Keys",
|
||||
"前往设置": "Go to Settings",
|
||||
"前往设置页面": "Go to Settings Page",
|
||||
@@ -823,6 +828,8 @@
|
||||
"原密码": "Original Password",
|
||||
"原生格式": "Native format",
|
||||
"原生额度": "Raw quota",
|
||||
"使用原生额度输入": "Use raw quota input",
|
||||
"收起原生额度输入": "Hide raw quota input",
|
||||
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Deduplication completed: {{before}} keys before deduplication, {{after}} keys after deduplication",
|
||||
"参与官方同步": "Participate in official sync",
|
||||
"参数": "parameter",
|
||||
@@ -923,6 +930,7 @@
|
||||
"启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation",
|
||||
"启用Ping间隔": "Enable Ping interval",
|
||||
"启用SMTP SSL": "Enable SMTP SSL",
|
||||
"强制使用 AUTH LOGIN": "Force AUTH LOGIN",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "Enable SSRF Protection (Recommended for server security)",
|
||||
"启用供应商": "Enable Provider",
|
||||
"启用全部": "Enable all",
|
||||
@@ -1360,6 +1368,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "After enabling, ping data will be sent periodically to keep the connection active",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "After enabling, when the current group channel fails, it will try the next group's channel in order",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "When enabled, all requests will be directly forwarded to the upstream without any processing (redirects and channel adaptation will also be disabled). Please enable with caution.",
|
||||
"开启后,模型名称会参与 cache key(不同模型隔离)。": "When enabled, the model name is included in the cache key (isolates different models).",
|
||||
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "When enabled, if this rule matches and the request fails, no channel switch retry will occur.",
|
||||
"开启后,规则名称会参与 cache key(不同规则隔离)。": "When enabled, the rule name will be part of the cache key (isolated by rule).",
|
||||
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "When enabled, requests to Claude through this channel will force append ?beta=true (no need for clients to pass this parameter manually)",
|
||||
@@ -1436,7 +1445,7 @@
|
||||
"思考预算占比": "Thinking budget ratio",
|
||||
"性能指标": "Performance Indicators",
|
||||
"性能监控": "Performance Monitor",
|
||||
"性能设置": "Performance Settings",
|
||||
"性能设置": "Performance",
|
||||
"总 GPU 小时": "Total GPU Hours",
|
||||
"总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Total price: text price {{textPrice}} + audio price {{audioPrice}} = {{symbol}}{{total}}",
|
||||
"总分配内存": "Total Allocated Memory",
|
||||
@@ -1590,7 +1599,7 @@
|
||||
"支付方式名称": "Pay Method Name",
|
||||
"支付方式类型": "Pay Method Type",
|
||||
"支付渠道": "Payment Channels",
|
||||
"支付设置": "Payment Settings",
|
||||
"支付设置": "Payment",
|
||||
"支付请求失败": "Payment request failed",
|
||||
"支付金额": "Payment Amount",
|
||||
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",
|
||||
@@ -1898,6 +1907,7 @@
|
||||
"条件规则": "Condition Rules",
|
||||
"条件项设置": "Condition Item Settings",
|
||||
"条日志已清理!": "logs have been cleared!",
|
||||
"条规则": "rules",
|
||||
"条,共": "of",
|
||||
"来源": "Source",
|
||||
"来源于 IO.NET 部署": "From IO.NET Deployment",
|
||||
@@ -1985,6 +1995,7 @@
|
||||
"模型定价,需要登录访问": "Model pricing, requires login to access",
|
||||
"模型广场": "Model Marketplace",
|
||||
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
|
||||
"模型排行": "Model ranking",
|
||||
"模型支持的接口端点信息": "Model supported API endpoint information",
|
||||
"模型数据分析": "Model Data Analysis",
|
||||
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
|
||||
@@ -1997,7 +2008,7 @@
|
||||
"模型消耗趋势": "Model consumption trend",
|
||||
"模型版本": "Model version",
|
||||
"模型的详细描述和基本特性": "Detailed description and basic characteristics of the model",
|
||||
"模型相关设置": "Model related settings",
|
||||
"模型相关设置": "Model Related",
|
||||
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:",
|
||||
"模型管理": "Model Management",
|
||||
"模型组": "Model group",
|
||||
@@ -2010,7 +2021,7 @@
|
||||
"模型部署": "Model Deployment",
|
||||
"模型部署服务未启用": "Model deployment service is not enabled",
|
||||
"模型部署管理": "Model Deployment Management",
|
||||
"模型部署设置": "Model Deployment Settings",
|
||||
"模型部署设置": "Model Deployment",
|
||||
"模型配置": "Model Configuration",
|
||||
"模型重定向": "Model mapping",
|
||||
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:",
|
||||
@@ -2143,6 +2154,7 @@
|
||||
"添加公告": "Add Notice",
|
||||
"添加分类": "Add Category",
|
||||
"添加分组": "Add Group",
|
||||
"添加分组规则": "Add Group Rules",
|
||||
"添加后提交": "Submit after adding",
|
||||
"添加启动参数": "Add Startup Args",
|
||||
"添加启动命令": "Add Startup Command",
|
||||
@@ -2159,6 +2171,14 @@
|
||||
"添加键值对": "Add key-value pair",
|
||||
"添加问答": "Add FAQ",
|
||||
"添加额度": "Add quota",
|
||||
"减少": "Subtract",
|
||||
"覆盖": "Override",
|
||||
"调整额度": "Adjust Quota",
|
||||
"调整额度成功": "Quota adjusted successfully",
|
||||
"当前额度": "Current quota",
|
||||
"变更": "Change",
|
||||
"预计结果": "Estimated result",
|
||||
"正数为增加,负数为减少": "Positive to add, negative to subtract",
|
||||
"清理不活跃缓存": "Clean up inactive cache",
|
||||
"清理失败": "Cleanup failed",
|
||||
"清理方式": "Cleanup Mode",
|
||||
@@ -2277,6 +2297,8 @@
|
||||
"用户每周期最多请求完成次数": "User max successful request times per period",
|
||||
"用户每周期最多请求次数": "User max request times per period",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
|
||||
"用户消耗排行": "User consumption ranking",
|
||||
"用户消耗趋势": "User consumption trend",
|
||||
"用户的基本账户信息": "User basic account information",
|
||||
"用户管理": "User Management",
|
||||
"用户组": "User group",
|
||||
@@ -2367,6 +2389,7 @@
|
||||
"确认冲突项修改": "Confirm conflict item modification",
|
||||
"确认删除": "Confirm deletion",
|
||||
"确认删除模型": "Confirm Delete Model",
|
||||
"确认删除该分组的所有规则?": "Delete all rules for this group?",
|
||||
"确认删除该分组?": "Confirm delete this group?",
|
||||
"确认删除该规则?": "Confirm delete this rule?",
|
||||
"确认取消密码登录": "Confirm cancel password login",
|
||||
@@ -2521,7 +2544,7 @@
|
||||
"系统文档和帮助信息": "System documentation and help information",
|
||||
"系统消息": "System message",
|
||||
"系统管理功能": "System management functions",
|
||||
"系统设置": "System Settings",
|
||||
"系统设置": "System",
|
||||
"系统访问令牌": "System Access Token",
|
||||
"索引": "Index",
|
||||
"紧凑列表": "Compact list",
|
||||
@@ -2550,7 +2573,7 @@
|
||||
"绘图": "Drawing",
|
||||
"绘图任务记录": "Drawing task records",
|
||||
"绘图日志": "Drawing Logs",
|
||||
"绘图设置": "Drawing settings",
|
||||
"绘图设置": "Drawing",
|
||||
"统一的": "The Unified",
|
||||
"统计Tokens": "Statistical Tokens",
|
||||
"统计已重置": "Statistics reset",
|
||||
@@ -2628,7 +2651,7 @@
|
||||
"聊天区域": "Chat Area",
|
||||
"聊天应用名称": "Chat Application Name",
|
||||
"聊天应用名称已存在,请使用其他名称": "Chat application name already exists, please use another name",
|
||||
"聊天设置": "Chat settings",
|
||||
"聊天设置": "Chat",
|
||||
"聊天配置": "Chat configuration",
|
||||
"聊天链接配置错误,请联系管理员": "Chat link configuration error, please contact administrator",
|
||||
"联系我们": "Contact Us",
|
||||
@@ -2878,6 +2901,7 @@
|
||||
"请求参数无效": "Invalid request parameters",
|
||||
"请求发生错误": "An error occurred with the request",
|
||||
"请求发生错误: ": "An error occurred with the request: ",
|
||||
"模型价格未配置": "Model Price Not Configured",
|
||||
"请求后端接口失败:": "Failed to request the backend interface: ",
|
||||
"请求失败": "Request failed",
|
||||
"请求头覆盖": "Request header override",
|
||||
@@ -3064,6 +3088,7 @@
|
||||
"调用次数": "Call Count",
|
||||
"调用次数分布": "Models call distribution",
|
||||
"调用次数排行": "Models call ranking",
|
||||
"调用趋势": "Call trend",
|
||||
"调试信息": "Debug information",
|
||||
"谨慎": "Cautious",
|
||||
"豆包": "Doubao",
|
||||
@@ -3159,7 +3184,7 @@
|
||||
"过期时间不能早于当前时间!": "Expiration time cannot be earlier than the current time!",
|
||||
"过期时间快捷设置": "Expiration time quick settings",
|
||||
"过期时间格式错误!": "Expiration time format error!",
|
||||
"运营设置": "Operation Settings",
|
||||
"运营设置": "Operation",
|
||||
"运行中": "Running",
|
||||
"运行命令 (Command)": "Command",
|
||||
"运行时长": "Runtime Duration",
|
||||
@@ -3247,7 +3272,7 @@
|
||||
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
|
||||
"通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test successful, model ${model} took ${time.toFixed(2)} seconds.",
|
||||
"通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Channel ${name} test successful, took ${time.toFixed(2)} seconds.",
|
||||
"速率限制设置": "Rate limit settings",
|
||||
"速率限制设置": "Rate Limit",
|
||||
"逻辑": "Logic",
|
||||
"邀请": "Invitations",
|
||||
"邀请人": "Inviter",
|
||||
@@ -3411,6 +3436,7 @@
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Audio output: {{tokens}} / 1M * model ratio {{modelRatio}} * audio ratio {{audioRatio}} * audio completion ratio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"页脚": "Footer",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
|
||||
"页面渲染出错,请刷新页面重试": "An error occurred while rendering the page. Please refresh and try again.",
|
||||
"顶栏管理": "Header Management",
|
||||
"项": "items",
|
||||
"项目": "Project",
|
||||
|
||||
Vendored
+30
-4
@@ -246,6 +246,7 @@
|
||||
"price_xxx 的商品价格 ID,新建产品后可获得": "ID de prix du produit price_xxx, peut être obtenu après la création d'un nouveau produit",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "Le champ speed contrôle le mode de vitesse d'inférence de Claude. Désactivé par défaut pour éviter un passage involontaire au mode fast",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "Clé secrète Stripe sk_xxx ou rk_xxx, les informations sensibles ne sont pas affichées",
|
||||
"standard 已被移除,vip 用户看不到": "standard has been removed, vip users cannot see it",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex",
|
||||
@@ -435,9 +436,11 @@
|
||||
"余额充值管理": "Recharge du solde",
|
||||
"作废": "Invalider",
|
||||
"作废于": "Invalidé le",
|
||||
"下一次重置": "Prochaine réinitialisation",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "Après invalidation, l'abonnement devient immédiatement invalide. L'historique n'est pas affecté. Continuer ?",
|
||||
"作用域": "Portée",
|
||||
"作用域:包含分组": "Portée : inclure le groupe",
|
||||
"作用域:包含模型名称": "Portée : inclure le nom du modèle",
|
||||
"作用域:包含规则名称": "Portée : inclure le nom de la règle",
|
||||
"你似乎并没有修改什么": "Vous ne semblez rien avoir modifié",
|
||||
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Vous pouvez les ajouter manuellement dans « Noms de modèles personnalisés », cliquer sur Remplir puis soumettre, ou utiliser directement les actions ci-dessous pour les traiter automatiquement.",
|
||||
@@ -572,6 +575,7 @@
|
||||
"允许 inference_geo 透传": "Autoriser la transmission de inference_geo",
|
||||
"允许 safety_identifier 透传": "Autoriser le passage de safety_identifier",
|
||||
"允许 service_tier 透传": "Autoriser le passage de service_tier",
|
||||
"允许 speed 透传": "Autoriser la transmission de speed",
|
||||
"允许 stream_options.include_obfuscation 透传": "Autoriser la transmission de stream_options.include_obfuscation",
|
||||
"允许不安全的 Origin(HTTP)": "Autoriser une origine non sécurisée (HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "Autoriser le rappel (divulguera l'adresse IP du serveur)",
|
||||
@@ -694,7 +698,7 @@
|
||||
"分类名称": "Nom de la catégorie",
|
||||
"分组": "Groupe",
|
||||
"分组JSON设置": "Group JSON Settings",
|
||||
"分组与模型定价设置": "Groupe et tarification",
|
||||
"分组与模型定价设置": "Groupes & tarification des modèles",
|
||||
"分组价格": "Prix de groupe",
|
||||
"分组倍率": "Ratio",
|
||||
"分组倍率设置": "Ratio de groupe",
|
||||
@@ -768,6 +772,7 @@
|
||||
"刷新统计": "Actualiser les statistiques",
|
||||
"刷新缓存统计": "Actualiser les statistiques du cache",
|
||||
"刷新缓存统计失败": "Échec de l'actualisation des statistiques du cache",
|
||||
"刷新页面": "Recharger la page",
|
||||
"前往 io.net API Keys": "Go to io.net API Keys",
|
||||
"前往设置": "Go to Settings",
|
||||
"前往设置页面": "Go to Settings Page",
|
||||
@@ -819,6 +824,8 @@
|
||||
"原密码": "Mot de passe original",
|
||||
"原生格式": "Format natif",
|
||||
"原生额度": "Quota brut",
|
||||
"使用原生额度输入": "Saisir le quota brut",
|
||||
"收起原生额度输入": "Masquer la saisie du quota brut",
|
||||
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Doublons supprimés : {{before}} clés avant, {{after}} clés après",
|
||||
"参与官方同步": "Participer à la synchronisation officielle",
|
||||
"参数": "paramètre",
|
||||
@@ -918,6 +925,7 @@
|
||||
"启用Gemini思考后缀适配": "Activer l'adaptation du suffixe de la pensée Gemini",
|
||||
"启用Ping间隔": "Activer l'intervalle de ping",
|
||||
"启用SMTP SSL": "Activer SMTP SSL",
|
||||
"强制使用 AUTH LOGIN": "Forcer AUTH LOGIN",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "Activer la protection SSRF (recommandé pour la sécurité du serveur)",
|
||||
"启用供应商": "Activer le fournisseur",
|
||||
"启用全部": "Activer tout",
|
||||
@@ -1359,6 +1367,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "Après activation, des données ping seront envoyées périodiquement pour maintenir la connexion active",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Après activation, lorsque le canal du groupe actuel échoue, il essaiera le canal du groupe suivant dans l'ordre",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Après activation, toutes les requêtes seront directement transmises en amont sans aucun traitement (la redirection et l'adaptation de canal seront également désactivées), veuillez activer avec prudence",
|
||||
"开启后,模型名称会参与 cache key(不同模型隔离)。": "Lorsque activé, le nom du modèle est inclus dans la clé de cache (isole les différents modèles).",
|
||||
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "Une fois activé, si cette règle est déclenchée et que la requête échoue, aucune nouvelle tentative sur un autre canal ne sera effectuée.",
|
||||
"开启后,规则名称会参与 cache key(不同规则隔离)。": "Une fois activé, le nom de la règle fera partie de la clé de cache (isolation par règle).",
|
||||
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "Une fois activé, les requêtes à Claude via ce canal ajouteront automatiquement ?beta=true (pas besoin de le passer manuellement côté client)",
|
||||
@@ -1433,7 +1442,7 @@
|
||||
"思考预算占比": "Ratio du budget de la pensée",
|
||||
"性能指标": "Indicateurs de performance",
|
||||
"性能监控": "Surveillance des performances",
|
||||
"性能设置": "Paramètres de performance",
|
||||
"性能设置": "Performance",
|
||||
"总 GPU 小时": "Total GPU Hours",
|
||||
"总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Prix total : prix du texte {{textPrice}} + prix de l'audio {{audioPrice}} = {{symbol}}{{total}}",
|
||||
"总分配内存": "Mémoire totale allouée",
|
||||
@@ -1882,6 +1891,7 @@
|
||||
"条件规则": "Règles de condition",
|
||||
"条件项设置": "Paramètres des éléments de condition",
|
||||
"条日志已清理!": "les journaux ont été effacés !",
|
||||
"条规则": "rules",
|
||||
"条,共": "sur",
|
||||
"来源": "Source",
|
||||
"来源于 IO.NET 部署": "From IO.NET Deployment",
|
||||
@@ -1967,6 +1977,7 @@
|
||||
"模型定价,需要登录访问": "Tarification du modèle, nécessite une connexion pour y accéder",
|
||||
"模型广场": "Marché des modèles",
|
||||
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
|
||||
"模型排行": "Classement des modèles",
|
||||
"模型支持的接口端点信息": "Informations sur les points de terminaison de l'API pris en charge par le modèle",
|
||||
"模型数据分析": "Analyse des données du modèle",
|
||||
"模型映射必须是合法的 JSON 格式!": "Le mappage de modèles doit être au format JSON valide !",
|
||||
@@ -1979,7 +1990,7 @@
|
||||
"模型消耗趋势": "Tendance de la consommation des modèles",
|
||||
"模型版本": "Version du modèle",
|
||||
"模型的详细描述和基本特性": "Description détaillée et caractéristiques de base du modèle",
|
||||
"模型相关设置": "Paramètres liés au modèle",
|
||||
"模型相关设置": "Modèle associé",
|
||||
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "La communauté des modèles a besoin de la contribution de tous. Si vous trouvez des données incorrectes ou si vous souhaitez contribuer à de nouvelles données de modèle, veuillez visiter :",
|
||||
"模型管理": "Modèles",
|
||||
"模型组": "Groupe de modèles",
|
||||
@@ -1992,7 +2003,7 @@
|
||||
"模型部署": "Model Deployment",
|
||||
"模型部署服务未启用": "Model deployment service is not enabled",
|
||||
"模型部署管理": "Model Deployment Management",
|
||||
"模型部署设置": "Model Deployment Settings",
|
||||
"模型部署设置": "Déploiement de modèles",
|
||||
"模型配置": "Configuration du modèle",
|
||||
"模型重定向": "Redirection de modèle",
|
||||
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "Les modèles suivants provenant de la redirection n'ont pas été ajoutés à la liste « Modèles », l'appel échouera faute de modèle disponible :",
|
||||
@@ -2122,6 +2133,7 @@
|
||||
"添加公告": "Ajouter un avis",
|
||||
"添加分类": "Ajouter une catégorie",
|
||||
"添加分组": "Add Group",
|
||||
"添加分组规则": "Add Group Rules",
|
||||
"添加后提交": "Soumettre après ajout",
|
||||
"添加启动参数": "Add Startup Args",
|
||||
"添加启动命令": "Add Startup Command",
|
||||
@@ -2137,6 +2149,14 @@
|
||||
"添加键值对": "Ajouter une paire clé-valeur",
|
||||
"添加问答": "Ajouter une FAQ",
|
||||
"添加额度": "Ajouter un quota",
|
||||
"减少": "Soustraire",
|
||||
"覆盖": "Remplacer",
|
||||
"调整额度": "Ajuster le quota",
|
||||
"调整额度成功": "Quota ajusté avec succès",
|
||||
"当前额度": "Quota actuel",
|
||||
"变更": "Modification",
|
||||
"预计结果": "Résultat estimé",
|
||||
"正数为增加,负数为减少": "Positif pour ajouter, négatif pour soustraire",
|
||||
"清理不活跃缓存": "Nettoyer le cache inactif",
|
||||
"清理失败": "Échec du nettoyage",
|
||||
"清理方式": "Mode de nettoyage",
|
||||
@@ -2252,6 +2272,8 @@
|
||||
"用户每周期最多请求完成次数": "Nombre maximal de requêtes utilisateur réussies par période",
|
||||
"用户每周期最多请求次数": "Nombre maximal de requêtes utilisateur par période",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'",
|
||||
"用户消耗排行": "Classement de consommation des utilisateurs",
|
||||
"用户消耗趋势": "Tendance de consommation des utilisateurs",
|
||||
"用户的基本账户信息": "Informations de base du compte utilisateur",
|
||||
"用户管理": "Utilisateurs",
|
||||
"用户组": "Groupe d'utilisateurs",
|
||||
@@ -2343,6 +2365,7 @@
|
||||
"确认冲突项修改": "Confirmer la modification de l'élément de conflit",
|
||||
"确认删除": "Confirmer la suppression",
|
||||
"确认删除模型": "Confirm Delete Model",
|
||||
"确认删除该分组的所有规则?": "Delete all rules for this group?",
|
||||
"确认删除该分组?": "Confirm delete this group?",
|
||||
"确认删除该规则?": "Confirm delete this rule?",
|
||||
"确认取消密码登录": "Confirmer l'annulation de la connexion par mot de passe",
|
||||
@@ -2851,6 +2874,7 @@
|
||||
"请求参数无效": "Invalid request parameters",
|
||||
"请求发生错误": "Une erreur s'est produite lors de la demande",
|
||||
"请求发生错误: ": "Une erreur s'est produite lors de la demande : ",
|
||||
"模型价格未配置": "Prix du modèle non configuré",
|
||||
"请求后端接口失败:": "Échec de la requête de l'interface backend : ",
|
||||
"请求失败": "Échec de la demande",
|
||||
"请求头覆盖": "Remplacement des en-têtes de demande",
|
||||
@@ -3037,6 +3061,7 @@
|
||||
"调用次数": "Nombre d'appels",
|
||||
"调用次数分布": "Distribution des appels de modèles",
|
||||
"调用次数排行": "Classement des appels de modèles",
|
||||
"调用趋势": "Tendance des appels",
|
||||
"调试信息": "Informations de débogage",
|
||||
"谨慎": "Prudent",
|
||||
"豆包": "Doubao",
|
||||
@@ -3376,6 +3401,7 @@
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Sortie audio : {{tokens}} / 1M * ratio du modèle {{modelRatio}} * ratio audio {{audioRatio}} * ratio de complétion audio {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"页脚": "Pied de page",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "Page non trouvée, veuillez vérifier si l'adresse de votre navigateur est correcte",
|
||||
"页面渲染出错,请刷新页面重试": "Une erreur est survenue lors du rendu de la page. Veuillez rafraîchir et réessayer.",
|
||||
"顶栏管理": "En-tête",
|
||||
"项": "éléments",
|
||||
"项目": "Élément",
|
||||
|
||||
Vendored
+39
-13
@@ -242,6 +242,7 @@
|
||||
"price_xxx 的商品价格 ID,新建产品后可获得": "price_xxx の料金ID。新規製品の作成後に取得できます",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "safety_identifierフィールドは、OpenAIが利用ポリシーに違反する可能性のあるアプリユーザーを特定するために使用されます。ユーザーのプライバシーを保護するため、デフォルトでは無効です",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "service_tierフィールドはサービス階層の指定に使用されます。パススルーを許可すると実際の課金額が想定を上回る場合があるため、追加料金を避けるためにデフォルトでは無効になっています",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "speed フィールドは Claude の推論速度モードを制御します。意図せず fast モードへ切り替わるのを避けるため、デフォルトで無効です",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "sk_xxx または rk_xxx のStripe APIキー。機密情報は表示されません",
|
||||
"standard 已被移除,vip 用户看不到": "standard は削除され、vipユーザーには表示されません",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "storeフィールドは、製品の評価と最適化のためにOpenAIがリクエストデータを保存することを許可します。デフォルトでは無効です。有効にすると、Codexが正常に利用できなくなる場合があります",
|
||||
@@ -401,7 +402,7 @@
|
||||
"以下上游数据可能不可信:": "以下のアップストリームデータは信頼できない可能性があります:",
|
||||
"以下文件解析失败,已忽略:{{list}}": "以下のファイルは解析に失敗したため無視されました:{{list}}",
|
||||
"以及": "および",
|
||||
"仪表盘设置": "ダッシュボード設定",
|
||||
"仪表盘设置": "ダッシュボード",
|
||||
"价格": "料金",
|
||||
"价格摘要": "価格概要",
|
||||
"价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later",
|
||||
@@ -431,9 +432,11 @@
|
||||
"余额充值管理": "残高チャージ管理",
|
||||
"作废": "無効化",
|
||||
"作废于": "無効化日",
|
||||
"下一次重置": "次回リセット",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "無効化するとこのサブスクリプションは直ちに失効します。履歴には影響しません。続行しますか?",
|
||||
"作用域": "スコープ",
|
||||
"作用域:包含分组": "スコープ:グループを含む",
|
||||
"作用域:包含模型名称": "スコープ:モデル名を含む",
|
||||
"作用域:包含规则名称": "スコープ:ルール名を含む",
|
||||
"你似乎并没有修改什么": "何も変更されていないようです",
|
||||
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "You can manually add them under “Custom model names”, click Fill and submit, or use the actions below to handle them automatically.",
|
||||
@@ -516,7 +519,7 @@
|
||||
"保存 Turnstile 设置": "Turnstile 設定を保存",
|
||||
"保存 WeChat Server 设置": "WeChatサーバー設定を保存",
|
||||
"保存分组倍率设置": "グループ倍率設定を保存",
|
||||
"保存分组相关设置": "グループ設定を保存",
|
||||
"保存分组相关设置": "グループ関連設定を保存",
|
||||
"保存备用码": "バックアップコード",
|
||||
"保存备用码以备不时之需": "万一に備え保存",
|
||||
"保存失败": "保存に失敗しました",
|
||||
@@ -568,6 +571,7 @@
|
||||
"允许 inference_geo 透传": "inference_geoパススルーを許可",
|
||||
"允许 safety_identifier 透传": "safety_identifierのパススルーを許可する",
|
||||
"允许 service_tier 透传": "service_tierのパススルーを許可する",
|
||||
"允许 speed 透传": "speed パススルーを許可",
|
||||
"允许 stream_options.include_obfuscation 透传": "stream_options.include_obfuscationパススルーを許可",
|
||||
"允许不安全的 Origin(HTTP)": "安全でないオリジン(HTTP)を許可する",
|
||||
"允许回调(会泄露服务器 IP 地址)": "コールバックを許可する(サーバーIPアドレスが漏洩します)",
|
||||
@@ -664,7 +668,7 @@
|
||||
"其他": "その他",
|
||||
"其他注册选项": "その他のサインアップオプション",
|
||||
"其他登录选项": "その他のログインオプション",
|
||||
"其他设置": "その他の設定",
|
||||
"其他设置": "その他",
|
||||
"其他详情": "Other details",
|
||||
"内存 阈值 (%)": "メモリしきい値 (%)",
|
||||
"内存使用率超过此值时拒绝请求": "メモリ使用率がこの値を超えた場合にリクエストを拒否",
|
||||
@@ -685,7 +689,7 @@
|
||||
"分类名称": "分類名称",
|
||||
"分组": "グループ",
|
||||
"分组JSON设置": "グループJSON設定",
|
||||
"分组与模型定价设置": "グループとモデルの料金設定",
|
||||
"分组与模型定价设置": "グループ&モデル料金設定",
|
||||
"分组价格": "グループ料金",
|
||||
"分组倍率": "グループレート",
|
||||
"分组倍率设置": "グループ倍率設定",
|
||||
@@ -759,6 +763,7 @@
|
||||
"刷新统计": "統計を更新",
|
||||
"刷新缓存统计": "キャッシュ統計を更新",
|
||||
"刷新缓存统计失败": "キャッシュ統計の更新に失敗しました",
|
||||
"刷新页面": "ページを更新",
|
||||
"前往 io.net API Keys": "Go to io.net API Keys",
|
||||
"前往设置": "Go to Settings",
|
||||
"前往设置页面": "Go to Settings Page",
|
||||
@@ -810,6 +815,8 @@
|
||||
"原密码": "現在のパスワード",
|
||||
"原生格式": "ネイティブ形式",
|
||||
"原生额度": "生クォータ",
|
||||
"使用原生额度输入": "生クォータで入力",
|
||||
"收起原生额度输入": "生クォータ入力を非表示",
|
||||
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "重複排除完了:重複排除前 {{before}} 個のAPIキー、重複排除後 {{after}} 個のAPIキー",
|
||||
"参与官方同步": "公式との同期",
|
||||
"参数": "パラメータ",
|
||||
@@ -909,6 +916,7 @@
|
||||
"启用Gemini思考后缀适配": "Gemini思考サフィックスモードを有効にする",
|
||||
"启用Ping间隔": "Ping間隔を有効にする",
|
||||
"启用SMTP SSL": "SMTP SSLを有効にする",
|
||||
"强制使用 AUTH LOGIN": "AUTH LOGINを強制する",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "SSRF保護を有効にする(サーバーを保護するため、有効化を推奨します)",
|
||||
"启用供应商": "プロバイダーを有効化",
|
||||
"启用全部": "すべてを有効にする",
|
||||
@@ -1342,6 +1350,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "有効にすると、接続をアクティブに保つためにpingデータが定期的に送信されます",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "有効にすると、現在のグループチャネルが失敗した場合、次のグループのチャネルを順番に試行します",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "有効にすると、すべてのリクエストは直接アップストリームにパススルーされ、いかなる処理も行われません(リダイレクトとチャネルの自動調整も無効になります)。有効にする際はご注意ください",
|
||||
"开启后,模型名称会参与 cache key(不同模型隔离)。": "有効にすると、モデル名がキャッシュキーに含まれます(異なるモデルを分離)。",
|
||||
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "有効にすると、このルールがヒットしてリクエストが失敗した場合、チャネル切り替えリトライは行われません。",
|
||||
"开启后,规则名称会参与 cache key(不同规则隔离)。": "有効にすると、ルール名がキャッシュキーに含まれます(ルールごとに隔離)。",
|
||||
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "有効にすると、このチャネルでClaudeにリクエストする際に?beta=trueが強制追加されます(クライアント側で手動パラメータ渡し不要)",
|
||||
@@ -1416,7 +1425,7 @@
|
||||
"思考预算占比": "思考予算の割合",
|
||||
"性能指标": "性能指標",
|
||||
"性能监控": "パフォーマンス監視",
|
||||
"性能设置": "パフォーマンス設定",
|
||||
"性能设置": "パフォーマンス",
|
||||
"总 GPU 小时": "Total GPU Hours",
|
||||
"总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "合計料金:テキスト料金 {{textPrice}} + オーディオ料金 {{audioPrice}} = {{symbol}}{{total}}",
|
||||
"总分配内存": "総割り当てメモリ",
|
||||
@@ -1565,7 +1574,7 @@
|
||||
"支付方式名称": "決済方法名",
|
||||
"支付方式类型": "決済方法タイプ",
|
||||
"支付渠道": "決済チャネル",
|
||||
"支付设置": "決済設定",
|
||||
"支付设置": "決済",
|
||||
"支付请求失败": "決済リクエストに失敗しました",
|
||||
"支付金额": "決済金額",
|
||||
"支持 Ctrl+V 粘贴图片": "Ctrl+V で画像を貼り付け可能",
|
||||
@@ -1865,6 +1874,7 @@
|
||||
"条件规则": "条件ルール",
|
||||
"条件项设置": "条件項目設定",
|
||||
"条日志已清理!": "件のログがクリアされました",
|
||||
"条规则": "件のルール",
|
||||
"条,共": "件、合計",
|
||||
"来源": "ソース",
|
||||
"来源于 IO.NET 部署": "From IO.NET Deployment",
|
||||
@@ -1950,6 +1960,7 @@
|
||||
"模型定价,需要登录访问": "モデル料金(アクセスにはログインが必要です)",
|
||||
"模型广场": "モデルマーケットプレイス",
|
||||
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
|
||||
"模型排行": "モデルランキング",
|
||||
"模型支持的接口端点信息": "モデルが対応するAPIエンドポイント情報",
|
||||
"模型数据分析": "モデルデータ分析",
|
||||
"模型映射必须是合法的 JSON 格式!": "モデルマッピングは、有効なJSON形式である必要があります",
|
||||
@@ -1962,7 +1973,7 @@
|
||||
"模型消耗趋势": "モデル消費推移",
|
||||
"模型版本": "モデルバージョン",
|
||||
"模型的详细描述和基本特性": "モデルの詳細な説明と基本的な特徴",
|
||||
"模型相关设置": "モデル関連設定",
|
||||
"模型相关设置": "モデル関連",
|
||||
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "モデルコミュニティは皆様の協力によって維持されています。データに誤りがある場合や、新規モデルデータをコントリビュートしたい場合は、以下にアクセスしてください:",
|
||||
"模型管理": "モデル管理",
|
||||
"模型组": "モデルグループ",
|
||||
@@ -1975,7 +1986,7 @@
|
||||
"模型部署": "Model Deployment",
|
||||
"模型部署服务未启用": "Model deployment service is not enabled",
|
||||
"模型部署管理": "Model Deployment Management",
|
||||
"模型部署设置": "Model Deployment Settings",
|
||||
"模型部署设置": "モデルデプロイ",
|
||||
"模型配置": "モデル設定",
|
||||
"模型重定向": "モデルマッピング",
|
||||
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:",
|
||||
@@ -2105,6 +2116,7 @@
|
||||
"添加公告": "お知らせ追加",
|
||||
"添加分类": "分類追加",
|
||||
"添加分组": "グループを追加",
|
||||
"添加分组规则": "グループルールを追加",
|
||||
"添加后提交": "Submit after adding",
|
||||
"添加启动参数": "Add Startup Args",
|
||||
"添加启动命令": "Add Startup Command",
|
||||
@@ -2120,6 +2132,14 @@
|
||||
"添加键值对": "キー/値ペア追加",
|
||||
"添加问答": "FAQ追加",
|
||||
"添加额度": "残高追加",
|
||||
"减少": "減少",
|
||||
"覆盖": "上書き",
|
||||
"调整额度": "残高調整",
|
||||
"调整额度成功": "残高の調整に成功しました",
|
||||
"当前额度": "現在の残高",
|
||||
"变更": "変更",
|
||||
"预计结果": "予想結果",
|
||||
"正数为增加,负数为减少": "正の数で追加、負の数で減少",
|
||||
"清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ",
|
||||
"清理失败": "クリーンアップに失敗しました",
|
||||
"清理方式": "クリーンアップモード",
|
||||
@@ -2235,6 +2255,8 @@
|
||||
"用户每周期最多请求完成次数": "期間ごとのユーザー最大成功リクエスト数",
|
||||
"用户每周期最多请求次数": "期間ごとのユーザー最大リクエスト数",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "ユーザーがサインアップ時に表示されるウェブサイト名です。例:「マイサイト」",
|
||||
"用户消耗排行": "ユーザー消費ランキング",
|
||||
"用户消耗趋势": "ユーザー消費推移",
|
||||
"用户的基本账户信息": "ユーザーの基本アカウント情報",
|
||||
"用户管理": "ユーザー管理",
|
||||
"用户组": "ユーザーグループ",
|
||||
@@ -2324,6 +2346,7 @@
|
||||
"确认冲突项修改": "競合項目の変更の確認",
|
||||
"确认删除": "削除の確認",
|
||||
"确认删除模型": "Confirm Delete Model",
|
||||
"确认删除该分组的所有规则?": "このグループの全ルールを削除しますか?",
|
||||
"确认删除该分组?": "このグループを削除しますか?",
|
||||
"确认删除该规则?": "このルールを削除しますか?",
|
||||
"确认取消密码登录": "パスワードログイン無効化の確認",
|
||||
@@ -2477,7 +2500,7 @@
|
||||
"系统文档和帮助信息": "システムのドキュメントとヘルプ",
|
||||
"系统消息": "システムメッセージ",
|
||||
"系统管理功能": "システム管理機能",
|
||||
"系统设置": "システム設定",
|
||||
"系统设置": "システム",
|
||||
"系统访问令牌": "システムアクセストークン",
|
||||
"索引": "インデックス",
|
||||
"紧凑列表": "コンパクトリスト",
|
||||
@@ -2506,7 +2529,7 @@
|
||||
"绘图": "画像生成",
|
||||
"绘图任务记录": "画像生成タスク履歴",
|
||||
"绘图日志": "画像生成履歴",
|
||||
"绘图设置": "画像生成設定",
|
||||
"绘图设置": "画像生成",
|
||||
"统一的": "統合型",
|
||||
"统计Tokens": "トークン統計",
|
||||
"统计已重置": "統計がリセットされました",
|
||||
@@ -2583,7 +2606,7 @@
|
||||
"聊天区域": "チャットエリア",
|
||||
"聊天应用名称": "チャットアプリ名",
|
||||
"聊天应用名称已存在,请使用其他名称": "このチャットアプリ名はすでに存在します。別の名称を入力してください",
|
||||
"聊天设置": "チャット設定",
|
||||
"聊天设置": "チャット",
|
||||
"聊天配置": "チャット設定",
|
||||
"聊天链接配置错误,请联系管理员": "チャットURLの設定でエラーが発生しました。管理者にお問い合わせください",
|
||||
"联系我们": "お問い合わせ",
|
||||
@@ -2832,6 +2855,7 @@
|
||||
"请求参数无效": "Invalid request parameters",
|
||||
"请求发生错误": "リクエストでエラーが発生しました",
|
||||
"请求发生错误: ": "リクエストでエラーが発生しました:",
|
||||
"模型价格未配置": "モデル価格が未設定",
|
||||
"请求后端接口失败:": "バックエンドAPIリクエストに失敗しました:",
|
||||
"请求失败": "リクエストに失敗しました",
|
||||
"请求头覆盖": "リクエストヘッダーの上書き",
|
||||
@@ -3018,6 +3042,7 @@
|
||||
"调用次数": "呼び出し回数",
|
||||
"调用次数分布": "呼び出し回数分布",
|
||||
"调用次数排行": "呼び出し回数ランキング",
|
||||
"调用趋势": "呼び出し推移",
|
||||
"调试信息": "デバッグ情報",
|
||||
"谨慎": "注意",
|
||||
"豆包": "豆包",
|
||||
@@ -3108,7 +3133,7 @@
|
||||
"过期时间不能早于当前时间!": "有効期限は現在時刻より前に設定できません",
|
||||
"过期时间快捷设置": "有効期限クイック設定",
|
||||
"过期时间格式错误!": "有効期限のフォーマットが正しくありません",
|
||||
"运营设置": "運用設定",
|
||||
"运营设置": "運用",
|
||||
"运行中": "Running",
|
||||
"运行命令 (Command)": "Command",
|
||||
"运行时长": "Runtime Duration",
|
||||
@@ -3194,7 +3219,7 @@
|
||||
"通道 ${name} 余额更新成功!": "チャネル「${name}」のクォータを更新しました。",
|
||||
"通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "チャネル「${name}」のテストに成功しました。モデル「${model}」の所要時間 ${time.toFixed(2)} 秒。",
|
||||
"通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "チャネル「${name}」のテストに成功しました。所要時間 ${time.toFixed(2)} 秒。",
|
||||
"速率限制设置": "レート制限設定",
|
||||
"速率限制设置": "レート制限",
|
||||
"逻辑": "ロジック",
|
||||
"邀请": "招待",
|
||||
"邀请人": "招待元",
|
||||
@@ -3357,6 +3382,7 @@
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音声出力: {{tokens}} / 1M * モデル倍率 {{modelRatio}} * 音声倍率 {{audioRatio}} * 音声補完倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"页脚": "フッター",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "ページが見つかりませんでした。ブラウザのアドレスが正しいかご確認ください",
|
||||
"页面渲染出错,请刷新页面重试": "ページのレンダリング中にエラーが発生しました。ページを更新して再試行してください。",
|
||||
"顶栏管理": "トップバー管理",
|
||||
"项": "件",
|
||||
"项目": "プロジェクト",
|
||||
|
||||
Vendored
+38
-12
@@ -249,6 +249,7 @@
|
||||
"price_xxx 的商品价格 ID,新建产品后可获得": "ID цены товара price_xxx, можно получить после создания нового продукта",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Поле safety_identifier помогает OpenAI идентифицировать пользователей приложений, которые могут нарушать политику использования. По умолчанию отключено для защиты конфиденциальности пользователей",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Поле service_tier используется для указания уровня сервиса, позволяет передавать параметры, которые могут привести к фактической оплате выше ожидаемой. По умолчанию отключено для избежания дополнительных расходов",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "Поле speed управляет режимом скорости инференса Claude. По умолчанию отключено, чтобы избежать непреднамеренного переключения в режим fast",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "Ключ Stripe sk_xxx или rk_xxx, конфиденциальная информация не отображается",
|
||||
"standard 已被移除,vip 用户看不到": "standard has been removed, vip users cannot see it",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Поле store используется для авторизации OpenAI хранить данные запросов для оценки и оптимизации продукта. По умолчанию отключено, после включения может привести к неработоспособности Codex",
|
||||
@@ -408,7 +409,7 @@
|
||||
"以下上游数据可能不可信:": "Следующие upstream данные могут быть недостоверными:",
|
||||
"以下文件解析失败,已忽略:{{list}}": "Не удалось проанализировать следующие файлы, они проигнорированы: {{list}}",
|
||||
"以及": "а также",
|
||||
"仪表盘设置": "Настройки панели управления",
|
||||
"仪表盘设置": "Панель управления",
|
||||
"价格": "Цена",
|
||||
"价格摘要": "Сводка цен",
|
||||
"价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later",
|
||||
@@ -438,9 +439,11 @@
|
||||
"余额充值管理": "Управление пополнением баланса",
|
||||
"作废": "Аннулировать",
|
||||
"作废于": "Аннулировано",
|
||||
"下一次重置": "Следующий сброс",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?",
|
||||
"作用域": "Область действия",
|
||||
"作用域:包含分组": "Область действия: включить группу",
|
||||
"作用域:包含模型名称": "Область действия: включить имя модели",
|
||||
"作用域:包含规则名称": "Область действия: включить имя правила",
|
||||
"你似乎并没有修改什么": "Похоже, вы ничего не изменили",
|
||||
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "Вы можете добавить их вручную в разделе «Пользовательские названия моделей», нажать «Заполнить», затем отправить или воспользоваться действиями ниже для автоматической обработки.",
|
||||
@@ -575,6 +578,7 @@
|
||||
"允许 inference_geo 透传": "Разрешить передачу inference_geo",
|
||||
"允许 safety_identifier 透传": "Разрешить сквозную передачу safety_identifier",
|
||||
"允许 service_tier 透传": "Разрешить сквозную передачу service_tier",
|
||||
"允许 speed 透传": "Разрешить передачу speed",
|
||||
"允许 stream_options.include_obfuscation 透传": "Разрешить передачу stream_options.include_obfuscation",
|
||||
"允许不安全的 Origin(HTTP)": "Разрешить небезопасные Origin (HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "Разрешить обратные вызовы (может раскрыть IP-адрес сервера)",
|
||||
@@ -679,7 +683,7 @@
|
||||
"其他": "Другое",
|
||||
"其他注册选项": "Другие варианты регистрации",
|
||||
"其他登录选项": "Другие варианты входа",
|
||||
"其他设置": "Другие настройки",
|
||||
"其他设置": "Прочее",
|
||||
"其他详情": "Другие детали",
|
||||
"内存 阈值 (%)": "Порог памяти (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Отклонять запросы, когда использование памяти превышает это значение",
|
||||
@@ -700,7 +704,7 @@
|
||||
"分类名称": "Название категории",
|
||||
"分组": "Группа",
|
||||
"分组JSON设置": "Group JSON Settings",
|
||||
"分组与模型定价设置": "Настройки групп и ценообразования моделей",
|
||||
"分组与模型定价设置": "Группы и цены моделей",
|
||||
"分组价格": "Цена группы",
|
||||
"分组倍率": "Коэффициент группы",
|
||||
"分组倍率设置": "Настройки коэффициента группы",
|
||||
@@ -774,6 +778,7 @@
|
||||
"刷新统计": "Обновить статистику",
|
||||
"刷新缓存统计": "Обновить статистику кэша",
|
||||
"刷新缓存统计失败": "Не удалось обновить статистику кэша",
|
||||
"刷新页面": "Обновить страницу",
|
||||
"前往 io.net API Keys": "Go to io.net API Keys",
|
||||
"前往设置": "Go to Settings",
|
||||
"前往设置页面": "Go to Settings Page",
|
||||
@@ -825,6 +830,8 @@
|
||||
"原密码": "Старый пароль",
|
||||
"原生格式": "Нативный формат",
|
||||
"原生额度": "Исходный лимит",
|
||||
"使用原生额度输入": "Ввод в исходных единицах",
|
||||
"收起原生额度输入": "Скрыть ввод в исходных единицах",
|
||||
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Дедупликация завершена: до дедупликации {{before}} ключей, после дедупликации {{after}} ключей",
|
||||
"参与官方同步": "Участвовать в официальной синхронизации",
|
||||
"参数": "Параметры",
|
||||
@@ -924,6 +931,7 @@
|
||||
"启用Gemini思考后缀适配": "Включить адаптацию суффикса мышления Gemini",
|
||||
"启用Ping间隔": "Включить интервал Ping",
|
||||
"启用SMTP SSL": "Включить SMTP SSL",
|
||||
"强制使用 AUTH LOGIN": "Принудительно AUTH LOGIN",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "Включить защиту SSRF (рекомендуется включить для защиты безопасности сервера)",
|
||||
"启用供应商": "Включить поставщика",
|
||||
"启用全部": "Включить все",
|
||||
@@ -1371,6 +1379,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "После включения будет периодически отправляться ping-данные для поддержания активности соединения",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "После включения, когда канал текущей группы не работает, он будет пытаться использовать канал следующей группы по порядку",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "После включения все запросы будут напрямую передаваться upstream без какой-либо обработки (перенаправление и адаптация каналов также будут отключены), включайте с осторожностью",
|
||||
"开启后,模型名称会参与 cache key(不同模型隔离)。": "При включении имя модели включается в ключ кэша (изолирует разные модели).",
|
||||
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "При включении, если правило сработало и запрос не удался, переключение канала для повтора не выполняется.",
|
||||
"开启后,规则名称会参与 cache key(不同规则隔离)。": "При включении имя правила будет частью ключа кэша (изоляция по правилам).",
|
||||
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "При включении запросы к Claude через этот канал будут принудительно дополнены ?beta=true (клиенту не нужно передавать этот параметр вручную)",
|
||||
@@ -1445,7 +1454,7 @@
|
||||
"思考预算占比": "Доля бюджета на размышления",
|
||||
"性能指标": "Показатели производительности",
|
||||
"性能监控": "Мониторинг производительности",
|
||||
"性能设置": "Настройки производительности",
|
||||
"性能设置": "Производительность",
|
||||
"总 GPU 小时": "Total GPU Hours",
|
||||
"总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Общая цена: цена текста {{textPrice}} + цена аудио {{audioPrice}} = {{symbol}}{{total}}",
|
||||
"总分配内存": "Общая выделенная память",
|
||||
@@ -1594,7 +1603,7 @@
|
||||
"支付方式名称": "Название метода оплаты",
|
||||
"支付方式类型": "Тип метода оплаты",
|
||||
"支付渠道": "Платежные каналы",
|
||||
"支付设置": "Настройки оплаты",
|
||||
"支付设置": "Оплата",
|
||||
"支付请求失败": "Запрос на оплату не удался",
|
||||
"支付金额": "Сумма оплаты",
|
||||
"支持 Ctrl+V 粘贴图片": "Поддержка Ctrl+V для вставки изображения",
|
||||
@@ -1894,6 +1903,7 @@
|
||||
"条件规则": "Правила условий",
|
||||
"条件项设置": "Настройки элементов условий",
|
||||
"条日志已清理!": "записей журнала очищено!",
|
||||
"条规则": "rules",
|
||||
"条,共": "записей, всего",
|
||||
"来源": "Источник",
|
||||
"来源于 IO.NET 部署": "From IO.NET Deployment",
|
||||
@@ -1979,6 +1989,7 @@
|
||||
"模型定价,需要登录访问": "Ценообразование моделей, требуется вход для доступа",
|
||||
"模型广场": "Площадка моделей",
|
||||
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
|
||||
"模型排行": "Рейтинг моделей",
|
||||
"模型支持的接口端点信息": "Информация о конечных точках интерфейса, поддерживаемых моделью",
|
||||
"模型数据分析": "Анализ данных моделей",
|
||||
"模型映射必须是合法的 JSON 格式!": "Сопоставление моделей должно быть в допустимом формате JSON!",
|
||||
@@ -1991,7 +2002,7 @@
|
||||
"模型消耗趋势": "Тенденции потребления моделей",
|
||||
"模型版本": "Версия модели",
|
||||
"模型的详细描述和基本特性": "Подробное описание и основные характеристики модели",
|
||||
"模型相关设置": "Настройки, связанные с моделью",
|
||||
"模型相关设置": "Модели",
|
||||
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "Сообщество моделей требует совместного поддержания всеми. Если вы обнаружили ошибки в данных или хотите внести новые данные о моделях, посетите:",
|
||||
"模型管理": "Управление моделями",
|
||||
"模型组": "Группа моделей",
|
||||
@@ -2004,7 +2015,7 @@
|
||||
"模型部署": "Model Deployment",
|
||||
"模型部署服务未启用": "Model deployment service is not enabled",
|
||||
"模型部署管理": "Model Deployment Management",
|
||||
"模型部署设置": "Model Deployment Settings",
|
||||
"模型部署设置": "Развёртывание моделей",
|
||||
"模型配置": "Конфигурация модели",
|
||||
"模型重定向": "Перенаправление модели",
|
||||
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "Следующие модели из перенаправления ещё не добавлены в список «Модели», из-за отсутствия доступных моделей вызовы завершатся ошибкой:",
|
||||
@@ -2134,6 +2145,7 @@
|
||||
"添加公告": "Добавить объявление",
|
||||
"添加分类": "Добавить категорию",
|
||||
"添加分组": "Add Group",
|
||||
"添加分组规则": "Add Group Rules",
|
||||
"添加后提交": "Отправить после добавления",
|
||||
"添加启动参数": "Add Startup Args",
|
||||
"添加启动命令": "Add Startup Command",
|
||||
@@ -2149,6 +2161,14 @@
|
||||
"添加键值对": "Добавить пару ключ-значение",
|
||||
"添加问答": "Добавить вопрос-ответ",
|
||||
"添加额度": "Добавить лимит",
|
||||
"减少": "Уменьшить",
|
||||
"覆盖": "Заменить",
|
||||
"调整额度": "Скорректировать квоту",
|
||||
"调整额度成功": "Квота успешно скорректирована",
|
||||
"当前额度": "Текущая квота",
|
||||
"变更": "Изменение",
|
||||
"预计结果": "Ожидаемый результат",
|
||||
"正数为增加,负数为减少": "Положительное для увеличения, отрицательное для уменьшения",
|
||||
"清理不活跃缓存": "Очистить неактивный кэш",
|
||||
"清理失败": "Ошибка очистки",
|
||||
"清理方式": "Режим очистки",
|
||||
@@ -2264,6 +2284,8 @@
|
||||
"用户每周期最多请求完成次数": "Максимальное количество выполненных запросов пользователя за период",
|
||||
"用户每周期最多请求次数": "Максимальное количество запросов пользователя за период",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "Название сайта, которое видят пользователи при регистрации, например 'Мой сайт'",
|
||||
"用户消耗排行": "Рейтинг потребления пользователей",
|
||||
"用户消耗趋势": "Тенденция потребления пользователей",
|
||||
"用户的基本账户信息": "Основная информация об аккаунте пользователя",
|
||||
"用户管理": "Управление пользователями",
|
||||
"用户组": "Группа пользователей",
|
||||
@@ -2357,6 +2379,7 @@
|
||||
"确认冲突项修改": "Подтвердить изменение конфликтующих элементов",
|
||||
"确认删除": "Подтвердить удаление",
|
||||
"确认删除模型": "Confirm Delete Model",
|
||||
"确认删除该分组的所有规则?": "Delete all rules for this group?",
|
||||
"确认删除该分组?": "Confirm delete this group?",
|
||||
"确认删除该规则?": "Confirm delete this rule?",
|
||||
"确认取消密码登录": "Подтвердить отмену входа по паролю",
|
||||
@@ -2510,7 +2533,7 @@
|
||||
"系统文档和帮助信息": "Системная документация и справочная информация",
|
||||
"系统消息": "Системные сообщения",
|
||||
"系统管理功能": "Функции системного управления",
|
||||
"系统设置": "Системные настройки",
|
||||
"系统设置": "Система",
|
||||
"系统访问令牌": "Токен доступа к системе",
|
||||
"索引": "Индекс",
|
||||
"紧凑列表": "Компактный список",
|
||||
@@ -2539,7 +2562,7 @@
|
||||
"绘图": "Рисование",
|
||||
"绘图任务记录": "Записи задач рисования",
|
||||
"绘图日志": "Журнал рисования",
|
||||
"绘图设置": "Настройки рисования",
|
||||
"绘图设置": "Рисование",
|
||||
"统一的": "Единый",
|
||||
"统计Tokens": "Статистика токенов",
|
||||
"统计已重置": "Статистика сброшена",
|
||||
@@ -2616,7 +2639,7 @@
|
||||
"聊天区域": "Область чата",
|
||||
"聊天应用名称": "Название чат-приложения",
|
||||
"聊天应用名称已存在,请使用其他名称": "Название чат-приложения уже существует, используйте другое название",
|
||||
"聊天设置": "Настройки чата",
|
||||
"聊天设置": "Чат",
|
||||
"聊天配置": "Конфигурация чата",
|
||||
"聊天链接配置错误,请联系管理员": "Ошибка конфигурации ссылки чата, свяжитесь с администратором",
|
||||
"联系我们": "Свяжитесь с нами",
|
||||
@@ -2865,6 +2888,7 @@
|
||||
"请求参数无效": "Invalid request parameters",
|
||||
"请求发生错误": "Произошла ошибка запроса",
|
||||
"请求发生错误: ": "Произошла ошибка запроса: ",
|
||||
"模型价格未配置": "Цена модели не настроена",
|
||||
"请求后端接口失败:": "Не удалось запросить внутренний интерфейс:",
|
||||
"请求失败": "Запрос не удался",
|
||||
"请求头覆盖": "Переопределение заголовков запроса",
|
||||
@@ -3051,6 +3075,7 @@
|
||||
"调用次数": "Количество вызовов",
|
||||
"调用次数分布": "Распределение количества вызовов",
|
||||
"调用次数排行": "Рейтинг количества вызовов",
|
||||
"调用趋势": "Тенденция вызовов",
|
||||
"调试信息": "Отладочная информация",
|
||||
"谨慎": "Осторожно",
|
||||
"豆包": "Doubao",
|
||||
@@ -3141,7 +3166,7 @@
|
||||
"过期时间不能早于当前时间!": "Время истечения не может быть раньше текущего времени!",
|
||||
"过期时间快捷设置": "Быстрая настройка времени истечения",
|
||||
"过期时间格式错误!": "Ошибка формата времени истечения!",
|
||||
"运营设置": "Операционные настройки",
|
||||
"运营设置": "Операции",
|
||||
"运行中": "Running",
|
||||
"运行命令 (Command)": "Command",
|
||||
"运行时长": "Runtime Duration",
|
||||
@@ -3227,7 +3252,7 @@
|
||||
"通道 ${name} 余额更新成功!": "Баланс канала ${name} успешно обновлен!",
|
||||
"通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。": "Канал ${name} успешно протестирован, модель ${model} заняла ${time.toFixed(2)} секунд.",
|
||||
"通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "Канал ${name} успешно протестирован, заняло ${time.toFixed(2)} секунд.",
|
||||
"速率限制设置": "Настройки ограничения скорости",
|
||||
"速率限制设置": "Ограничение скорости",
|
||||
"逻辑": "Логика",
|
||||
"邀请": "Приглашение",
|
||||
"邀请人": "Пригласивший",
|
||||
@@ -3390,6 +3415,7 @@
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Аудиовывод: {{tokens}} / 1M * коэффициент модели {{modelRatio}} * аудио-коэффициент {{audioRatio}} * коэффициент аудиозавершения {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"页脚": "Подвал",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "Страница не найдена, пожалуйста, проверьте правильность адреса в браузере",
|
||||
"页面渲染出错,请刷新页面重试": "Произошла ошибка при отрисовке страницы. Пожалуйста, обновите страницу и попробуйте снова.",
|
||||
"顶栏管理": "Управление верхней панелью",
|
||||
"项": "элементов",
|
||||
"项目": "Проект",
|
||||
|
||||
Vendored
+38
-12
@@ -243,6 +243,7 @@
|
||||
"price_xxx 的商品价格 ID,新建产品后可获得": "ID giá sản phẩm cho price_xxx, có sẵn sau khi tạo sản phẩm mới",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Trường safety_identifier giúp OpenAI xác định người dùng ứng dụng có thể vi phạm chính sách sử dụng. Tắt theo mặc định để bảo vệ quyền riêng tư của người dùng",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Trường service_tier được sử dụng để chỉ định cấp độ dịch vụ. Cho phép truyền qua có thể dẫn đến việc tính phí thực tế cao hơn dự kiến. Tắt theo mặc định để tránh phí bổ sung",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "Trường speed kiểm soát chế độ tốc độ suy luận Claude. Mặc định tắt để tránh vô tình chuyển sang chế độ fast",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "Khóa Stripe cho sk_xxx hoặc rk_xxx, thông tin nhạy cảm không được hiển thị",
|
||||
"standard 已被移除,vip 用户看不到": "standard has been removed, vip users cannot see it",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Trường store ủy quyền cho OpenAI lưu trữ dữ liệu yêu cầu để đánh giá và tối ưu hóa sản phẩm. Tắt theo mặc định. Bật có thể khiến Codex hoạt động không chính xác",
|
||||
@@ -402,7 +403,7 @@
|
||||
"以下上游数据可能不可信:": "Dữ liệu thượng nguồn sau đây có thể không đáng tin cậy: ",
|
||||
"以下文件解析失败,已忽略:{{list}}": "Các tệp sau không phân tích được và đã bị bỏ qua: {{list}}",
|
||||
"以及": "và",
|
||||
"仪表盘设置": "Cài đặt bảng điều khiển",
|
||||
"仪表盘设置": "Bảng điều khiển",
|
||||
"价格": "Giá cả",
|
||||
"价格摘要": "Tóm tắt giá",
|
||||
"价格暂时不可用,请稍后重试": "Price temporarily unavailable, please try again later",
|
||||
@@ -432,9 +433,11 @@
|
||||
"余额充值管理": "Quản lý nạp tiền số dư",
|
||||
"作废": "Vô hiệu",
|
||||
"作废于": "Vô hiệu vào",
|
||||
"下一次重置": "Đặt lại tiếp theo",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "Sau khi vô hiệu, đăng ký sẽ mất hiệu lực ngay. Lịch sử không bị ảnh hưởng. Tiếp tục?",
|
||||
"作用域": "Phạm vi",
|
||||
"作用域:包含分组": "Phạm vi: Bao gồm nhóm",
|
||||
"作用域:包含模型名称": "Phạm vi: Bao gồm tên mô hình",
|
||||
"作用域:包含规则名称": "Phạm vi: Bao gồm tên quy tắc",
|
||||
"你似乎并没有修改什么": "Bạn dường như không sửa đổi gì cả",
|
||||
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "You can manually add them under “Custom model names”, click Fill and submit, or use the actions below to handle them automatically.",
|
||||
@@ -569,6 +572,7 @@
|
||||
"允许 inference_geo 透传": "Cho phép truyền inference_geo",
|
||||
"允许 safety_identifier 透传": "Cho phép safety_identifier truyền qua",
|
||||
"允许 service_tier 透传": "Cho phép service_tier truyền qua",
|
||||
"允许 speed 透传": "Cho phép truyền speed",
|
||||
"允许 stream_options.include_obfuscation 透传": "Cho phép truyền stream_options.include_obfuscation",
|
||||
"允许不安全的 Origin(HTTP)": "Cho phép Origin không an toàn (HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "Cho phép gọi lại (sẽ làm lộ địa chỉ IP máy chủ)",
|
||||
@@ -665,7 +669,7 @@
|
||||
"其他": "Khác",
|
||||
"其他注册选项": "Tùy chọn đăng ký khác",
|
||||
"其他登录选项": "Tùy chọn đăng nhập khác",
|
||||
"其他设置": "Cài đặt khác",
|
||||
"其他设置": "Khác",
|
||||
"其他详情": "Other details",
|
||||
"内存 阈值 (%)": "Ngưỡng bộ nhớ (%)",
|
||||
"内存使用率超过此值时拒绝请求": "Từ chối yêu cầu khi sử dụng bộ nhớ vượt quá giá trị này",
|
||||
@@ -686,7 +690,7 @@
|
||||
"分类名称": "Tên danh mục",
|
||||
"分组": "Nhóm",
|
||||
"分组JSON设置": "Group JSON Settings",
|
||||
"分组与模型定价设置": "Cài đặt giá nhóm và mô hình",
|
||||
"分组与模型定价设置": "Nhóm & định giá mô hình",
|
||||
"分组价格": "Giá nhóm",
|
||||
"分组倍率": "Tỷ lệ nhóm",
|
||||
"分组倍率设置": "Cài đặt tỷ lệ nhóm",
|
||||
@@ -760,6 +764,7 @@
|
||||
"刷新统计": "Làm mới thống kê",
|
||||
"刷新缓存统计": "Làm mới thống kê bộ nhớ đệm",
|
||||
"刷新缓存统计失败": "Làm mới thống kê bộ nhớ đệm thất bại",
|
||||
"刷新页面": "Tải lại trang",
|
||||
"前往 io.net API Keys": "Go to io.net API Keys",
|
||||
"前往设置": "Go to Settings",
|
||||
"前往设置页面": "Go to Settings Page",
|
||||
@@ -811,6 +816,8 @@
|
||||
"原密码": "Mật khẩu cũ",
|
||||
"原生格式": "Định dạng gốc",
|
||||
"原生额度": "Hạn mức gốc",
|
||||
"使用原生额度输入": "Nhập hạn mức gốc",
|
||||
"收起原生额度输入": "Ẩn nhập hạn mức gốc",
|
||||
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "Hoàn tất loại bỏ trùng lặp: {{before}} khóa trước khi loại bỏ, {{after}} khóa sau khi loại bỏ",
|
||||
"参与官方同步": "Tham gia đồng bộ chính thức",
|
||||
"参数": "tham số",
|
||||
@@ -910,6 +917,7 @@
|
||||
"启用Gemini思考后缀适配": "Bật thích ứng hậu tố tư duy Gemini",
|
||||
"启用Ping间隔": "Bật khoảng thời gian Ping",
|
||||
"启用SMTP SSL": "Bật SMTP SSL",
|
||||
"强制使用 AUTH LOGIN": "Buộc AUTH LOGIN",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "Bật bảo vệ SSRF (Khuyên dùng để bảo mật máy chủ)",
|
||||
"启用供应商": "Bật nhà cung cấp",
|
||||
"启用全部": "Bật tất cả",
|
||||
@@ -1343,6 +1351,7 @@
|
||||
"开启后,将定期发送ping数据保持连接活跃": "Sau khi bật, dữ liệu ping sẽ được gửi định kỳ để giữ kết nối hoạt động",
|
||||
"开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道": "Sau khi bật, khi kênh nhóm hiện tại thất bại, nó sẽ thử kênh của nhóm tiếp theo theo thứ tự",
|
||||
"开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启": "Khi bật, tất cả các yêu cầu sẽ được chuyển tiếp trực tiếp đến thượng nguồn mà không cần xử lý (chuyển hướng và thích ứng kênh cũng sẽ bị vô hiệu hóa). Vui lòng bật một cách thận trọng.",
|
||||
"开启后,模型名称会参与 cache key(不同模型隔离)。": "Khi bật, tên mô hình sẽ được bao gồm trong cache key (cách ly các mô hình khác nhau).",
|
||||
"开启后,若该规则命中且请求失败,将不会切换渠道重试。": "Khi bật, nếu quy tắc này trúng và yêu cầu thất bại, sẽ không chuyển kênh để thử lại.",
|
||||
"开启后,规则名称会参与 cache key(不同规则隔离)。": "Khi bật, tên quy tắc sẽ tham gia vào cache key (cách ly theo quy tắc).",
|
||||
"开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)": "Khi bật, yêu cầu đến Claude qua kênh này sẽ tự động thêm ?beta=true (client không cần truyền thủ công)",
|
||||
@@ -1417,7 +1426,7 @@
|
||||
"思考预算占比": "Tỷ lệ ngân sách tư duy",
|
||||
"性能指标": "Chỉ số hiệu suất",
|
||||
"性能监控": "Giám sát hiệu suất",
|
||||
"性能设置": "Cài đặt hiệu suất",
|
||||
"性能设置": "Hiệu suất",
|
||||
"总 GPU 小时": "Total GPU Hours",
|
||||
"总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}": "Tổng giá: giá văn bản {{textPrice}} + giá âm thanh {{audioPrice}} = {{symbol}}{{total}}",
|
||||
"总分配内存": "Tổng bộ nhớ đã phân bổ",
|
||||
@@ -1566,7 +1575,7 @@
|
||||
"支付方式名称": "Tên phương thức thanh toán",
|
||||
"支付方式类型": "Loại phương thức thanh toán",
|
||||
"支付渠道": "Kênh thanh toán",
|
||||
"支付设置": "Cài đặt thanh toán",
|
||||
"支付设置": "Thanh toán",
|
||||
"支付请求失败": "Yêu cầu thanh toán thất bại",
|
||||
"支付金额": "Số tiền thanh toán",
|
||||
"支持 Ctrl+V 粘贴图片": "Hỗ trợ Ctrl+V để dán hình ảnh",
|
||||
@@ -1866,6 +1875,7 @@
|
||||
"条件规则": "Quy tắc điều kiện",
|
||||
"条件项设置": "Cài đặt mục điều kiện",
|
||||
"条日志已清理!": "nhật ký đã được xóa!",
|
||||
"条规则": "rules",
|
||||
"条,共": "của",
|
||||
"来源": "Nguồn",
|
||||
"来源于 IO.NET 部署": "From IO.NET Deployment",
|
||||
@@ -1960,6 +1970,7 @@
|
||||
"模型库": "Thư viện mô hình",
|
||||
"模型拉取失败: {{error}}": "Failed to pull model: {{error}}",
|
||||
"模型排序": "Sắp xếp mô hình",
|
||||
"模型排行": "Xếp hạng mô hình",
|
||||
"模型支持的接口端点信息": "Thông tin điểm cuối API được mô hình hỗ trợ",
|
||||
"模型数据分析": "Phân tích dữ liệu mô hình",
|
||||
"模型映射": "Ánh xạ mô hình",
|
||||
@@ -1976,7 +1987,7 @@
|
||||
"模型版本": "Phiên bản mô hình",
|
||||
"模型状态": "Trạng thái mô hình",
|
||||
"模型的详细描述和基本特性": "Mô tả chi tiết và các đặc điểm cơ bản của mô hình",
|
||||
"模型相关设置": "Cài đặt liên quan đến mô hình",
|
||||
"模型相关设置": "Mô hình liên quan",
|
||||
"模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "Cộng đồng mô hình cần sự đóng góp của mọi người. Nếu bạn phát hiện dữ liệu sai hoặc muốn đóng góp dữ liệu mô hình mới, vui lòng truy cập:",
|
||||
"模型管理": "Quản lý mô hình",
|
||||
"模型类型": "Loại mô hình",
|
||||
@@ -1993,7 +2004,7 @@
|
||||
"模型部署": "Model Deployment",
|
||||
"模型部署服务未启用": "Model deployment service is not enabled",
|
||||
"模型部署管理": "Model Deployment Management",
|
||||
"模型部署设置": "Model Deployment Settings",
|
||||
"模型部署设置": "Triển khai mô hình",
|
||||
"模型配置": "Cấu hình mô hình",
|
||||
"模型重定向": "Chuyển hướng mô hình",
|
||||
"模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:": "The following models from the redirect have not been added to the “Models” list and requests will fail due to no available model:",
|
||||
@@ -2182,6 +2193,7 @@
|
||||
"添加分类": "Thêm danh mục",
|
||||
"添加分组": "Thêm nhóm",
|
||||
"添加分组倍率": "Thêm tỷ lệ nhóm",
|
||||
"添加分组规则": "Add Group Rules",
|
||||
"添加后提交": "Submit after adding",
|
||||
"添加启动参数": "Add Startup Args",
|
||||
"添加启动命令": "Add Startup Command",
|
||||
@@ -2214,6 +2226,14 @@
|
||||
"添加键值对": "Thêm cặp khóa-giá trị",
|
||||
"添加问答": "Thêm hỏi đáp",
|
||||
"添加额度": "Thêm hạn ngạch",
|
||||
"减少": "Giảm",
|
||||
"覆盖": "Ghi đè",
|
||||
"调整额度": "Điều chỉnh hạn ngạch",
|
||||
"调整额度成功": "Điều chỉnh hạn ngạch thành công",
|
||||
"当前额度": "Hạn ngạch hiện tại",
|
||||
"变更": "Thay đổi",
|
||||
"预计结果": "Kết quả dự kiến",
|
||||
"正数为增加,负数为减少": "Số dương để tăng, số âm để giảm",
|
||||
"清理": "Dọn dẹp",
|
||||
"清理不活跃缓存": "Xóa cache không hoạt động",
|
||||
"清理历史日志": "Dọn dẹp nhật ký lịch sử",
|
||||
@@ -2411,6 +2431,8 @@
|
||||
"用户注册": "Đăng ký người dùng",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "Tên trang web người dùng nhìn thấy khi đăng ký, ví dụ: 'Trang web của tôi'",
|
||||
"用户注册设置": "Cài đặt đăng ký người dùng",
|
||||
"用户消耗排行": "Xếp hạng tiêu thụ người dùng",
|
||||
"用户消耗趋势": "Xu hướng tiêu thụ người dùng",
|
||||
"用户登录": "Đăng nhập người dùng",
|
||||
"用户的基本账户信息": "Thông tin tài khoản cơ bản của người dùng",
|
||||
"用户管理": "Quản lý người dùng",
|
||||
@@ -2553,6 +2575,7 @@
|
||||
"确认冲突项修改": "Xác nhận sửa đổi mục xung đột",
|
||||
"确认删除": "Xác nhận xóa",
|
||||
"确认删除模型": "Confirm Delete Model",
|
||||
"确认删除该分组的所有规则?": "Delete all rules for this group?",
|
||||
"确认删除该分组?": "Confirm delete this group?",
|
||||
"确认删除该规则?": "Confirm delete this rule?",
|
||||
"确认取消密码登录": "Xác nhận hủy đăng nhập mật khẩu",
|
||||
@@ -2754,7 +2777,7 @@
|
||||
"系统监控": "Giám sát hệ thống",
|
||||
"系统管理": "Quản lý hệ thống",
|
||||
"系统管理功能": "Chức năng quản lý hệ thống",
|
||||
"系统设置": "Cài đặt hệ thống",
|
||||
"系统设置": "Hệ thống",
|
||||
"系统访问令牌": "Mã thông báo truy cập hệ thống",
|
||||
"系统负载": "Tải hệ thống",
|
||||
"系统通知": "Thông báo hệ thống",
|
||||
@@ -2807,7 +2830,7 @@
|
||||
"绘图任务记录": "Hồ sơ tác vụ vẽ",
|
||||
"绘图日志": "Nhật ký vẽ",
|
||||
"绘图模型": "Mô hình vẽ",
|
||||
"绘图设置": "Cài đặt vẽ",
|
||||
"绘图设置": "Vẽ",
|
||||
"统一的": "Cổng thống nhất",
|
||||
"统计": "Thống kê",
|
||||
"统计Tokens": "Thống kê Tokens",
|
||||
@@ -2898,7 +2921,7 @@
|
||||
"聊天区域": "Khu vực trò chuyện",
|
||||
"聊天应用名称": "Tên ứng dụng trò chuyện",
|
||||
"聊天应用名称已存在,请使用其他名称": "Tên ứng dụng trò chuyện đã tồn tại, vui lòng sử dụng tên khác",
|
||||
"聊天设置": "Cài đặt trò chuyện",
|
||||
"聊天设置": "Trò chuyện",
|
||||
"聊天配置": "Cấu hình trò chuyện",
|
||||
"聊天链接配置错误,请联系管理员": "Lỗi cấu hình liên kết trò chuyện, vui lòng liên hệ quản trị viên",
|
||||
"联系": "Liên hệ",
|
||||
@@ -3223,6 +3246,7 @@
|
||||
"请求参数无效": "Invalid request parameters",
|
||||
"请求发生错误": "Đã xảy ra lỗi yêu cầu",
|
||||
"请求发生错误: ": "Đã xảy ra lỗi yêu cầu: ",
|
||||
"模型价格未配置": "Giá mô hình chưa được cấu hình",
|
||||
"请求后端接口失败:": "Yêu cầu giao diện phụ trợ thất bại: ",
|
||||
"请求失败": "Yêu cầu thất bại",
|
||||
"请求失败,请重试": "Yêu cầu thất bại, vui lòng thử lại",
|
||||
@@ -3470,6 +3494,7 @@
|
||||
"调用次数": "Số lần gọi",
|
||||
"调用次数分布": "Phân phối số lần gọi",
|
||||
"调用次数排行": "Xếp hạng số lần gọi",
|
||||
"调用趋势": "Xu hướng cuộc gọi",
|
||||
"调试信息": "Thông tin gỡ lỗi",
|
||||
"谨慎": "Thận trọng",
|
||||
"豆包": "Doubao",
|
||||
@@ -3586,7 +3611,7 @@
|
||||
"过期时间不能早于当前时间!": "Thời gian hết hạn không thể sớm hơn thời gian hiện tại!",
|
||||
"过期时间快捷设置": "Cài đặt nhanh thời gian hết hạn",
|
||||
"过期时间格式错误!": "Lỗi định dạng thời gian hết hạn!",
|
||||
"运营设置": "Cài đặt vận hành",
|
||||
"运营设置": "Vận hành",
|
||||
"运行中": "Đang chạy",
|
||||
"运行命令 (Command)": "Command",
|
||||
"运行时长": "Runtime Duration",
|
||||
@@ -3710,7 +3735,7 @@
|
||||
"通道管理": "Quản lý kênh",
|
||||
"通道类型": "Loại kênh",
|
||||
"通道设置": "Cài đặt kênh",
|
||||
"速率限制设置": "Cài đặt giới hạn tốc độ",
|
||||
"速率限制设置": "Giới hạn tốc độ",
|
||||
"逻辑": "Logic",
|
||||
"邀请": "Mời",
|
||||
"邀请人": "Người mời",
|
||||
@@ -3925,6 +3950,7 @@
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "Đầu ra âm thanh: {{tokens}} / 1M * hệ số mô hình {{modelRatio}} * hệ số âm thanh {{audioRatio}} * hệ số hoàn thành âm thanh {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"页脚": "Chân trang",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "Không tìm thấy trang, vui lòng kiểm tra xem địa chỉ trình duyệt của bạn có chính xác không",
|
||||
"页面渲染出错,请刷新页面重试": "Đã xảy ra lỗi khi hiển thị trang. Vui lòng tải lại trang và thử lại.",
|
||||
"顶栏管理": "Quản lý thanh tiêu đề",
|
||||
"项": "mục",
|
||||
"项目": "Dự án",
|
||||
|
||||
Vendored
+23
-2
@@ -140,6 +140,7 @@
|
||||
"Reasoning Effort": "Reasoning Effort",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示",
|
||||
"SMTP 发送者邮箱": "SMTP 发送者邮箱",
|
||||
"SMTP 服务器地址": "SMTP 服务器地址",
|
||||
@@ -286,7 +287,7 @@
|
||||
"以下上游数据可能不可信:": "以下上游数据可能不可信:",
|
||||
"以下文件解析失败,已忽略:{{list}}": "以下文件解析失败,已忽略:{{list}}",
|
||||
"以及": "以及",
|
||||
"仪表盘设置": "仪表盘设置",
|
||||
"仪表盘设置": "仪表盘",
|
||||
"价格": "价格",
|
||||
"价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}}": "价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}}",
|
||||
"价格:${{price}} * {{ratioType}}:{{ratio}}": "价格:${{price}} * {{ratioType}}:{{ratio}}",
|
||||
@@ -410,6 +411,7 @@
|
||||
"允许 HTTP 协议图片请求(适用于自部署代理)": "允许 HTTP 协议图片请求(适用于自部署代理)",
|
||||
"允许 safety_identifier 透传": "允许 safety_identifier 透传",
|
||||
"允许 service_tier 透传": "允许 service_tier 透传",
|
||||
"允许 speed 透传": "允许 speed 透传",
|
||||
"允许 Turnstile 用户校验": "允许 Turnstile 用户校验",
|
||||
"允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)",
|
||||
@@ -680,6 +682,7 @@
|
||||
"启用Gemini思考后缀适配": "启用Gemini思考后缀适配",
|
||||
"启用Ping间隔": "启用Ping间隔",
|
||||
"启用SMTP SSL": "启用SMTP SSL",
|
||||
"强制使用 AUTH LOGIN": "强制使用 AUTH LOGIN",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)",
|
||||
"启用全部": "启用全部",
|
||||
"启用后可接入 io.net GPU 资源": "启用后可接入 io.net GPU 资源",
|
||||
@@ -1604,6 +1607,14 @@
|
||||
"添加键值对": "添加键值对",
|
||||
"添加问答": "添加问答",
|
||||
"添加额度": "添加额度",
|
||||
"减少": "减少",
|
||||
"覆盖": "覆盖",
|
||||
"调整额度": "调整额度",
|
||||
"调整额度成功": "调整额度成功",
|
||||
"当前额度": "当前额度",
|
||||
"变更": "变更",
|
||||
"预计结果": "预计结果",
|
||||
"正数为增加,负数为减少": "正数为增加,负数为减少",
|
||||
"清理方式": "清理方式",
|
||||
"清理日志文件": "清理日志文件",
|
||||
"清空": "清空",
|
||||
@@ -2144,6 +2155,7 @@
|
||||
"请求参数无效": "请求参数无效",
|
||||
"请求发生错误": "请求发生错误",
|
||||
"请求发生错误: ": "请求发生错误: ",
|
||||
"模型价格未配置": "模型价格未配置",
|
||||
"请求后端接口失败:": "请求后端接口失败:",
|
||||
"请求失败": "请求失败",
|
||||
"请求头覆盖": "请求头覆盖",
|
||||
@@ -2314,6 +2326,10 @@
|
||||
"调用次数": "调用次数",
|
||||
"调用次数分布": "调用次数分布",
|
||||
"调用次数排行": "调用次数排行",
|
||||
"调用趋势": "调用趋势",
|
||||
"模型排行": "模型排行",
|
||||
"用户消耗排行": "用户消耗排行",
|
||||
"用户消耗趋势": "用户消耗趋势",
|
||||
"调试信息": "调试信息",
|
||||
"谨慎": "谨慎",
|
||||
"警告": "警告",
|
||||
@@ -2732,6 +2748,8 @@
|
||||
"请输入总额度": "请输入总额度",
|
||||
"0 表示不限": "0 表示不限",
|
||||
"原生额度": "原生额度",
|
||||
"使用原生额度输入": "使用原生额度输入",
|
||||
"收起原生额度输入": "收起原生额度输入",
|
||||
"升级分组": "升级分组",
|
||||
"不升级": "不升级",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。",
|
||||
@@ -2781,6 +2799,7 @@
|
||||
"至": "至",
|
||||
"过期于": "过期于",
|
||||
"作废于": "作废于",
|
||||
"下一次重置": "下一次重置",
|
||||
"购买套餐后即可享受模型权益": "购买套餐后即可享受模型权益",
|
||||
"限购": "限购",
|
||||
"推荐": "推荐",
|
||||
@@ -2980,6 +2999,8 @@
|
||||
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
|
||||
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
|
||||
"连接信息已填入": "连接信息已填入",
|
||||
"无法读取剪贴板": "无法读取剪贴板"
|
||||
"无法读取剪贴板": "无法读取剪贴板",
|
||||
"页面渲染出错,请刷新页面重试": "页面渲染出错,请刷新页面重试",
|
||||
"刷新页面": "刷新页面"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+28
-2
@@ -198,9 +198,11 @@
|
||||
"default为默认设置,可单独设置每个分类的安全等级": "default為預設設定,可單獨設定每個分類的安全等級",
|
||||
"default为默认设置,可单独设置每个模型的版本": "default為預設設定,可單獨設定每個模型的版本",
|
||||
"false": "false",
|
||||
"inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息": "inference_geo 字段用於控制 Claude 資料駐留推理區域。預設關閉以避免未經授權透傳地域資訊",
|
||||
"price_xxx 的商品价格 ID,新建产品后可获得": "price_xxx 的商品價格 ID,新建產品後可獲得",
|
||||
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "safety_identifier 字段用於幫助 OpenAI 識別可能違反使用政策的應用程式使用者。預設關閉以保護使用者隱私",
|
||||
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "service_tier 字段用於指定服務層級,允許透傳可能導致實際計費高於預期。預設關閉以避免額外費用",
|
||||
"speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式": "speed 字段用於控制 Claude 推理速度模式。預設關閉以避免意外切換到 fast 模式",
|
||||
"sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示": "sk_xxx 或 rk_xxx 的 Stripe 密鑰,敏感資訊不顯示",
|
||||
"standard 已被移除,vip 用户看不到": "standard 已被移除,vip 使用者看不到",
|
||||
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "store 字段用於授權 OpenAI 存儲請求數據以評估和優化產品。預設關閉,開啟後可能導致 Codex 無法正常使用",
|
||||
@@ -379,6 +381,7 @@
|
||||
"余额充值管理": "餘額儲值管理",
|
||||
"作废": "作廢",
|
||||
"作废于": "作廢於",
|
||||
"下一次重置": "下一次重置",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "作廢後該訂閱將立即失效,歷史記錄不受影響。是否繼續?",
|
||||
"你似乎并没有修改什么": "你似乎並沒有修改什麼",
|
||||
"你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。": "你可以在「自訂模型名稱」處手動添加它們,然後點擊填入後再提交,或者直接使用下方操作自動處理。",
|
||||
@@ -449,7 +452,7 @@
|
||||
"保存 Turnstile 设置": "儲存 Turnstile 設定",
|
||||
"保存 WeChat Server 设置": "儲存 WeChat Server 設定",
|
||||
"保存分组倍率设置": "儲存分組倍率設定",
|
||||
"保存分组相关设置": "儲存分組相關設定",
|
||||
"保存分组相关设置": "保存分組相關設定",
|
||||
"保存备用码": "儲存備用碼",
|
||||
"保存备用码以备不时之需": "儲存備用碼以備不時之需",
|
||||
"保存失败": "儲存失敗",
|
||||
@@ -497,6 +500,8 @@
|
||||
"允许 Turnstile 用户校验": "允許 Turnstile 使用者校驗",
|
||||
"允许 safety_identifier 透传": "允許 safety_identifier 透傳",
|
||||
"允许 service_tier 透传": "允許 service_tier 透傳",
|
||||
"允许 inference_geo 透传": "允許 inference_geo 透傳",
|
||||
"允许 speed 透传": "允許 speed 透傳",
|
||||
"允许不安全的 Origin(HTTP)": "允許不安全的 Origin(HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "允許回調(會洩露伺服器 IP 位址)",
|
||||
"允许在 Stripe 支付中输入促销码": "允許在 Stripe 支付中輸入促銷碼",
|
||||
@@ -602,7 +607,7 @@
|
||||
"分类名称": "分類名稱",
|
||||
"分组": "分組",
|
||||
"分组JSON设置": "分組 JSON 設定",
|
||||
"分组与模型定价设置": "分組與模型定價設定",
|
||||
"分组与模型定价设置": "分組與模型定價",
|
||||
"分组价格": "分組價格",
|
||||
"分组倍率": "分組倍率",
|
||||
"分组倍率设置": "分組倍率設定",
|
||||
@@ -670,6 +675,7 @@
|
||||
"刷新容器信息": "刷新容器資訊",
|
||||
"刷新日志": "刷新日誌",
|
||||
"刷新统计": "刷新統計",
|
||||
"刷新页面": "重新整理頁面",
|
||||
"前往 io.net API Keys": "前往 io.net API Keys",
|
||||
"前往设置": "前往設定",
|
||||
"前往设置页面": "前往設定頁面",
|
||||
@@ -718,6 +724,8 @@
|
||||
"原密码": "原密碼",
|
||||
"原生格式": "原生格式",
|
||||
"原生额度": "原生額度",
|
||||
"使用原生额度输入": "使用原生額度輸入",
|
||||
"收起原生额度输入": "收起原生額度輸入",
|
||||
"去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥": "去重完成:去重前 {{before}} 個密鑰,去重後 {{after}} 個密鑰",
|
||||
"参与官方同步": "參與官方同步",
|
||||
"参数": "參數",
|
||||
@@ -797,6 +805,7 @@
|
||||
"启用Gemini思考后缀适配": "啟用Gemini思考後綴相容",
|
||||
"启用Ping间隔": "啟用Ping間隔",
|
||||
"启用SMTP SSL": "啟用SMTP SSL",
|
||||
"强制使用 AUTH LOGIN": "強制使用 AUTH LOGIN",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "啟用SSRF防護(推薦開啟以保護伺服器安全)",
|
||||
"启用全部": "啟用全部",
|
||||
"启用后可接入 io.net GPU 资源": "啟用後可接入 io.net GPU 資源",
|
||||
@@ -1657,6 +1666,7 @@
|
||||
"条": "條",
|
||||
"条 - 第": "條 - 第",
|
||||
"条日志已清理!": "條日誌已清理!",
|
||||
"条规则": "條規則",
|
||||
"条,共": "條,共",
|
||||
"来源": "來源",
|
||||
"来源于 IO.NET 部署": "來源於 IO.NET 部署",
|
||||
@@ -1743,6 +1753,7 @@
|
||||
"模型定价,需要登录访问": "模型定價,需要登錄訪問",
|
||||
"模型广场": "模型廣場",
|
||||
"模型拉取失败: {{error}}": "模型拉取失敗: {{error}}",
|
||||
"模型排行": "模型排行",
|
||||
"模型支持的接口端点信息": "模型支援的接口端點資訊",
|
||||
"模型数据分析": "模型數據分析",
|
||||
"模型映射必须是合法的 JSON 格式!": "模型映射必須是合法的 JSON 格式!",
|
||||
@@ -1884,6 +1895,7 @@
|
||||
"添加公告": "添加公告",
|
||||
"添加分类": "添加分類",
|
||||
"添加分组": "新增分組",
|
||||
"添加分组规则": "新增分組規則",
|
||||
"添加后提交": "添加後提交",
|
||||
"添加启动参数": "添加啟動參數",
|
||||
"添加启动命令": "添加啟動命令",
|
||||
@@ -1900,6 +1912,14 @@
|
||||
"添加键值对": "添加鍵值對",
|
||||
"添加问答": "添加問答",
|
||||
"添加额度": "添加額度",
|
||||
"减少": "減少",
|
||||
"覆盖": "覆蓋",
|
||||
"调整额度": "調整額度",
|
||||
"调整额度成功": "調整額度成功",
|
||||
"当前额度": "當前額度",
|
||||
"变更": "變更",
|
||||
"预计结果": "預計結果",
|
||||
"正数为增加,负数为减少": "正數為增加,負數為減少",
|
||||
"清理不活跃缓存": "清理不活躍快取",
|
||||
"清理失败": "清理失敗",
|
||||
"清理方式": "清理方式",
|
||||
@@ -2006,6 +2026,8 @@
|
||||
"用户每周期最多请求完成次数": "使用者每週期最多請求完成次數",
|
||||
"用户每周期最多请求次数": "使用者每週期最多請求次數",
|
||||
"用户注册时看到的网站名称,比如'我的网站'": "使用者註冊時看到的網站名稱,比如'我的網站'",
|
||||
"用户消耗排行": "用戶消耗排行",
|
||||
"用户消耗趋势": "用戶消耗趨勢",
|
||||
"用户的基本账户信息": "使用者的基本帳號資訊",
|
||||
"用户管理": "使用者管理",
|
||||
"用户组": "使用者組",
|
||||
@@ -2089,6 +2111,7 @@
|
||||
"确认冲突项修改": "確認衝突項修改",
|
||||
"确认删除": "確認刪除",
|
||||
"确认删除模型": "確認刪除模型",
|
||||
"确认删除该分组的所有规则?": "確認刪除該分組的所有規則?",
|
||||
"确认删除该分组?": "確認刪除該分組?",
|
||||
"确认删除该规则?": "確認刪除該規則?",
|
||||
"确认取消密码登录": "確認取消密碼登錄",
|
||||
@@ -2545,6 +2568,7 @@
|
||||
"请求参数无效": "請求參數無效",
|
||||
"请求发生错误": "請求發生錯誤",
|
||||
"请求发生错误: ": "請求發生錯誤: ",
|
||||
"模型价格未配置": "模型價格未配置",
|
||||
"请求后端接口失败:": "請求後端接口失敗:",
|
||||
"请求失败": "請求失敗",
|
||||
"请求头覆盖": "請求頭覆蓋",
|
||||
@@ -2719,6 +2743,7 @@
|
||||
"调用次数": "調用次數",
|
||||
"调用次数分布": "調用次數分佈",
|
||||
"调用次数排行": "調用次數排行",
|
||||
"调用趋势": "調用趨勢",
|
||||
"调试信息": "除錯訊息",
|
||||
"谨慎": "謹慎",
|
||||
"豆包": "豆包",
|
||||
@@ -3041,6 +3066,7 @@
|
||||
"音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "音訊輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音訊倍率 {{audioRatio}} * 音訊補全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
|
||||
"页脚": "頁腳",
|
||||
"页面未找到,请检查您的浏览器地址是否正确": "頁面未找到,請檢查您的瀏覽器位址是否正確",
|
||||
"页面渲染出错,请刷新页面重试": "頁面渲染出錯,請重新整理頁面重試",
|
||||
"顶栏管理": "頂欄管理",
|
||||
"项目": "項目",
|
||||
"项目内容": "項目內容",
|
||||
|
||||
@@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
|
||||
},
|
||||
"skip_retry_on_failure": false,
|
||||
"include_using_group": true,
|
||||
"include_model_name": false,
|
||||
"include_rule_name": true
|
||||
}
|
||||
]`;
|
||||
@@ -191,6 +192,36 @@ const parseOptionalObjectJson = (jsonString, label) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildChannelAffinityRulePayload = ({
|
||||
values,
|
||||
isEdit,
|
||||
editingRuleId,
|
||||
rulesLength,
|
||||
modelRegex,
|
||||
pathRegex,
|
||||
keySources,
|
||||
userAgentInclude,
|
||||
paramOverrideTemplate,
|
||||
}) => ({
|
||||
id: isEdit ? editingRuleId : rulesLength,
|
||||
name: (values?.name || '').trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: pathRegex,
|
||||
key_sources: keySources,
|
||||
value_regex: (values?.value_regex || '').trim(),
|
||||
ttl_seconds: Number(values?.ttl_seconds || 0),
|
||||
include_using_group: !!values?.include_using_group,
|
||||
include_model_name: !!values?.include_model_name,
|
||||
include_rule_name: !!values?.include_rule_name,
|
||||
skip_retry_on_failure: !!values?.skip_retry_on_failure,
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramOverrideTemplate
|
||||
? { param_override_template: paramOverrideTemplate }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export default function SettingsChannelAffinity(props) {
|
||||
const { t } = useTranslation();
|
||||
const { Text } = Typography;
|
||||
@@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
ttl_seconds: Number(r.ttl_seconds || 0),
|
||||
skip_retry_on_failure: !!r.skip_retry_on_failure,
|
||||
include_using_group: r.include_using_group ?? true,
|
||||
include_model_name: !!r.include_model_name,
|
||||
include_rule_name: r.include_rule_name ?? true,
|
||||
param_override_template_json: r.param_override_template
|
||||
? stringifyPretty(r.param_override_template)
|
||||
@@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
|
||||
const templates = [
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
|
||||
].map(
|
||||
(tpl) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...baseTemplate, name };
|
||||
},
|
||||
);
|
||||
].map((tpl) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...baseTemplate, name };
|
||||
});
|
||||
|
||||
const next = [...(rules || []), ...templates].map((r, idx) => ({
|
||||
...(r || {}),
|
||||
@@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
|
||||
title: t('作用域'),
|
||||
render: (_, record) => {
|
||||
const tags = [];
|
||||
if (record?.include_using_group) tags.push('分组');
|
||||
if (record?.include_rule_name) tags.push('规则');
|
||||
if (record?.include_using_group) tags.push(t('分组'));
|
||||
if (record?.include_model_name) tags.push(t('模型'));
|
||||
if (record?.include_rule_name) tags.push(t('规则'));
|
||||
if (tags.length === 0) return '-';
|
||||
return tags.map((x) => (
|
||||
<Tag key={x} style={{ marginRight: 4 }}>
|
||||
@@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: false,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
};
|
||||
setEditingRule(nextRule);
|
||||
@@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
|
||||
return showError(t(paramTemplateValidation.message));
|
||||
}
|
||||
|
||||
const rulePayload = {
|
||||
id: isEdit ? editingRule.id : rules.length,
|
||||
name: (values.name || '').trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: normalizeStringList(values.path_regex_text),
|
||||
key_sources: keySourcesValidation.value,
|
||||
value_regex: (values.value_regex || '').trim(),
|
||||
ttl_seconds: Number(values.ttl_seconds || 0),
|
||||
include_using_group: !!values.include_using_group,
|
||||
include_rule_name: !!values.include_rule_name,
|
||||
...(values.skip_retry_on_failure
|
||||
? { skip_retry_on_failure: true }
|
||||
: {}),
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramTemplateValidation.value
|
||||
? { param_override_template: paramTemplateValidation.value }
|
||||
: {}),
|
||||
};
|
||||
const rulePayload = buildChannelAffinityRulePayload({
|
||||
values,
|
||||
isEdit,
|
||||
editingRuleId: editingRule?.id,
|
||||
rulesLength: rules.length,
|
||||
modelRegex,
|
||||
pathRegex: normalizeStringList(values.path_regex_text),
|
||||
keySources: keySourcesValidation.value,
|
||||
userAgentInclude,
|
||||
paramOverrideTemplate: paramTemplateValidation.value,
|
||||
});
|
||||
|
||||
if (!rulePayload.name) return showError(t('名称不能为空'));
|
||||
|
||||
@@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_using_group'
|
||||
label={t('作用域:包含分组')}
|
||||
@@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_model_name'
|
||||
label={t('作用域:包含模型名称')}
|
||||
/>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('开启后,模型名称会参与 cache key(不同模型隔离)。')}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_rule_name'
|
||||
label={t('作用域:包含规则名称')}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CardTable from '../../../../components/common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -57,14 +64,106 @@ export function serializeGroupGroupRatio(rules) {
|
||||
: JSON.stringify(nested, null, 2);
|
||||
}
|
||||
|
||||
function GroupSection({ groupName, items, groupOptions, onUpdate, onRemove, onAdd, t }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
|
||||
<Text strong>{groupName}</Text>
|
||||
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => onAdd(groupName)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组的所有规则?')}
|
||||
onConfirm={() => items.forEach((item) => onRemove(item._id))}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{items.map((rule) => (
|
||||
<div
|
||||
key={rule._id}
|
||||
className='flex items-center gap-2'
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={rule.usingGroup || undefined}
|
||||
placeholder={t('选择使用分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => onUpdate(rule._id, 'usingGroup', v)}
|
||||
style={{ flex: 1 }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={rule.ratio}
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => onUpdate(rule._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => onRemove(rule._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupGroupRatioRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
@@ -76,21 +175,11 @@ export default function GroupGroupRatioRules({
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
const next = rules.map((r) =>
|
||||
r._id === id ? { ...r, [field]: val } : r,
|
||||
);
|
||||
emitChange(next);
|
||||
emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: '', usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
}, [rules, emitChange]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => {
|
||||
emitChange(rules.filter((r) => r._id !== id));
|
||||
@@ -98,107 +187,99 @@ export default function GroupGroupRatioRules({
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName) => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: groupName, usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addNewGroup = useCallback(() => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: name, usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
}, [rules, emitChange, newGroupName]);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('用户分组'),
|
||||
dataIndex: 'userGroup',
|
||||
key: 'userGroup',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
const grouped = useMemo(() => {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rules.forEach((r) => {
|
||||
if (!r.userGroup) return;
|
||||
if (!map[r.userGroup]) {
|
||||
map[r.userGroup] = [];
|
||||
order.push(r.userGroup);
|
||||
}
|
||||
map[r.userGroup].push(r);
|
||||
});
|
||||
return order.map((name) => ({ name, items: map[name] }));
|
||||
}, [rules]);
|
||||
|
||||
if (grouped.length === 0 && rules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={record.userGroup || undefined}
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateRule(record._id, 'userGroup', v)}
|
||||
style={{ width: '100%' }}
|
||||
allowCreate
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('使用分组'),
|
||||
dataIndex: 'usingGroup',
|
||||
key: 'usingGroup',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={record.usingGroup || undefined}
|
||||
placeholder={t('选择使用分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateRule(record._id, 'usingGroup', v)}
|
||||
style={{ width: '100%' }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={record.ratio}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateRule(record._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => removeRule(record._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, groupOptions, updateRule, removeRule],
|
||||
);
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
|
||||
{t('添加规则')}
|
||||
<div className='space-y-2'>
|
||||
{grouped.map((group) => (
|
||||
<GroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
groupOptions={groupOptions}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className='mt-3 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CardTable from '../../../../components/common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -21,12 +44,8 @@ const OP_REMOVE = 'remove';
|
||||
const OP_APPEND = 'append';
|
||||
|
||||
function parsePrefix(rawKey) {
|
||||
if (rawKey.startsWith('+:')) {
|
||||
return { op: OP_ADD, groupName: rawKey.slice(2) };
|
||||
}
|
||||
if (rawKey.startsWith('-:')) {
|
||||
return { op: OP_REMOVE, groupName: rawKey.slice(2) };
|
||||
}
|
||||
if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) };
|
||||
if (rawKey.startsWith('-:')) return { op: OP_REMOVE, groupName: rawKey.slice(2) };
|
||||
return { op: OP_APPEND, groupName: rawKey };
|
||||
}
|
||||
|
||||
@@ -38,11 +57,7 @@ function toRawKey(op, groupName) {
|
||||
|
||||
function parseJSON(str) {
|
||||
if (!str || !str.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
try { return JSON.parse(str); } catch { return {}; }
|
||||
}
|
||||
|
||||
function flattenRules(nested) {
|
||||
@@ -68,17 +83,14 @@ function nestRules(rules) {
|
||||
rules.forEach(({ userGroup, op, targetGroup, description }) => {
|
||||
if (!userGroup || !targetGroup) return;
|
||||
if (!result[userGroup]) result[userGroup] = {};
|
||||
const key = toRawKey(op, targetGroup);
|
||||
result[userGroup][key] = description;
|
||||
result[userGroup][toRawKey(op, targetGroup)] = description;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeGroupSpecialUsable(rules) {
|
||||
const nested = nestRules(rules);
|
||||
return Object.keys(nested).length === 0
|
||||
? ''
|
||||
: JSON.stringify(nested, null, 2);
|
||||
return Object.keys(nested).length === 0 ? '' : JSON.stringify(nested, null, 2);
|
||||
}
|
||||
|
||||
const OP_TAG_MAP = {
|
||||
@@ -87,14 +99,118 @@ const OP_TAG_MAP = {
|
||||
[OP_APPEND]: { color: 'blue', label: '追加' },
|
||||
};
|
||||
|
||||
function UsableGroupSection({ groupName, items, opOptions, onUpdate, onRemove, onAdd, t }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
|
||||
<Text strong>{groupName}</Text>
|
||||
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => onAdd(groupName)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组的所有规则?')}
|
||||
onConfirm={() => items.forEach((item) => onRemove(item._id))}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{items.map((rule) => (
|
||||
<div
|
||||
key={rule._id}
|
||||
className='flex items-center gap-2'
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
value={rule.op}
|
||||
optionList={opOptions}
|
||||
onChange={(v) => onUpdate(rule._id, 'op', v)}
|
||||
style={{ width: 120 }}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
const info = OP_TAG_MAP[optionNode.value] || {};
|
||||
return <Tag size='small' color={info.color}>{optionNode.label}</Tag>;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
size='small'
|
||||
value={rule.targetGroup}
|
||||
placeholder={t('分组名称')}
|
||||
onChange={(v) => onUpdate(rule._id, 'targetGroup', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{rule.op !== OP_REMOVE ? (
|
||||
<Input
|
||||
size='small'
|
||||
value={rule.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => onUpdate(rule._id, 'description', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type='tertiary' size='small'>-</Text>
|
||||
</div>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => onRemove(rule._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupSpecialUsableRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
@@ -106,41 +222,46 @@ export default function GroupSpecialUsableRules({
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
const next = rules.map((r) => {
|
||||
if (r._id !== id) return r;
|
||||
const updated = { ...r, [field]: val };
|
||||
if (field === 'op' && val === OP_REMOVE) {
|
||||
updated.description = 'remove';
|
||||
} else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
|
||||
if (updated.description === 'remove') updated.description = '';
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
emitChange(next);
|
||||
emitChange(
|
||||
rules.map((r) => {
|
||||
if (r._id !== id) return r;
|
||||
const updated = { ...r, [field]: val };
|
||||
if (field === 'op' && val === OP_REMOVE) updated.description = 'remove';
|
||||
else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
|
||||
if (updated.description === 'remove') updated.description = '';
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{
|
||||
_id: uid(),
|
||||
userGroup: '',
|
||||
op: OP_APPEND,
|
||||
targetGroup: '',
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}, [rules, emitChange]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => {
|
||||
emitChange(rules.filter((r) => r._id !== id));
|
||||
(id) => emitChange(rules.filter((r) => r._id !== id)),
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName) => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: groupName, op: OP_APPEND, targetGroup: '', description: '' },
|
||||
]);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addNewGroup = useCallback(() => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: name, op: OP_APPEND, targetGroup: '', description: '' },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
}, [rules, emitChange, newGroupName]);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
@@ -155,120 +276,74 @@ export default function GroupSpecialUsableRules({
|
||||
[t],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('用户分组'),
|
||||
dataIndex: 'userGroup',
|
||||
key: 'userGroup',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
const grouped = useMemo(() => {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rules.forEach((r) => {
|
||||
if (!r.userGroup) return;
|
||||
if (!map[r.userGroup]) {
|
||||
map[r.userGroup] = [];
|
||||
order.push(r.userGroup);
|
||||
}
|
||||
map[r.userGroup].push(r);
|
||||
});
|
||||
return order.map((name) => ({ name, items: map[name] }));
|
||||
}, [rules]);
|
||||
|
||||
if (grouped.length === 0 && rules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={record.userGroup || undefined}
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateRule(record._id, 'userGroup', v)}
|
||||
style={{ width: '100%' }}
|
||||
allowCreate
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
dataIndex: 'op',
|
||||
key: 'op',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
size='small'
|
||||
value={record.op}
|
||||
optionList={opOptions}
|
||||
onChange={(v) => updateRule(record._id, 'op', v)}
|
||||
style={{ width: '100%' }}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
const tagInfo = OP_TAG_MAP[optionNode.value] || {};
|
||||
return (
|
||||
<Tag size='small' color={tagInfo.color}>
|
||||
{optionNode.label}
|
||||
</Tag>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('目标分组'),
|
||||
dataIndex: 'targetGroup',
|
||||
key: 'targetGroup',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.targetGroup}
|
||||
placeholder={t('分组名称')}
|
||||
onChange={(v) => updateRule(record._id, 'targetGroup', v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (_, record) =>
|
||||
record.op === OP_REMOVE ? (
|
||||
<Text type='tertiary' size='small'>-</Text>
|
||||
) : (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => updateRule(record._id, 'description', v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => removeRule(record._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, groupOptions, opOptions, updateRule, removeRule],
|
||||
);
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
|
||||
{t('添加规则')}
|
||||
<div className='space-y-2'>
|
||||
{grouped.map((group) => (
|
||||
<UsableGroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
opOptions={opOptions}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className='mt-3 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -61,60 +61,63 @@ export function serializeGroupTable(rows) {
|
||||
};
|
||||
}
|
||||
|
||||
export default function GroupTable({
|
||||
groupRatio,
|
||||
userUsableGroups,
|
||||
onChange,
|
||||
}) {
|
||||
export default function GroupTable({ groupRatio, userUsableGroups, onChange }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rows, setRows] = useState(() =>
|
||||
buildRows(groupRatio, userUsableGroups),
|
||||
);
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRows) => {
|
||||
setRows(newRows);
|
||||
onChange?.(serializeGroupTable(newRows));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
// Use functional setRows to keep updateRow/addRow/removeRow referentially
|
||||
// stable, preventing columns useMemo from rebuilding on every keystroke
|
||||
// which causes the Input cursor to jump to end (cursor reset bug).
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const emitAndSet = useCallback((updater) => {
|
||||
setRows((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
onChangeRef.current?.(serializeGroupTable(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(id, field, value) => {
|
||||
const next = rows.map((r) =>
|
||||
r._id === id ? { ...r, [field]: value } : r,
|
||||
emitAndSet((prev) =>
|
||||
prev.map((r) => (r._id === id ? { ...r, [field]: value } : r)),
|
||||
);
|
||||
emitChange(next);
|
||||
},
|
||||
[rows, emitChange],
|
||||
[emitAndSet],
|
||||
);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
const existingNames = new Set(rows.map((r) => r.name));
|
||||
let counter = 1;
|
||||
let newName = `group_${counter}`;
|
||||
while (existingNames.has(newName)) {
|
||||
counter++;
|
||||
newName = `group_${counter}`;
|
||||
}
|
||||
emitChange([
|
||||
...rows,
|
||||
{
|
||||
_id: uid(),
|
||||
name: newName,
|
||||
ratio: 1,
|
||||
selectable: true,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}, [rows, emitChange]);
|
||||
emitAndSet((prev) => {
|
||||
const existingNames = new Set(prev.map((r) => r.name));
|
||||
let counter = 1;
|
||||
let newName = `group_${counter}`;
|
||||
while (existingNames.has(newName)) {
|
||||
counter++;
|
||||
newName = `group_${counter}`;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
_id: uid(),
|
||||
name: newName,
|
||||
ratio: 1,
|
||||
selectable: true,
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [emitAndSet]);
|
||||
|
||||
const removeRow = useCallback(
|
||||
(id) => {
|
||||
emitChange(rows.filter((r) => r._id !== id));
|
||||
emitAndSet((prev) => prev.filter((r) => r._id !== id));
|
||||
},
|
||||
[rows, emitChange],
|
||||
[emitAndSet],
|
||||
);
|
||||
|
||||
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
|
||||
@@ -127,6 +130,11 @@ export default function GroupTable({
|
||||
return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
|
||||
}, [groupNames]);
|
||||
|
||||
// Use ref so column render functions always read the latest duplicate set
|
||||
// without adding duplicateNames to columns deps (which would break cursor).
|
||||
const duplicateNamesRef = useRef(duplicateNames);
|
||||
duplicateNamesRef.current = duplicateNames;
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -138,7 +146,9 @@ export default function GroupTable({
|
||||
<Input
|
||||
size='small'
|
||||
value={record.name}
|
||||
status={duplicateNames.has(record.name) ? 'warning' : undefined}
|
||||
status={
|
||||
duplicateNamesRef.current.has(record.name) ? 'warning' : undefined
|
||||
}
|
||||
onChange={(v) => updateRow(record._id, 'name', v)}
|
||||
/>
|
||||
),
|
||||
@@ -212,7 +222,7 @@ export default function GroupTable({
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, duplicateNames, updateRow, removeRow],
|
||||
[t, updateRow, removeRow],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -223,9 +233,7 @@ export default function GroupTable({
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>
|
||||
}
|
||||
empty={<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRow}>
|
||||
@@ -234,7 +242,8 @@ export default function GroupTable({
|
||||
</div>
|
||||
{duplicateNames.size > 0 && (
|
||||
<Text type='warning' size='small' className='mt-2 block'>
|
||||
{t('存在重复的分组名称:')}{Array.from(duplicateNames).join(', ')}
|
||||
{t('存在重复的分组名称:')}
|
||||
{Array.from(duplicateNames).join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user