Compare commits

...

20 Commits

Author SHA1 Message Date
CaIon a706f00287 feat(EditChannelModal): persist advanced settings state in local storage
Added functionality to save and restore the state of advanced settings in the EditChannelModal using local storage. This enhancement allows users to maintain their preferences when editing channels, improving the overall user experience.
2026-04-02 00:17:21 +08:00
Calcium-Ion 7efb1922fe Merge pull request #3526 from feitianbubu/pr/e560265b6e57aa7b95bc98cb53397ef0a3082d9d
支持wan2.7生图-wan2.7-image
2026-04-02 00:15:04 +08:00
Calcium-Ion 89fe99f3bd Merge pull request #3512 from imlhb/patch-2
fix: prevent double-counting of image count n in billing
2026-04-02 00:14:39 +08:00
feitianbubu e5b5331d3b feat: wan 2.7 support N for gen images number 2026-04-01 17:39:50 +08:00
feitianbubu 18373c6eac feat: add wan 2.7 2026-04-01 17:39:11 +08:00
Calcium-Ion 5b47011e08 Merge pull request #3450 from QuantumNous/dependabot/npm_and_yarn/electron/picomatch-4.0.4
chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in /electron
2026-04-01 14:35:30 +08:00
CaIon ab99c30884 fix: move image count n to OtherRatio to prevent double-counting
The previous commit commented out AddOtherRatio("n") in the Ali
adaptor to fix double-counting but this could cause billing evasion
when n is specified via extra["parameters"] instead of request.N.

Root cause: ImagePriceRatio in GetTokenCountMeta() already included
n, AND channel adaptors added OtherRatio("n"), resulting in n²
billing.

Proper fix:
- Remove n from ImagePriceRatio (keep sizeRatio * qualityRatio only)
- In ImageHelper, add default OtherRatio("n") when adaptor hasn't
  set one; set fallback tokens to 1 (base unit)
- Restore Ali adaptor's AddOtherRatio("n") — it uses actual upstream
  parameters/response count, preventing billing evasion
2026-03-31 23:58:10 +08:00
CaIon 670abee2f0 fix(EditChannelModal): enhance clipboard handling with error checks
Added checks to ensure clipboard functionality is available before attempting to read from it. Improved error handling during clipboard read operations to prevent unhandled exceptions.
2026-03-31 21:42:36 +08:00
CaIon 8bb9a42f68 feat: add clipboard magic string for quick channel creation from token copy
When copying a token, users can now choose "Copy Connection String" which
encodes both the API key and server URL as a JSON clipboard payload
(type: newapi_channel_conn). When opening the channel creation form, the
clipboard is auto-detected and a banner offers to fill key + base_url,
eliminating repeated tab-switching when connecting to another new-api instance.
2026-03-31 19:39:23 +08:00
CaIon d22f889e5d fix(xAI): set MaxTokens to nil when MaxCompletionTokens is 0 for grok-3-mini model 2026-03-31 19:16:16 +08:00
Calcium-Ion 3734059da7 Merge pull request #3462 from DaZuiZui/main
docs(zh-TW): fix missing content and add partner logo
2026-03-31 19:02:15 +08:00
Calcium-Ion 26ce873f8b Merge pull request #3474 from wans10/main
fix(dashboard): 修复消耗分布图表悬浮时滚动条闪烁
2026-03-31 18:57:16 +08:00
CaIon e099117c61 refactor: use POST for account binding endpoints and normalize reset responses
- Switch /api/oauth/email/bind and /api/oauth/wechat/bind from GET to
  POST with JSON body for better REST semantics
- Normalize password reset endpoint to return consistent responses
- Apply url.QueryEscape to WeChat code parameter for robustness
2026-03-31 18:44:40 +08:00
CaIon 310d618a16 style: enhance footer layout and add custom class for styling
- Refactor Footer component to use a semantic <footer> element for better accessibility.
- Update CSS to include a new class for the custom footer, allowing for relative positioning.
- Adjust layout to improve responsiveness and visual alignment of footer content.
2026-03-31 18:41:44 +08:00
CaIon 20399d3c8f fix: harden SSRF protection for unauthenticated and user-level endpoints
- Add ValidateURLWithFetchSetting check before fetching MJ image URLs
  in RelayMidjourneyImage (unauthenticated endpoint)
- Add ValidateURLWithFetchSetting check before fetching video URLs
  in VideoProxy (upstream-controlled URL)
- Enable ApplyIPFilterForDomain by default to prevent DNS rebinding
  bypass of SSRF protection
- Elevate FetchModels endpoint from AdminAuth to RootAuth
- Update frontend: mark domain IP filtering as recommended, update
  description and i18n translations (zh-CN/zh-TW/en/fr/ja/ru/vi)
2026-03-31 17:57:47 +08:00
刘泓宾 53aeee4ff7 Comment out price data adjustment logic
Comment out code that modifies price data based on image count.
测试发现,如果是接入阿里百炼平台的qwen-image-2.0系列模型,这边计费的时候会出现 0.2*n*倍率*n的情况,最前面的0.2*n会直接显示为模型价格。
例如:
日志详情	模型价格 $0.600000,专属倍率 0.3
其他详情	大小 1080*1920, 品质 standard, 生成数量 3, 其他倍率 n: 3.000000
计费过程	模型价格:$0.600000 * 专属倍率:0.3 = $0.180000
2026-03-31 17:12:06 +08:00
wans10 b2dd4acc9f fix(dashboard): 修复消耗分布图表悬浮时滚动条闪烁 2026-03-28 12:14:22 +08:00
哇塞大嘴好帥 4e492b26f6 Update README.zh_TW.md 2026-03-27 17:34:28 +08:00
哇塞大嘴好帥 82b750398c Update README.zh_TW.md 2026-03-27 17:15:12 +08:00
dependabot[bot] 814a3f5124 chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in /electron
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 07:58:57 +00:00
33 changed files with 428 additions and 129 deletions
+11 -8
View File
@@ -70,17 +70,20 @@
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
</a><!--
--><a href="https://github.com/iOfficeAI/AionUi/" target="_blank">
<img src="./docs/images/aionui.png" alt="Aion UI" height="80" />
</a><!--
--><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大學" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
</a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
</a><!--
--><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里雲" height="80" />
</a>
<a href="https://io.net/" target="_blank">
</a><!--
--><a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
+14 -21
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
@@ -116,7 +117,6 @@ func GetStatus(c *gin.Context) {
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"_qn": "new-api",
}
// 根据启用状态注入可选内容
@@ -308,31 +308,24 @@ func SendPasswordResetEmail(c *gin.Context) {
})
return
}
if !model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该邮箱地址未注册",
})
return
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
return
if model.IsEmailAlreadyTaken(email) {
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type PasswordResetRequest struct {
+1 -1
View File
@@ -46,7 +46,7 @@ func GetPricing(c *gin.Context) {
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"_": "a42d372ccf0b5dd13ecf71203521f9d2",
"pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2",
})
}
+12 -2
View File
@@ -925,9 +925,19 @@ func ManageUser(c *gin.Context) {
return
}
type emailBindRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func EmailBind(c *gin.Context) {
email := c.Query("email")
code := c.Query("code")
var req emailBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiError(c, errors.New("invalid request body"))
return
}
email := req.Email
code := req.Code
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
+9
View File
@@ -10,10 +10,12 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
@@ -127,6 +129,13 @@ func VideoProxy(c *gin.Context) {
return
}
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err))
videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err))
return
}
req.URL, err = url.Parse(videoURL)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
+15 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
@@ -25,7 +26,7 @@ func getWeChatIdByCode(code string) (string, error) {
if code == "" {
return "", errors.New("无效的参数")
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, url.QueryEscape(code)), nil)
if err != nil {
return "", err
}
@@ -121,6 +122,10 @@ func WeChatAuth(c *gin.Context) {
setupLogin(&user, c)
}
type wechatBindRequest struct {
Code string `json:"code"`
}
func WeChatBind(c *gin.Context) {
if !common.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{
@@ -129,7 +134,15 @@ func WeChatBind(c *gin.Context) {
})
return
}
code := c.Query("code")
var req wechatBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的请求",
})
return
}
code := req.Code
wechatId, err := getWeChatIdByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
+5 -6
View File
@@ -148,15 +148,14 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
}
// not support token count for dalle
n := uint(1)
if i.N != nil {
n = *i.N
}
// n is NOT included here; it is handled via OtherRatio("n") in
// image_handler.go (default) or channel adaptors (actual count).
// Including n here caused double-counting for channels that also
// set OtherRatio("n") (e.g. Ali/Bailian).
return &types.TokenCountMeta{
CombineText: i.Prompt,
MaxTokens: 1584,
ImagePriceRatio: sizeRatio * qualityRatio * float64(n),
ImagePriceRatio: sizeRatio * qualityRatio,
}
}
Generated Vendored
+3 -3
View File
@@ -3948,9 +3948,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+11 -6
View File
@@ -171,12 +171,17 @@ type AliImageRequest struct {
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
+1 -2
View File
@@ -54,7 +54,6 @@ func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequ
}
}
// 检查n参数
if imageRequest.Parameters.N != 0 {
info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N))
}
@@ -181,6 +180,7 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
},
}
imageRequest.Parameters = AliImageParameters{
N: int(lo.FromPtrOr(request.N, uint(1))),
Watermark: request.Watermark,
}
return &imageRequest, nil
@@ -328,7 +328,6 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela
}
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
// 可能生成多张图片,修正计费数量n
if aliResponse.Usage.ImageCount != 0 {
info.PriceData.AddOtherRatio("n", float64(aliResponse.Usage.ImageCount))
} else if len(imageResponses.Data) != 0 {
+2 -1
View File
@@ -40,7 +40,8 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ
}
func isOldWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") && !strings.Contains(modelName, "wan2.6")
return strings.Contains(modelName, "wan") &&
!lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) })
}
func isWanModel(modelName string) bool {
+1 -1
View File
@@ -76,7 +76,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if strings.HasPrefix(request.Model, "grok-3-mini") {
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = lo.ToPtr(uint(0))
request.MaxTokens = nil
}
if strings.HasSuffix(request.Model, "-high") {
request.ReasoningEffort = "high"
+11 -2
View File
@@ -117,11 +117,20 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if request.N != nil {
imageN = *request.N
}
// n is handled via OtherRatio so it is applied exactly once in quota
// calculation (both price-based and ratio-based paths).
// Adaptors may have already set a more accurate count from the
// upstream response; only set the default when they haven't.
if _, hasN := info.PriceData.OtherRatios["n"]; !hasN {
info.PriceData.AddOtherRatio("n", float64(imageN))
}
if usage.(*dto.Usage).TotalTokens == 0 {
usage.(*dto.Usage).TotalTokens = int(imageN)
usage.(*dto.Usage).TotalTokens = 1
}
if usage.(*dto.Usage).PromptTokens == 0 {
usage.(*dto.Usage).PromptTokens = int(imageN)
usage.(*dto.Usage).PromptTokens = 1
}
quality := "standard"
+7
View File
@@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) {
if httpClient == nil {
httpClient = service.GetHttpClient()
}
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
c.JSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("request blocked: %v", err),
})
return
}
resp, err := httpClient.Get(midjourneyTask.ImageUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+3 -3
View File
@@ -36,10 +36,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
// Non-standard OAuth (WeChat, Telegram) - keep original routes
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
@@ -226,7 +226,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/batch", controller.DeleteChannelBatch)
channelRoute.POST("/fix", controller.FixChannelsAbilities)
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels)
channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth)
channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth)
channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel)
+1
View File
@@ -17,6 +17,7 @@ var defaultQwenSettings = QwenSettings{
"z-image",
"qwen-image",
"wan2.6",
"wan2.7",
"qwen-image-edit",
"qwen-image-edit-max",
"qwen-image-edit-max-2026-01-16",
+1 -1
View File
@@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
ApplyIPFilterForDomain: false,
ApplyIPFilterForDomain: true,
}
func init() {
+20 -16
View File
@@ -221,23 +221,27 @@ const FooterBar = () => {
return (
<div className='w-full'>
{footer ? (
<div className='relative'>
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
<span>{t('设计与开发由')} </span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
<footer className='relative h-auto py-4 px-6 md:px-24 w-full flex items-center justify-center overflow-hidden'>
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-4'>
<div
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='text-sm flex-shrink-0'>
<span className='!text-semi-color-text-1'>
{t('设计与开发由')}{' '}
</span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
</div>
</div>
</div>
</footer>
) : (
customFooter
)}
@@ -306,9 +306,9 @@ const PersonalSetting = () => {
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
);
const res = await API.post('/api/oauth/wechat/bind', {
code: inputs.wechat_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('微信账户绑定成功!'));
@@ -378,9 +378,10 @@ const PersonalSetting = () => {
return;
}
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
);
const res = await API.post('/api/oauth/email/bind', {
email: inputs.email,
code: inputs.email_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('邮箱账户绑定成功!'));
@@ -108,7 +108,7 @@ const SystemSetting = () => {
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
'fetch_setting.apply_ip_filter_for_domain': false,
'fetch_setting.apply_ip_filter_for_domain': true,
});
const [originInputs, setOriginInputs] = useState({});
@@ -847,7 +847,7 @@ const SystemSetting = () => {
}
style={{ marginBottom: 8 }}
>
{t('对域名启用 IP 过滤(实验性')}
{t('对域名启用 IP 过滤(推荐开启')}
</Form.Checkbox>
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
@@ -67,6 +67,7 @@ import SecureVerificationModal from '../../../common/modals/SecureVerificationMo
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
import { parseChannelConnectionString } from '../../../../helpers/token';
import { createApiCalls } from '../../../../services/secureVerification';
import {
collectInvalidStatusCodeEntries,
@@ -102,6 +103,7 @@ const REGION_EXAMPLE = {
'claude-3-5-sonnet-20240620': 'europe-west1',
};
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8;
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded';
const PARAM_OVERRIDE_LEGACY_TEMPLATE = {
temperature: 0,
@@ -398,8 +400,15 @@ const EditChannelModal = (props) => {
[],
);
// 剪贴板连接信息自动检测
const [clipboardConfig, setClipboardConfig] = useState(null);
// 高级设置折叠状态
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false);
const toggleAdvancedSettings = (open) => {
setAdvancedSettingsOpen(open);
localStorage.setItem(ADVANCED_SETTINGS_EXPANDED_KEY, String(open));
};
const formContainerRef = useRef(null);
const doubaoApiClickCountRef = useRef(0);
const initialBaseUrlRef = useRef('');
@@ -538,6 +547,39 @@ const EditChannelModal = (props) => {
handleInputChange('settings', settingsJson);
};
const applyClipboardConfig = (config) => {
if (!config) return;
setInputs((prev) => ({
...prev,
key: config.key,
base_url: config.url,
}));
if (formApiRef.current) {
formApiRef.current.setValue('key', config.key);
formApiRef.current.setValue('base_url', config.url);
}
setClipboardConfig(null);
showSuccess(t('连接信息已填入'));
};
const pasteFromClipboard = async () => {
if (!navigator?.clipboard?.readText) {
showError(t('无法读取剪贴板'));
return;
}
try {
const text = await navigator.clipboard.readText();
const parsed = parseChannelConnectionString(text);
if (parsed) {
applyClipboardConfig(parsed);
} else {
showInfo(t('剪贴板中未检测到连接信息'));
}
} catch {
showError(t('无法读取剪贴板'));
}
};
const isIonetLocked = isIonetChannel && isEdit;
const handleInputChange = (name, value) => {
@@ -1269,12 +1311,22 @@ const EditChannelModal = (props) => {
loadChannel();
} else {
formApiRef.current?.setValues(getInitValues());
try {
navigator?.clipboard?.readText()?.then((text) => {
const parsed = parseChannelConnectionString(text);
if (parsed) {
setClipboardConfig(parsed);
}
}).catch(() => {});
} catch {}
}
fetchModelGroups();
// 重置手动输入模式状态
setUseManualInput(false);
// 重置高级设置折叠状态
setAdvancedSettingsOpen(false);
// 编辑模式下恢复用户偏好,创建模式一律折叠
setAdvancedSettingsOpen(
isEdit && localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true'
);
} else {
// 统一的模态框关闭重置逻辑
resetModalState();
@@ -1329,6 +1381,8 @@ const EditChannelModal = (props) => {
setInputs(getInitValues());
// 重置密钥显示状态
resetKeyDisplayState();
// 重置剪贴板检测状态
setClipboardConfig(null);
};
const handleVertexUploadChange = ({ fileList }) => {
@@ -2077,14 +2131,27 @@ const EditChannelModal = (props) => {
<SideSheet
placement={isEdit ? 'right' : 'left'}
title={
<Space>
<Tag color='blue' shape='circle'>
{isEdit ? t('编辑') : t('新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('更新渠道信息') : t('创建新的渠道')}
</Title>
</Space>
<div className='flex items-center justify-between w-full'>
<Space>
<Tag color='blue' shape='circle'>
{isEdit ? t('编辑') : t('新建')}
</Tag>
<Title heading={4} className='m-0'>
{isEdit ? t('更新渠道信息') : t('创建新的渠道')}
</Title>
</Space>
{!isEdit && (
<Button
size='small'
type='tertiary'
className='ec-dbcd0a3c01b55203 shrink-0'
icon={<IconBolt />}
onClick={pasteFromClipboard}
>
{t('从剪贴板粘贴配置')}
</Button>
)}
</div>
}
bodyStyle={{ padding: '0' }}
visible={props.visible}
@@ -2446,6 +2513,34 @@ const EditChannelModal = (props) => {
<>
<Spin spinning={loading}>
<div className='p-2 space-y-3' ref={formContainerRef}>
{!isEdit && clipboardConfig && (
<Banner
type='info'
className='ec-dbcd0a3c01b55203'
description={
<div className='flex items-center justify-between gap-2'>
<span>{t('检测到剪贴板中的连接信息')}</span>
<div className='flex gap-1'>
<Button
size='small'
theme='solid'
type='primary'
onClick={() => applyClipboardConfig(clipboardConfig)}
>
{t('自动填入')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => setClipboardConfig(null)}
>
{t('忽略')}
</Button>
</div>
</div>
}
/>
)}
{/* Core Configuration Card - Always Visible */}
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header */}
@@ -3548,7 +3643,7 @@ const EditChannelModal = (props) => {
{isMobile ? (
<Collapse
activeKey={advancedSettingsOpen ? ['advanced'] : []}
onChange={(keys) => setAdvancedSettingsOpen(keys.includes('advanced'))}
onChange={(keys) => toggleAdvancedSettings(keys.includes('advanced'))}
>
<Collapse.Panel
header={
@@ -3570,7 +3665,7 @@ const EditChannelModal = (props) => {
backgroundColor: advancedSettingsOpen ? 'var(--semi-color-primary-light-default)' : 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-fill-2)',
}}
onClick={() => setAdvancedSettingsOpen(!advancedSettingsOpen)}
onClick={() => toggleAdvancedSettings(!advancedSettingsOpen)}
>
<div className='flex items-center gap-2'>
<IconSetting size={16} />
@@ -116,6 +116,8 @@ const renderTokenKey = (
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
t,
) => {
const revealed = !!showKeys[record.id];
const loading = !!loadingTokenKeys[record.id];
@@ -145,18 +147,35 @@ const renderTokenKey = (
await toggleTokenVisibility(record);
}}
/>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyTokenKey(record);
}}
/>
<Dropdown
trigger='click'
position='bottomRight'
clickToHide
menu={[
{
node: 'item',
name: t('复制密钥'),
onClick: () => copyTokenKey(record),
},
{
node: 'item',
name: t('复制连接信息'),
onClick: () => copyTokenConnectionString(record),
},
]}
>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
}}
/>
</Dropdown>
</div>
}
/>
@@ -444,6 +463,7 @@ export const getTokensColumns = ({
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -484,6 +504,8 @@ export const getTokensColumns = ({
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
t,
),
},
{
@@ -43,6 +43,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -60,6 +61,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -73,6 +75,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
+38
View File
@@ -80,3 +80,41 @@ export function getServerAddress() {
return serverAddress;
}
export const CHANNEL_CONN_CLIPBOARD_TYPE = 'newapi_channel_conn';
/**
* @param {string} key - 完整的 API key(含 sk- 前缀)
* @param {string} url - 服务器地址
* @returns {string} JSON 格式的连接字符串
*/
export function encodeChannelConnectionString(key, url) {
return JSON.stringify({
_type: CHANNEL_CONN_CLIPBOARD_TYPE,
key,
url,
});
}
/**
* @param {string} text - 剪贴板文本
* @returns {{ key: string, url: string } | null}
*/
export function parseChannelConnectionString(text) {
if (!text || typeof text !== 'string') return null;
try {
const parsed = JSON.parse(text.trim());
if (
parsed &&
typeof parsed === 'object' &&
parsed._type === CHANNEL_CONN_CLIPBOARD_TYPE &&
typeof parsed.key === 'string' &&
typeof parsed.url === 'string'
) {
return { key: parsed.key, url: parsed.url };
}
} catch {
// not valid JSON
}
return null;
}
+13 -1
View File
@@ -29,7 +29,11 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
import {
fetchTokenKey as fetchTokenKeyById,
getServerAddress,
encodeChannelConnectionString,
} from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
@@ -198,6 +202,13 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
await copyText(`sk-${fullKey}`);
};
const copyTokenConnectionString = async (record) => {
const fullKey = await fetchTokenKey(record);
const serverUrl = getServerAddress();
const connStr = encodeChannelConnectionString(`sk-${fullKey}`, serverUrl);
await copyText(connStr);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
@@ -465,6 +476,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
onOpenLink,
manageToken,
searchTokens,
+12 -3
View File
@@ -935,7 +935,7 @@
"在此输入系统名称": "Enter the system name here",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Enter the home page content here, supports Markdown",
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
"域名IP过滤详细说明": "Recommended: When enabled, domains are resolved via DNS and the resulting IPs are checked against private address ranges, effectively preventing DNS rebinding attacks that bypass SSRF protection. Note: A domain may resolve to multiple IPv4/IPv6 addresses. If you have configured an IP filter list, ensure it covers these addresses, otherwise access may fail.",
"域名白名单": "Domain Whitelist",
"域名黑名单": "Domain Blacklist",
"基本信息": "Basic Information",
@@ -1105,7 +1105,7 @@
"密钥预览": "Key preview",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "Enable IP filtering for domains (experimental)",
"对域名启用 IP 过滤(推荐开启": "Enable IP filtering for domains (recommended)",
"对外运营模式": "Default mode",
"对象清理规则": "Object Pruning Rules",
"导入": "Import",
@@ -3352,6 +3352,15 @@
"输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens",
"例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$",
"支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching."
"支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching.",
"复制密钥": "Copy Key",
"复制连接信息": "Copy Connection String",
"检测到剪贴板中的连接信息": "Connection info detected in clipboard",
"自动填入": "Auto-fill",
"忽略": "Ignore",
"从剪贴板粘贴配置": "Paste Config",
"剪贴板中未检测到连接信息": "No connection info found in clipboard",
"连接信息已填入": "Connection info applied",
"无法读取剪贴板": "Cannot read clipboard"
}
}
+12 -3
View File
@@ -928,7 +928,7 @@
"在此输入系统名称": "Saisissez le nom du système ici",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez le contenu de la politique de confidentialité ici, prend en charge le code Markdown & HTML",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Saisissez le contenu de la page d'accueil ici, prend en charge Markdown & HTML. Après configuration, les informations d'état de la page d'accueil ne seront plus affichées. Si un lien est saisi, il sera utilisé comme attribut src de l'iframe, ce qui vous permet de définir n'importe quelle page web comme page d'accueil",
"域名IP过滤详细说明": "⚠️ Il s'agit d'une option expérimentale. Un domaine peut se résoudre en plusieurs adresses IPv4/IPv6. Si cette option est activée, assurez-vous que la liste de filtres IP couvre ces adresses, sinon l'accès peut échouer.",
"域名IP过滤详细说明": "Recommandé : lorsqu'il est activé, les domaines sont résolus par DNS et les IP résultantes sont vérifiées par rapport aux plages d'adresses privées, ce qui empêche efficacement les attaques de DNS rebinding qui contournent la protection SSRF. Remarque : un domaine peut se résoudre en plusieurs adresses IPv4/IPv6. Si vous avez configuré une liste de filtres IP, assurez-vous qu'elle couvre ces adresses, sinon l'accès peut échouer.",
"域名白名单": "Liste blanche de domaines",
"域名黑名单": "Liste noire de domaines",
"基本信息": "Informations de base",
@@ -1097,7 +1097,7 @@
"密钥预览": "Aperçu de la clé",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir",
"对免费模型启用预消耗": "Activer la préconsommation pour les modèles gratuits",
"对域名启用 IP 过滤(实验性": "Activer le filtrage IP pour les domaines (expérimental)",
"对域名启用 IP 过滤(推荐开启": "Activer le filtrage IP pour les domaines (recommandé)",
"对外运营模式": "Mode par défaut",
"对象清理规则": "Règles de nettoyage d'objets",
"导入": "Importer",
@@ -3308,6 +3308,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens",
"复制密钥": "Copier la clé",
"复制连接信息": "Copier les infos de connexion",
"检测到剪贴板中的连接信息": "Informations de connexion détectées dans le presse-papiers",
"自动填入": "Remplir auto",
"忽略": "Ignorer",
"从剪贴板粘贴配置": "Coller la config",
"剪贴板中未检测到连接信息": "Aucune info de connexion trouvée dans le presse-papiers",
"连接信息已填入": "Informations de connexion appliquées",
"无法读取剪贴板": "Impossible de lire le presse-papiers"
}
}
+12 -3
View File
@@ -919,7 +919,7 @@
"在此输入系统名称": "システム名称を入力してください",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "プライバシーポリシーのコンテンツを入力してください。MarkdownとHTMLコードに対応しています",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "ホームのコンテンツを入力してください。MarkdownとHTMLに対応しています。設定後は、ホームのステータス情報が表示されなくなります。リンクを入力した場合は、そのリンクがiframeのsrc属性として使用され、任意のWebページをホームとして設定できます",
"域名IP过滤详细说明": "ドメインIPフィルタリングの詳細説明",
"域名IP过滤详细说明": "推奨:有効にすると、ドメインをDNS解決し、解決されたIPがプライベートアドレスかどうかを確認します。DNSリバインディング攻撃によるSSRF防護の回避を効果的に防止できます。注意:ドメインは複数のIPv4/IPv6アドレスに解決される場合があります。IPフィルタリストを設定している場合は、これらのアドレスをカバーしていることを確認してください。",
"域名白名单": "ドメインホワイトリスト",
"域名黑名单": "ドメインブラックリスト",
"基本信息": "基本情報",
@@ -1088,7 +1088,7 @@
"密钥预览": "APIキーのプレビュー",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "公式チャネルの場合、new-apiにはベースURLが組み込まれているため、サードパーティのプロキシサイトやAzureの専用のエンドポイントでない限り、入力する必要はありません。",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "ドメインのIPフィルタリングを有効にする(実験的",
"对域名启用 IP 过滤(推荐开启": "ドメインのIPフィルタリングを有効にする(推奨",
"对外运营模式": "公開運用モード",
"对象清理规则": "オブジェクトプルーニングルール",
"导入": "インポート",
@@ -3289,6 +3289,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "入力価格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "キーをコピー",
"复制连接信息": "接続情報をコピー",
"检测到剪贴板中的连接信息": "クリップボードに接続情報が検出されました",
"自动填入": "自動入力",
"忽略": "無視",
"从剪贴板粘贴配置": "クリップボードから貼り付け",
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
"连接信息已填入": "接続情報を入力しました",
"无法读取剪贴板": "クリップボードを読み取れません"
}
}
+12 -3
View File
@@ -934,7 +934,7 @@
"在此输入系统名称": "Введите здесь название системы",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Введите здесь содержимое политики конфиденциальности, поддерживается Markdown & HTML код",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Введите здесь содержание главной страницы, поддерживается код Markdown и HTML. После настройки информация о состоянии на главной странице больше не будет отображаться. Если введена ссылка, она будет использована как атрибут src для iframe, что позволяет установить любую веб-страницу как главную страницу",
"域名IP过滤详细说明": "⚠️ Эта функция является экспериментальной опцией, доменное имя может быть разрешено в несколько адресов IPv4/IPv6, если включено, убедитесь, что список фильтрации IP покрывает эти адреса, иначе это может привести к сбою доступа.",
"域名IP过滤详细说明": "Рекомендуется: при включении домены разрешаются через DNS, а полученные IP-адреса проверяются на принадлежность к частным диапазонам, что эффективно предотвращает атаки DNS rebinding, обходящие защиту SSRF. Примечание: домен может разрешаться в несколько адресов IPv4/IPv6. Если вы настроили список фильтрации IP, убедитесь, что он покрывает эти адреса, иначе доступ может быть нарушен.",
"域名白名单": "Белый список доменов",
"域名黑名单": "Чёрный список доменов",
"基本信息": "Основная информация",
@@ -1103,7 +1103,7 @@
"密钥预览": "Предпросмотр ключа",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Для официальных каналов new-api уже имеет встроенные адреса, если это не сторонние прокси-сайты или специальные адреса доступа Azure, заполнять не нужно",
"对免费模型启用预消耗": "Включить предварительное списание для бесплатных моделей",
"对域名启用 IP 过滤(实验性": "Включить IP-фильтрацию для доменов (экспериментально)",
"对域名启用 IP 过滤(推荐开启": "Включить IP-фильтрацию для доменов (рекомендуется)",
"对外运营模式": "Режим внешней эксплуатации",
"对象清理规则": "Правила очистки объектов",
"导入": "Импорт",
@@ -3322,6 +3322,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Цена ввода: {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens",
"复制密钥": "Копировать ключ",
"复制连接信息": "Копировать данные подключения",
"检测到剪贴板中的连接信息": "В буфере обмена обнаружены данные подключения",
"自动填入": "Заполнить",
"忽略": "Игнорировать",
"从剪贴板粘贴配置": "Вставить конфигурацию",
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
"连接信息已填入": "Данные подключения применены",
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
}
}
+12 -3
View File
@@ -920,7 +920,7 @@
"在此输入系统名称": "Nhập tên hệ thống tại đây",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Nhập nội dung chính sách bảo mật tại đây, hỗ trợ mã Markdown & HTML",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Nhập nội dung trang chủ tại đây, hỗ trợ Markdown",
"域名IP过滤详细说明": "⚠️ Đây là tùy chọn thử nghiệm. Một tên miền có thể phân giải thành nhiều địa chỉ IPv4/IPv6. Nếu bật, hãy đảm bảo danh sách lọc IP bao gồm các địa chỉ này, nếu không truy cập có thể thất bại.",
"域名IP过滤详细说明": "Khuyến nghị: Khi được bật, tên miền sẽ được phân giải DNS và các IP kết quả sẽ được kiểm tra xem có thuộc dải địa chỉ riêng tư hay không, ngăn chặn hiệu quả các cuộc tấn công DNS rebinding vượt qua bảo vệ SSRF. Lưu ý: Một tên miền có thể phân giải thành nhiều địa chỉ IPv4/IPv6. Nếu bạn đã cấu hình danh sách lọc IP, hãy đảm bảo nó bao gồm các địa chỉ này, nếu không truy cập có thể thất bại.",
"域名白名单": "Danh sách trắng tên miền",
"域名黑名单": "Danh sách đen tên miền",
"基本信息": "Thông tin cơ bản",
@@ -1089,7 +1089,7 @@
"密钥预览": "Xem trước khóa",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Đối với các kênh chính thức, new-api đã tích hợp sẵn địa chỉ. Trừ khi đó là trang web proxy của bên thứ ba hoặc địa chỉ truy cập đặc biệt của Azure, không cần điền vào",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "Bật lọc IP cho tên miền (thử nghiệm)",
"对域名启用 IP 过滤(推荐开启": "Bật lọc IP cho tên miền (khuyến ngh)",
"对外运营模式": "Chế độ mặc định",
"对象清理规则": "Quy tắc dọn dẹp đối tượng",
"导入": "Nhập",
@@ -3859,6 +3859,15 @@
"补全倍率 {{completionRatio}}": "Tỷ lệ hoàn thành {{completionRatio}}",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu ra {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu ra: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens",
"复制密钥": "Sao chép khóa",
"复制连接信息": "Sao chép thông tin kết nối",
"检测到剪贴板中的连接信息": "Phát hiện thông tin kết nối trong bộ nhớ tạm",
"自动填入": "Tự động điền",
"忽略": "Bỏ qua",
"从剪贴板粘贴配置": "Dán cấu hình",
"剪贴板中未检测到连接信息": "Không tìm thấy thông tin kết nối trong bộ nhớ tạm",
"连接信息已填入": "Đã áp dụng thông tin kết nối",
"无法读取剪贴板": "Không thể đọc bộ nhớ tạm"
}
}
+12 -3
View File
@@ -735,7 +735,7 @@
"在此输入系统名称": "在此输入系统名称",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页",
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
"域名IP过滤详细说明": "推荐开启:开启后会对域名进行 DNS 解析并检查解析后的 IP 是否为私有地址,可有效防止 DNS 重绑定攻击绕过 SSRF 防护。注意:域名可能解析到多个 IPv4/IPv6 地址,若配置了 IP 过滤列表,请确保覆盖这些地址,否则可能导致访问失败。",
"域名白名单": "域名白名单",
"域名黑名单": "域名黑名单",
"基本信息": "基本信息",
@@ -870,7 +870,7 @@
"密钥预览": "密钥预览",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写",
"对免费模型启用预消耗": "对免费模型启用预消耗",
"对域名启用 IP 过滤(实验性": "对域名启用 IP 过滤(实验性",
"对域名启用 IP 过滤(推荐开启": "对域名启用 IP 过滤(推荐开启",
"对外运营模式": "对外运营模式",
"导入": "导入",
"导入的配置将覆盖当前设置,是否继续?": "导入的配置将覆盖当前设置,是否继续?",
@@ -2956,6 +2956,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "复制密钥",
"复制连接信息": "复制连接信息",
"检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
"自动填入": "自动填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
}
}
+12 -3
View File
@@ -737,7 +737,7 @@
"在此输入系统名称": "在此輸入系統名稱",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此輸入隱私政策內容,支援 Markdown & HTML 程式碼",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此輸入首頁內容,支援 Markdown & HTML 程式碼,設定後首頁的狀態訊息將不再顯示。如果輸入的是一個連結,則會使用該連結作為 iframe 的 src 屬性,這允許你設定任意網頁作為首頁",
"域名IP过滤详细说明": "⚠️此功能為實驗性選項,域名可能解析到多個 IPv4/IPv6 位址,若開啟,請確保 IP 過濾列表覆蓋這些位址,否則可能導致訪問失敗。",
"域名IP过滤详细说明": "建議開啟:開啟後會對域名進行 DNS 解析並檢查解析後的 IP 是否為私有位址,可有效防止 DNS 重綁定攻擊繞過 SSRF 防護。注意:域名可能解析到多個 IPv4/IPv6 位址,若配置了 IP 過濾列表,請確保覆蓋這些位址,否則可能導致訪問失敗。",
"域名白名单": "域名白名單",
"域名黑名单": "域名黑名單",
"基本信息": "基本資訊",
@@ -873,7 +873,7 @@
"密钥预览": "密鑰預覽",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "對於官方管道,new-api已經內置位址,除非是第三方代理站點或者Azure的特殊接入位址,否則不需要填寫",
"对免费模型启用预消耗": "對免費模型啟用預消耗",
"对域名启用 IP 过滤(实验性": "對域名啟用 IP 過濾(實驗性",
"对域名启用 IP 过滤(推荐开启": "對域名啟用 IP 過濾(建議開啟",
"对外运营模式": "對外運營模式",
"导入": "導入",
"导入的配置将覆盖当前设置,是否继续?": "導入的設定將覆蓋當前設定,是否繼續?",
@@ -2973,6 +2973,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "輸入價格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "複製金鑰",
"复制连接信息": "複製連線資訊",
"检测到剪贴板中的连接信息": "偵測到剪貼簿中的連線資訊",
"自动填入": "自動填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "從剪貼簿貼上設定",
"剪贴板中未检测到连接信息": "剪貼簿中未偵測到連線資訊",
"连接信息已填入": "連線資訊已填入",
"无法读取剪贴板": "無法讀取剪貼簿"
}
}
+12
View File
@@ -31,6 +31,13 @@ body {
background-color: var(--semi-color-bg-0);
}
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
}
.app-layout {
height: 100vh;
height: 100dvh;
@@ -469,6 +476,9 @@ html.dark .sbg-variant-green {
.custom-footer {
font-size: 1.1em;
}
.custom-footer.na-cb6feafeb3990c78 {
position: relative;
}
/* 卡片内容容器通用样式 */
.card-content-container {
@@ -994,3 +1004,5 @@ html.dark .with-pastel-balls::before {
opacity: 1;
}
}
.ec-dbcd0a3c01b55203 { forced-color-adjust: auto; }